Skip to content
Merged
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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ Trace OpenAI Codex sessions to [TraceRoot](https://traceroot.ai). Every query be

## Install

Add the marketplace, then install the plugin from it:

```bash
codex plugin marketplace add traceroot-ai/traceroot-codex-plugin
codex plugin add tracing@traceroot-codex-plugin
```

Enable Codex hooks (the plugin runs as a hook):

```bash
codex features enable hooks
```

Enable it in `~/.codex/config.toml`:
This writes `features.hooks = true` to `~/.codex/config.toml`. The `codex plugin add` step above also enables the plugin there:

```toml
plugin_hooks = true
features.hooks = true

[plugins."tracing@traceroot-codex-plugin"]
enabled = true
Expand Down
36 changes: 28 additions & 8 deletions plugins/tracing/dist/index.mjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions plugins/tracing/src/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,17 @@ export async function dispatch(
const getGit = deps?.getGit ?? getGitContext;
const findSubagentFn = deps?.findSubagent ?? locateSubagentRollout;

const lines = await readRollout(transcript);
const { sessionMeta, turns } = parseRollout(lines);
// The Stop hook is the "turn is ending" signal — only then is the root span
// emitted (see planTurnSpans). Mid-turn PostToolUse hooks leave the root absent
// so the trace stays live. SubagentStop carries the PARENT transcript while the
// parent turn is still running, so it must NOT count as the parent turn ending.
// The Stop hook is the "turn is ending" signal — only then is the root span
// emitted (see planTurnSpans). Mid-turn PostToolUse hooks leave the root absent
// so the trace stays live. SubagentStop carries the PARENT transcript while the
// parent turn is still running, so it must NOT count as the parent turn ending.
const turnEnding = hook.hook_event_name === "Stop";

const { sessionMeta, turns } = parseRollout(await readRollout(transcript));

// A subagent session is emitted NESTED under its parent's trace (via the
// parent's spawn_agent tool). Codex fires PostToolUse/Stop on the subagent
Expand All @@ -126,7 +135,9 @@ export async function dispatch(

const cwd = sessionMeta.cwd ?? hook.cwd ?? process.cwd();
const git = await getGit(cwd).catch(() => ({}));
const ctx: EmitCtx = { environment: config.environment, userId: config.userId, git, maxChars: config.maxChars };
const ctx: EmitCtx = {
environment: config.environment, userId: config.userId, git, maxChars: config.maxChars, turnEnding,
};

const already = await loadEmittedSpanIds(transcript);
const tracing = buildTracingFn(config);
Expand Down
57 changes: 47 additions & 10 deletions plugins/tracing/src/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,24 @@ import { mapUsage } from "./tokens.js";
import type { ModelStep, SessionMeta, ToolCall, Turn } from "./types.js";
import { truncate } from "./util.js";

// The trace title. Used as the root span name AND as path[0] on every span so
// the trace reads as "Codex Turn" even before the root span exists (see below).
export const ROOT_NAME = "Codex Turn";

export type EmitCtx = {
environment?: string;
userId?: string;
git?: { repo?: string; ref?: string };
maxChars: number;
// True only on the terminal Stop hook (the turn is ending). The root span is
// emitted only when the turn ends, so a still-running turn has NO completed
// root in ClickHouse — which is how the backend's live-stream detector knows
// the trace is in progress and keeps streaming (matching the SDK's model).
turnEnding?: boolean;
};

type SpanAttrs = Record<string, string | number | string[]>;

export type EmittableSpan = {
spanId: string;
parentSpanId: string | null;
Expand All @@ -21,14 +32,14 @@ export type EmittableSpan = {
name: string;
startTime: number;
endTime: number;
attributes: Record<string, string | number>;
attributes: SpanAttrs;
complete: boolean;
};

const str = (v: unknown, max: number): string =>
truncate(typeof v === "string" ? v : JSON.stringify(v ?? ""), max);

function commonTrace(attrs: Record<string, string | number>, sessionMeta: SessionMeta, ctx: EmitCtx): void {
function commonTrace(attrs: SpanAttrs, sessionMeta: SessionMeta, ctx: EmitCtx): void {
attrs["traceroot.sdk.name"] = SDK_NAME;
attrs["traceroot.sdk.version"] = SDK_VERSION;
// Child subagent spans intentionally carry the CHILD session id here (sessionMeta is
Expand Down Expand Up @@ -57,7 +68,7 @@ export function planTurnSpans(sessionMeta: SessionMeta, turn: Turn, ctx: EmitCtx
const out: EmittableSpan[] = [];

// Root AGENT span (trace-level attrs live here).
const rootAttrs: Record<string, string | number> = { "traceroot.span.type": "AGENT" };
const rootAttrs: SpanAttrs = { "traceroot.span.type": "AGENT", "traceroot.span.path": [ROOT_NAME] };
commonTrace(rootAttrs, sessionMeta, ctx);
if (turn.userInput) rootAttrs["traceroot.span.input"] = str(turn.userInput, ctx.maxChars);
if (turn.finalOutput) rootAttrs["traceroot.span.output"] = str(turn.finalOutput, ctx.maxChars);
Expand All @@ -71,14 +82,31 @@ export function planTurnSpans(sessionMeta: SessionMeta, turn: Turn, ctx: EmitCtx
}));
if (ctx.git?.repo) rootAttrs["traceroot.git.repo"] = ctx.git.repo;
if (ctx.git?.ref) rootAttrs["traceroot.git.ref"] = ctx.git.ref;
// Root end = the latest activity in the turn, not just the task_complete time.
// turn.endTime only advances on task_complete, so a turn that was aborted or
// snapshotted live (no task_complete) would have endTime === startTime and
// render as a 0ms bar. Cover the turn's own steps and tool calls (including
// wait_agent, which ends when a spawned subagent finishes) so the root always
// spans its children — and grows correctly across live hooks.
let rootEnd = turn.endTime;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Root end time still ignores nested subagent spans. Parent trace roots can render too short whenever a child rollout outlives the parent turn's own steps/tools.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/tracing/src/spans.ts, line 91:

<comment>Root end time still ignores nested subagent spans. Parent trace roots can render too short whenever a child rollout outlives the parent turn's own steps/tools.</comment>

<file context>
@@ -71,14 +82,31 @@ export function planTurnSpans(sessionMeta: SessionMeta, turn: Turn, ctx: EmitCtx
+  // render as a 0ms bar. Cover the turn's own steps and tool calls (including
+  // wait_agent, which ends when a spawned subagent finishes) so the root always
+  // spans its children — and grows correctly across live hooks.
+  let rootEnd = turn.endTime;
+  for (const step of turn.steps) {
+    if (step.endTime > rootEnd) rootEnd = step.endTime;
</file context>

for (const step of turn.steps) {
if (step.endTime > rootEnd) rootEnd = step.endTime;
for (const tc of step.toolCalls) {
if (tc.endTime !== undefined && tc.endTime > rootEnd) rootEnd = tc.endTime;
}
}
out.push({
spanId: rootId, parentSpanId: rootParentSpanId, traceId, kind: "AGENT",
name: "Codex Turn", startTime: turn.startTime, endTime: turn.endTime,
name: ROOT_NAME, startTime: turn.startTime, endTime: rootEnd,
attributes: rootAttrs,
// The trace root (no parent) is emittable immediately so the trace is named
// "Codex Turn" from the first live hook; emit.ts re-emits it to refine
// end/output. Subagent roots stay gated on completion.
complete: rootParentSpanId === null ? true : turn.completed,
// The root is emitted only when the turn ENDS (task_complete, or the terminal
// Stop hook signalled via ctx.turnEnding) — never mid-turn. A completed root in
// ClickHouse is the backend's "trace is done" signal, so emitting it early would
// close the live SSE stream on the first hook (the trace would look finished and
// stop updating until refresh). Deferring it keeps the trace live while the turn
// runs; meanwhile traceroot.span.path keeps the title "Codex Turn". Subagent roots
// (rootParentSpanId !== null) stay gated on their own completion.
complete: rootParentSpanId === null ? (turn.completed || !!ctx.turnEnding) : turn.completed,
});

// LLM-step input is the user prompt (first step) or the prior step's tool
Expand Down Expand Up @@ -117,7 +145,13 @@ function planStepSpan(
sessionMeta: SessionMeta, turn: Turn, step: ModelStep,
spanId: string, parentSpanId: string, traceId: string, ctx: EmitCtx, input: string | undefined,
): EmittableSpan {
const attrs: Record<string, string | number> = { "traceroot.span.type": "LLM" };
const attrs: SpanAttrs = {
"traceroot.span.type": "LLM",
// path[0] = ROOT_NAME so the trace title stays "Codex Turn" while the root
// span is still deferred (the backend names the trace from the shallowest
// span's path[0] until the real root arrives).
"traceroot.span.path": [ROOT_NAME, turn.model ?? "model"],
};
commonTrace(attrs, sessionMeta, ctx);
if (turn.model) attrs["traceroot.llm.model"] = turn.model;
if (input) attrs["traceroot.span.input"] = truncate(input, ctx.maxChars);
Expand All @@ -135,7 +169,10 @@ function planStepSpan(
function planToolSpan(
sessionMeta: SessionMeta, tc: ToolCall, seed: string, parentSpanId: string, traceId: string, ctx: EmitCtx,
): EmittableSpan {
const attrs: Record<string, string | number> = { "traceroot.span.type": "TOOL" };
const attrs: SpanAttrs = {
"traceroot.span.type": "TOOL",
"traceroot.span.path": [ROOT_NAME, tc.name],
};
commonTrace(attrs, sessionMeta, ctx);
attrs["traceroot.span.input"] = str(tc.args, ctx.maxChars);
if (tc.output !== undefined) attrs["traceroot.span.output"] = str(tc.output, ctx.maxChars);
Expand Down
12 changes: 11 additions & 1 deletion plugins/tracing/src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export function parseRollout(lines: RolloutLine[]): { sessionMeta: SessionMeta;
case "user_message":
if (turn && typeof p.message === "string") turn.userInput = p.message;
break;
case "agent_message":
// The agent's final message. Codex writes this BEFORE the terminal
// task_complete line (which lands just AFTER the Stop hook fires), so
// capturing the output here is what lets the root span carry the output
// on a live run — relying only on task_complete, our hook reads too early
// to see it. Last one wins.
if (turn && typeof p.message === "string") turn.finalOutput = p.message;
break;
case "token_count":
if (turn && p.info?.last_token_usage) {
const s = ensureStep(turn, at);
Expand All @@ -133,7 +141,9 @@ export function parseRollout(lines: RolloutLine[]): { sessionMeta: SessionMeta;
turn.completed = true;
turn.endTime = at;
const lastText = [...turn.steps].reverse().find((s) => s.text)?.text;
turn.finalOutput = (p.last_agent_message ?? lastText) ?? undefined;
// Prefer an explicit last_agent_message; otherwise keep what the
// agent_message event already captured, then fall back to step text.
turn.finalOutput = (p.last_agent_message ?? turn.finalOutput ?? lastText) ?? undefined;
}
break;
default:
Expand Down
24 changes: 12 additions & 12 deletions plugins/tracing/test/emit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("dispatch", () => {
expect(r2.emitted).toBe(1);
});

it("live: trace root + completed tool/llm emit before the turn closes; root refreshes on completion", async () => {
it("live: completed LLM/TOOL stream mid-turn with NO root (trace stays live); Stop emits the root", async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "tr-live-"));
const transcript = path.join(dir, "rollout.jsonl");
await fs.copyFile(path.join(here, "fixtures", "rollout-inprogress.jsonl"), transcript);
Expand All @@ -66,19 +66,19 @@ describe("dispatch", () => {
getGit: async () => ({}),
};

const r1 = await dispatch({ transcript_path: transcript }, config, deps);
// Trace root (named "Codex Turn") emits immediately for live naming, plus the
// already-complete LLM step and its TOOL — before the turn closes.
expect(r1.emitted).toBe(3);
// Mid-turn PostToolUse hook: the already-complete LLM step + its TOOL emit,
// but the root does NOT — so ClickHouse holds no completed root and the
// backend keeps the live SSE stream open (the trace updates without refresh).
const r1 = await dispatch({ transcript_path: transcript, hook_event_name: "PostToolUse" }, config, deps);
expect(r1.emitted).toBe(2);
expect(mem.getFinishedSpans().map((s) => s.attributes["traceroot.span.type"]).sort())
.toEqual(["AGENT", "LLM", "TOOL"]);
.toEqual(["LLM", "TOOL"]);

// Now the turn completes: append task_complete and re-run. The root re-emits
// (refreshed end + finalOutput); LLM/TOOL already emitted (sidecar dedup).
await fs.appendFile(transcript,
'{"timestamp":"2026-06-23T23:21:36.100Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1","last_agent_message":"done","completed_at":1782256896}}\n');
const r2 = await dispatch({ transcript_path: transcript }, config, deps);
expect(r2.emitted).toBe(1); // the AGENT root re-emitted
// The terminal Stop hook ends the turn: the root emits (even though this
// in-progress rollout never got a task_complete), which signals trace_complete.
const r2 = await dispatch({ transcript_path: transcript, hook_event_name: "Stop" }, config, deps);
expect(r2.emitted).toBe(1);
expect(mem.getFinishedSpans().filter((s) => s.attributes["traceroot.span.type"] === "AGENT")).toHaveLength(1);
});

it("skips standalone emission for a subagent session (it nests under its parent)", async () => {
Expand Down
Loading
Loading