Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 50 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ Once enabled, every Codex turn shows up in Langfuse as a trace you can inspect,

After each Codex turn, the plugin reads the session's rollout transcript and uploads it to Langfuse as a [trace](https://langfuse.com/docs/observability/data-model). The structure mirrors how Codex actually works:

- **Turn** (`Codex Turn`, an [agent observation](https://langfuse.com/docs/observability/features/observation-types)) — one trace per turn, from your prompt to the final answer.
- **Turn** (`Codex Turn`, an [agent observation](https://langfuse.com/docs/observability/features/observation-types)) — one trace per turn by default, or a child of an existing application span in [attached mode](#attach-to-an-existing-trace), from your prompt to the final answer.
- **Generations** — one per model response within the turn, named `LLM` (or `LLM Subagent` inside subagent threads), with the model recorded on the observation plus reasoning, assistant text, the tool calls it requested, and token usage.
- **Tool calls** — shell commands, `apply_patch`, `spawn_agent`, MCP tools, web searches, etc., each with its input, output, and error status. MCP calls are named `server.tool`, and failed commands are flagged as errors.
- **Subagents** — subagent threads are resolved from their own rollout files and nested under the spawning turn as `Codex Subagent Turn`.
- **Sessions** — all turns from one Codex session are grouped via the Codex thread id, so you can replay the whole session in Langfuse's [Sessions](https://langfuse.com/docs/observability/features/sessions) view.
- **Sessions** — in standalone mode, all turns from one Codex session are grouped via the Codex thread id, so you can replay the whole session in Langfuse's [Sessions](https://langfuse.com/docs/observability/features/sessions) view.

Interrupted turns (where you cancel mid-response) are still uploaded and flagged as interrupted.

Expand Down Expand Up @@ -101,20 +101,21 @@ codex plugin list

## Environment variables

| Variable | Required | Default | Description |
| ------------------------------------------------------------- | -------- | ---------------------------- | -------------------------------------------------------------------- |
| `TRACE_TO_LANGFUSE` | Yes | `false` | Set to `"true"` to enable tracing |
| `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_CODEX_PUBLIC_KEY` | Yes | — | Langfuse public key (`pk-lf-...`) |
| `LANGFUSE_SECRET_KEY` / `LANGFUSE_CODEX_SECRET_KEY` | Yes | — | Langfuse secret key (`sk-lf-...`) |
| `LANGFUSE_BASE_URL` / `LANGFUSE_CODEX_BASE_URL` | No | `https://cloud.langfuse.com` | Langfuse host / data region |
| `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_CODEX_ENVIRONMENT` | No | — | Environment label for the traces (e.g. `production`) |
| `LANGFUSE_CODEX_USER_ID` | No | Codex auth email, if found | Attach a user id to all traces |
| `LANGFUSE_CODEX_TAGS` | No | — | Tags for all traces (JSON array or comma-separated) |
| `LANGFUSE_CODEX_METADATA` | No | — | JSON object of metadata to attach to all traces |
| `LANGFUSE_CODEX_TRACE_SEED` | No | — | Derive deterministic trace ids ([details](#deterministic-trace-ids)) |
| `LANGFUSE_CODEX_MAX_CHARS` | No | `20000` | Truncate inputs/outputs longer than this many characters |
| `LANGFUSE_CODEX_DEBUG` | No | `false` | Set to `"true"` for verbose logging to stderr |
| `LANGFUSE_CODEX_FAIL_ON_ERROR` | No | `false` | Set to `"true"` to make hook upload errors fail the hook |
| Variable | Required | Default | Description |
| ------------------------------------------------------------- | -------- | ---------------------------- | ------------------------------------------------------------------------------------- |
| `TRACE_TO_LANGFUSE` | Yes | `false` | Set to `"true"` to enable tracing |
| `LANGFUSE_PUBLIC_KEY` / `LANGFUSE_CODEX_PUBLIC_KEY` | Yes | — | Langfuse public key (`pk-lf-...`) |
| `LANGFUSE_SECRET_KEY` / `LANGFUSE_CODEX_SECRET_KEY` | Yes | — | Langfuse secret key (`sk-lf-...`) |
| `LANGFUSE_BASE_URL` / `LANGFUSE_CODEX_BASE_URL` | No | `https://cloud.langfuse.com` | Langfuse host / data region |
| `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_CODEX_ENVIRONMENT` | No | — | Environment label for the traces (e.g. `production`) |
| `LANGFUSE_CODEX_USER_ID` | No | Codex auth email, if found | Attach a user id to all traces |
| `LANGFUSE_CODEX_TAGS` | No | — | Tags for all traces (JSON array or comma-separated) |
| `LANGFUSE_CODEX_METADATA` | No | — | JSON object of metadata to attach to all traces |
| `LANGFUSE_CODEX_TRACEPARENT` | No | — | Attach turns beneath an existing W3C parent ([details](#attach-to-an-existing-trace)) |
| `LANGFUSE_CODEX_TRACE_SEED` | No | — | Derive deterministic trace ids ([details](#deterministic-trace-ids)) |
| `LANGFUSE_CODEX_MAX_CHARS` | No | `20000` | Truncate inputs/outputs longer than this many characters |
| `LANGFUSE_CODEX_DEBUG` | No | `false` | Set to `"true"` for verbose logging to stderr |
| `LANGFUSE_CODEX_FAIL_ON_ERROR` | No | `false` | Set to `"true"` to make hook upload errors fail the hook |

### Data regions

Expand All @@ -125,6 +126,36 @@ codex plugin list
| 🇯🇵 Japan | `https://jp.cloud.langfuse.com` |
| ⚕️ HIPAA | `https://hipaa.cloud.langfuse.com` |

## Attach to an existing trace

If another application launches a dedicated Codex process as part of an existing agent run, pass that run's [W3C Trace Context](https://www.w3.org/TR/trace-context/) in `LANGFUSE_CODEX_TRACEPARENT`:

```bash
TRACE_ID="0af7651916cd43dd8448eb211c80319c"
AGENT_RUN_SPAN_ID="b7ad6b7169203331"

LANGFUSE_CODEX_TRACEPARENT="00-${TRACE_ID}-${AGENT_RUN_SPAN_ID}-01" \
codex exec "your prompt"
```

The plugin then preserves its detailed observation tree while attaching it to the real application span:

```text
Master Agent Run
`-- Codex Turn
|-- LLM
| `-- exec_command
`-- Codex Subagent Turn
```

Use this mode only when one Master Agent Run owns one Codex/App Server process. The environment variable is process-scoped, so every top-level Codex turn in that process is attached directly to the same parent span. A long-lived App Server shared by unrelated runs requires request-level context propagation, which Codex Stop hooks do not currently expose.

`LANGFUSE_CODEX_TRACEPARENT` is intentionally runtime-only: set it on the child Codex process, not in `langfuse.json` or a persistent shell profile. The plugin does not read the unscoped `TRACEPARENT` variable or `tracestate`. If both `LANGFUSE_CODEX_TRACEPARENT` and `LANGFUSE_CODEX_TRACE_SEED` are set, the explicit parent takes precedence.

The Master application and this plugin must export to the same Langfuse project for the observations to appear in one trace. In attached mode, the Master application owns trace-level name, session, user, tags, and metadata; the plugin still records all Codex observation metadata, inputs, outputs, reasoning, usage, and errors.

The final trace flags are honored. A parent ending in `-01` exports the Codex observations; a parent ending in `-00` exports none of them, while completed turns are still written to the dedup sidecar as processed. Attached mode treats this upstream decision as authoritative even when `OTEL_TRACES_SAMPLER` is set. Standalone mode continues to honor the standard OpenTelemetry sampler environment variables. Invalid values fall back to `LANGFUSE_CODEX_TRACE_SEED` or an auto-generated trace; set `LANGFUSE_CODEX_FAIL_ON_ERROR=true` to reject them instead.

## Deterministic trace ids

By default, trace ids are auto-generated, and an external system (a CI harness, benchmark runner, or dataset-experiment service) that runs `codex exec` headlessly has to poll the Langfuse API to discover the trace a run produced. Set `LANGFUSE_CODEX_TRACE_SEED` (or `trace_seed` in `langfuse.json`) to make trace ids predictable instead:
Expand Down Expand Up @@ -212,10 +243,13 @@ The hook fails open: any tracing error is logged and swallowed so it never block
```bash
pnpm install
pnpm test # build, then run the test suite
pnpm run test:e2e # test a real Master span + bundled hook against Langfuse
pnpm run lint # prettier + tsc + build
pnpm run build # bundle the hook to plugins/tracing/dist/index.mjs
```

The opt-in E2E test requires `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` (plus `LANGFUSE_BASE_URL` for a non-EU or self-hosted instance). It creates one uniquely identified trace, validates the persisted parent-child tree through the public observations API, and deletes that trace before exiting. The regular test suite never sends data to Langfuse.

The hook ships as a single self-contained `plugins/tracing/dist/index.mjs`, because Codex runs the plugin without an install step and never installs its dependencies. The bundle is a build output and is not committed: `prepack` builds it when the npm package is published, so it travels in the tarball instead of in Git. `pnpm test` builds first, since the hook-command test executes the bundled hook.

### Releasing
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
"scripts": {
"build": "tsdown --config plugins/tracing/tsdown.config.ts",
"test": "pnpm run build && vitest run",
"test:e2e": "pnpm run build && vitest run --config vitest.e2e.config.ts",
"test:watch": "pnpm run build && vitest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint:tsc": "tsc --noEmit",
"lint": "pnpm run format:check && pnpm run lint:tsc && pnpm run build"
},
"dependencies": {
"@langfuse/core": "^5.4.1",
"@langfuse/otel": "^5.4.1",
"@langfuse/tracing": "^5.4.1",
"@opentelemetry/api": "^1.9.0",
Expand Down
8 changes: 6 additions & 2 deletions plugins/tracing/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getConfig } from "./config.js";
import { setupInstrumentation } from "./instrumentation.js";
import { readExternalParentSpanContext } from "./parent-context.js";
import { convertRollout } from "./trace.js";
import type { HookInput } from "./types.js";
import { debugLog, readStdin, setDebug } from "./utils.js";
Expand Down Expand Up @@ -44,9 +45,12 @@ export async function runHook(): Promise<void> {
return;
}

const instrumentation = setupInstrumentation(config);
const parentSpanContext = readExternalParentSpanContext(process.env, config.fail_on_error);
const instrumentation = setupInstrumentation(config, {
attached: parentSpanContext != null,
});
try {
await convertRollout(hookInput.transcript_path, { config });
await convertRollout(hookInput.transcript_path, { config, parentSpanContext });
} catch (error) {
debugLog("failed to convert rollout:", error);
if (config.fail_on_error) throw error;
Expand Down
20 changes: 18 additions & 2 deletions plugins/tracing/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { AlwaysOnSampler, ParentBasedSampler } from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";

import type { Config } from "./config.js";
Expand All @@ -8,6 +9,11 @@ export type Instrumentation = {
shutdown: () => Promise<void>;
};

type InstrumentationOptions = {
/** Whether spans are children of a process-level parent supplied by the launcher. */
attached?: boolean;
};

/**
* Configure an isolated OpenTelemetry tracer provider wired to Langfuse.
*
Expand All @@ -21,19 +27,29 @@ export type Instrumentation = {
* is far faster than one request per span — important for the hook's timeout
* budget. `shutdown()` below calls `forceFlush()` before the process exits.
*/
export function setupInstrumentation(config: Config): Instrumentation {
export function setupInstrumentation(
config: Config,
options: InstrumentationOptions = {},
): Instrumentation {
const spanProcessor = new LangfuseSpanProcessor({
publicKey: config.public_key,
secretKey: config.secret_key,
baseUrl: config.base_url,
environment: config.environment,
exportMode: "batched",
// The hook only ever creates Langfuse spans, so export all of them.
// The hook only creates Langfuse spans, so export every recorded span.
// Parent-based sampling below decides whether a span is recorded at all.
shouldExportSpan: () => true,
});

const provider = new NodeTracerProvider({
spanProcessors: [spanProcessor],
// Attached mode treats the launcher's sampled bit as authoritative.
// Standalone mode leaves this unset so standard OTEL_TRACES_SAMPLER
// configuration keeps working exactly as it did before attached mode.
...(options.attached
? { sampler: new ParentBasedSampler({ root: new AlwaysOnSampler() }) }
: {}),
});
provider.register();

Expand Down
27 changes: 27 additions & 0 deletions plugins/tracing/src/parent-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SpanContext } from "@opentelemetry/api";
import { parseTraceParent } from "@opentelemetry/core";

import { debugLog } from "./utils.js";

export const EXTERNAL_TRACEPARENT_ENV_VAR = "LANGFUSE_CODEX_TRACEPARENT";

/** Read the transient W3C parent context supplied by the process owner. */
export function readExternalParentSpanContext(
env: Readonly<Record<string, string | undefined>>,
failOnError: boolean,
): SpanContext | undefined {
const value = env[EXTERNAL_TRACEPARENT_ENV_VAR];
if (value === undefined) return undefined;

const parsed = parseTraceParent(value);
if (parsed) {
return { ...parsed, isRemote: true };
}

const error = new Error(`${EXTERNAL_TRACEPARENT_ENV_VAR} must be a valid W3C traceparent value`);
debugLog(
`invalid ${EXTERNAL_TRACEPARENT_ENV_VAR}; falling back to trace_seed or an auto-generated trace`,
);
if (failOnError) throw error;
return undefined;
}
63 changes: 41 additions & 22 deletions plugins/tracing/src/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ import type { Dirent } from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";

import { setLangfuseTraceIdInBaggage } from "@langfuse/core";
import {
createTraceId,
propagateAttributes,
startObservation,
type LangfuseGenerationAttributes,
type LangfuseObservation,
} from "@langfuse/tracing";
import { TraceFlags, type SpanContext } from "@opentelemetry/api";
import { context, TraceFlags, type SpanContext } from "@opentelemetry/api";

import type { Config } from "./config.js";
import { parseSession } from "./parse.js";
Expand Down Expand Up @@ -208,8 +209,8 @@ async function emitTurn(
config: Config;
rolloutFile: string;
parentObservation?: LangfuseObservation;
/** Pre-derived trace id for top-level turns (see seededTraceParent). */
seededParent?: SpanContext;
/** External or seed-derived parent context for top-level turns. */
parentSpanContext?: SpanContext;
},
): Promise<void> {
const clip = makeClip(ctx.config.max_chars);
Expand Down Expand Up @@ -238,7 +239,7 @@ async function emitTurn(
{
asType: "agent",
startTime: new Date(turn.startTime),
parentSpanContext: ctx.parentObservation?.otelSpan.spanContext() ?? ctx.seededParent,
parentSpanContext: ctx.parentObservation?.otelSpan.spanContext() ?? ctx.parentSpanContext,
},
);

Expand Down Expand Up @@ -329,7 +330,12 @@ function emitToolCall(
*/
export async function convertRollout(
rolloutFile: string,
options: { config: Config; parentObservation?: LangfuseObservation },
options: {
config: Config;
parentObservation?: LangfuseObservation;
/** Process-level parent owned by the application that launched Codex. */
parentSpanContext?: SpanContext;
},
): Promise<void> {
const { sessionMeta, turns } = parseSession(await loadSession(rolloutFile));
debugLog(`parsed ${turns.length} turn(s) from ${path.basename(rolloutFile)}`);
Expand All @@ -356,24 +362,37 @@ export async function convertRollout(

// Turn numbering stays 1-based over the full rollout (including turns
// skipped by dedup above) so the derived id is stable across hook runs.
const seededParent = await seededTraceParent(options.config, sessionMeta, turnIndex + 1);
const parentSpanContext =
options.parentSpanContext ??
(await seededTraceParent(options.config, sessionMeta, turnIndex + 1));
const emit = () =>
emitTurn(turn, sessionMeta, {
config: options.config,
rolloutFile,
parentSpanContext,
});

await propagateAttributes(
{
sessionId: sessionMeta.sessionId,
traceName: sessionMeta.isSubagentThread ? "Codex Subagent Turn" : "Codex Turn",
...(options.config.user_id ? { userId: options.config.user_id } : {}),
...(options.config.tags ? { tags: options.config.tags } : {}),
...(options.config.metadata ? { metadata: options.config.metadata } : {}),
},
async () => {
await emitTurn(turn, sessionMeta, {
config: options.config,
rolloutFile,
seededParent,
});
},
);
if (options.parentSpanContext) {
// The external application owns trace-level name, session, user, tags,
// and metadata. Codex observation metadata is still emitted by emitTurn.
// Carry its Langfuse claim so the SDK does not mark Codex as another app root.
const parentContext = setLangfuseTraceIdInBaggage(
context.active(),
options.parentSpanContext.traceId,
);
await context.with(parentContext, emit);
} else {
await propagateAttributes(
{
sessionId: sessionMeta.sessionId,
traceName: sessionMeta.isSubagentThread ? "Codex Subagent Turn" : "Codex Turn",
...(options.config.user_id ? { userId: options.config.user_id } : {}),
...(options.config.tags ? { tags: options.config.tags } : {}),
...(options.config.metadata ? { metadata: options.config.metadata } : {}),
},
emit,
);
}

// Only mark completed turns as uploaded; an in-progress trailing turn is
// re-uploaded (and finalized) on the next hook invocation.
Expand Down
Loading