@traceai/codex-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +164 -2
  2. package/dist/esm/attributes.d.ts +101 -0
  3. package/dist/esm/attributes.d.ts.map +1 -0
  4. package/dist/esm/attributes.js +104 -0
  5. package/dist/esm/attributes.js.map +1 -0
  6. package/dist/esm/index.d.ts +8 -0
  7. package/dist/esm/index.d.ts.map +1 -0
  8. package/dist/esm/index.js +5 -0
  9. package/dist/esm/index.js.map +1 -0
  10. package/dist/esm/instrumentation.d.ts +81 -0
  11. package/dist/esm/instrumentation.d.ts.map +1 -0
  12. package/dist/esm/instrumentation.js +186 -0
  13. package/dist/esm/instrumentation.js.map +1 -0
  14. package/dist/esm/package.json +1 -0
  15. package/dist/esm/spans.d.ts +152 -0
  16. package/dist/esm/spans.d.ts.map +1 -0
  17. package/dist/esm/spans.js +678 -0
  18. package/dist/esm/spans.js.map +1 -0
  19. package/dist/esm/threadWrapper.d.ts +35 -0
  20. package/dist/esm/threadWrapper.d.ts.map +1 -0
  21. package/dist/esm/threadWrapper.js +186 -0
  22. package/dist/esm/threadWrapper.js.map +1 -0
  23. package/dist/esm/types.d.ts +123 -0
  24. package/dist/esm/types.d.ts.map +1 -0
  25. package/dist/esm/types.js +10 -0
  26. package/dist/esm/types.js.map +1 -0
  27. package/dist/esm/version.d.ts +2 -0
  28. package/dist/esm/version.d.ts.map +1 -0
  29. package/dist/esm/version.js +2 -0
  30. package/dist/esm/version.js.map +1 -0
  31. package/dist/esnext/attributes.d.ts +101 -0
  32. package/dist/esnext/attributes.d.ts.map +1 -0
  33. package/dist/esnext/attributes.js +104 -0
  34. package/dist/esnext/attributes.js.map +1 -0
  35. package/dist/esnext/index.d.ts +8 -0
  36. package/dist/esnext/index.d.ts.map +1 -0
  37. package/dist/esnext/index.js +5 -0
  38. package/dist/esnext/index.js.map +1 -0
  39. package/dist/esnext/instrumentation.d.ts +81 -0
  40. package/dist/esnext/instrumentation.d.ts.map +1 -0
  41. package/dist/esnext/instrumentation.js +191 -0
  42. package/dist/esnext/instrumentation.js.map +1 -0
  43. package/dist/esnext/spans.d.ts +152 -0
  44. package/dist/esnext/spans.d.ts.map +1 -0
  45. package/dist/esnext/spans.js +688 -0
  46. package/dist/esnext/spans.js.map +1 -0
  47. package/dist/esnext/threadWrapper.d.ts +35 -0
  48. package/dist/esnext/threadWrapper.d.ts.map +1 -0
  49. package/dist/esnext/threadWrapper.js +183 -0
  50. package/dist/esnext/threadWrapper.js.map +1 -0
  51. package/dist/esnext/types.d.ts +123 -0
  52. package/dist/esnext/types.d.ts.map +1 -0
  53. package/dist/esnext/types.js +10 -0
  54. package/dist/esnext/types.js.map +1 -0
  55. package/dist/esnext/version.d.ts +2 -0
  56. package/dist/esnext/version.d.ts.map +1 -0
  57. package/dist/esnext/version.js +2 -0
  58. package/dist/esnext/version.js.map +1 -0
  59. package/dist/src/attributes.d.ts +101 -0
  60. package/dist/src/attributes.d.ts.map +1 -0
  61. package/dist/src/attributes.js +107 -0
  62. package/dist/src/attributes.js.map +1 -0
  63. package/dist/src/index.d.ts +8 -0
  64. package/dist/src/index.d.ts.map +1 -0
  65. package/dist/src/index.js +21 -0
  66. package/dist/src/index.js.map +1 -0
  67. package/dist/src/instrumentation.d.ts +81 -0
  68. package/dist/src/instrumentation.d.ts.map +1 -0
  69. package/dist/src/instrumentation.js +213 -0
  70. package/dist/src/instrumentation.js.map +1 -0
  71. package/dist/src/spans.d.ts +152 -0
  72. package/dist/src/spans.d.ts.map +1 -0
  73. package/dist/src/spans.js +698 -0
  74. package/dist/src/spans.js.map +1 -0
  75. package/dist/src/threadWrapper.d.ts +35 -0
  76. package/dist/src/threadWrapper.d.ts.map +1 -0
  77. package/dist/src/threadWrapper.js +206 -0
  78. package/dist/src/threadWrapper.js.map +1 -0
  79. package/dist/src/types.d.ts +123 -0
  80. package/dist/src/types.d.ts.map +1 -0
  81. package/dist/src/types.js +11 -0
  82. package/dist/src/types.js.map +1 -0
  83. package/dist/src/version.d.ts +2 -0
  84. package/dist/src/version.d.ts.map +1 -0
  85. package/dist/src/version.js +5 -0
  86. package/dist/src/version.js.map +1 -0
  87. package/package.json +75 -3
  88. package/src/attributes.ts +113 -0
  89. package/src/index.ts +27 -0
  90. package/src/instrumentation.ts +215 -0
  91. package/src/spans.ts +729 -0
  92. package/src/threadWrapper.ts +200 -0
  93. package/src/types.ts +113 -0
  94. package/src/version.ts +1 -0
package/src/spans.ts ADDED
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Span model for one Codex turn (one `Thread.run()` or `Thread.runStreamed()`).
3
+ *
4
+ * The SDK yields `ThreadEvent`s, not spans. `TurnTracer` turns them into:
5
+ *
6
+ * codex.turn (AGENT) session.id = thread id, usage
7
+ * ├─ codex.reasoning (CHAIN)
8
+ * ├─ codex.command_execution (TOOL)
9
+ * ├─ codex.file_change (TOOL)
10
+ * ├─ codex.mcp_tool_call (TOOL)
11
+ * ├─ codex.web_search (TOOL)
12
+ * ├─ codex.todo_list (CHAIN)
13
+ * ├─ codex.agent_message (LLM)
14
+ * └─ codex.error (CHAIN, codex.warning=true) non-fatal error item
15
+ *
16
+ * Every public method swallows its own errors: tracing never fails the agent.
17
+ */
18
+ import {
19
+ Attributes,
20
+ AttributeValue,
21
+ Context,
22
+ HrTime,
23
+ Span,
24
+ SpanKind,
25
+ SpanStatusCode,
26
+ Tracer,
27
+ diag,
28
+ trace,
29
+ } from "@opentelemetry/api";
30
+ import { getAttributesFromContext } from "@traceai/fi-core";
31
+ import { MimeType } from "@traceai/fi-semantic-conventions";
32
+ import {
33
+ AGENT_NAME,
34
+ CodexAttributes as C,
35
+ ITEM_SPANS,
36
+ PROVIDER_NAME,
37
+ SpanNames,
38
+ StandardAttributes as S,
39
+ UNKNOWN_ITEM_SPAN,
40
+ } from "./attributes";
41
+ import { InputLike, ThreadEventLike, ThreadItemLike, ThreadOptionsLike, TurnOptionsLike, UsageLike } from "./types";
42
+
43
+ /** Which content the wrapper may copy onto spans. Both default to hidden. */
44
+ export interface ContentPolicy {
45
+ hideInputs: boolean;
46
+ hideOutputs: boolean;
47
+ /** Error details remain hidden even when ordinary outputs are visible. */
48
+ hideErrorDetails: boolean;
49
+ }
50
+
51
+ /** How the wrapped event stream stopped. */
52
+ export type FinishOutcome =
53
+ | { kind: "completed" }
54
+ | { kind: "returned" }
55
+ | { kind: "error"; error: unknown };
56
+
57
+ const CONTENT_MAX = 5000;
58
+ const ERROR_MESSAGE_MAX = 500;
59
+ const LOG = "@traceai/codex-sdk";
60
+
61
+ /**
62
+ * One clock for every start and end time the wrapper sets. OTel falls back to
63
+ * millisecond `Date.now()` for the end of a span whose start time was given,
64
+ * so mixing explicit and implicit times can order a child after its parent.
65
+ */
66
+ export function clockMs(): number {
67
+ const perf = (globalThis as { performance?: { now?: () => number; timeOrigin?: number } }).performance;
68
+ if (perf && typeof perf.now === "function" && typeof perf.timeOrigin === "number") {
69
+ return perf.timeOrigin + perf.now();
70
+ }
71
+ return Date.now();
72
+ }
73
+
74
+ export function toHrTime(ms: number): HrTime {
75
+ const seconds = Math.floor(ms / 1000);
76
+ const nanos = Math.round((ms - seconds * 1000) * 1e6);
77
+ return nanos >= 1e9 ? [seconds + 1, nanos - 1e9] : [seconds, nanos];
78
+ }
79
+
80
+ export function truncate(value: string, max: number): string {
81
+ return value.length > max ? `${value.slice(0, max)}...[truncated]` : value;
82
+ }
83
+
84
+ export function safeJson(value: unknown, max = CONTENT_MAX): string {
85
+ try {
86
+ const text = typeof value === "string" ? value : JSON.stringify(value);
87
+ return truncate(text ?? String(value), max);
88
+ } catch {
89
+ return truncate(String(value), max);
90
+ }
91
+ }
92
+
93
+ /** Same normalization as the SDK's `normalizeInput`: text parts joined by a blank line. */
94
+ export function normalizeInput(input: InputLike | undefined): { prompt: string; images: number } {
95
+ if (typeof input === "string") {
96
+ return { prompt: input, images: 0 };
97
+ }
98
+ const parts: string[] = [];
99
+ let images = 0;
100
+ if (Array.isArray(input)) {
101
+ for (const part of input) {
102
+ if (part && part.type === "text" && typeof part.text === "string") parts.push(part.text);
103
+ else if (part && part.type === "local_image") images += 1;
104
+ }
105
+ }
106
+ return { prompt: parts.join("\n\n"), images };
107
+ }
108
+
109
+ function isAbortLike(error: unknown, signal?: TurnOptionsLike["signal"]): boolean {
110
+ if (signal?.aborted) return true;
111
+ const name = (error as { name?: unknown } | null)?.name;
112
+ return name === "AbortError";
113
+ }
114
+
115
+ function errorMessage(error: unknown): string {
116
+ if (error instanceof Error) return error.message;
117
+ return typeof error === "string" ? error : safeJson(error, ERROR_MESSAGE_MAX);
118
+ }
119
+
120
+ function errorName(error: unknown): string {
121
+ const name = (error as { name?: unknown } | null)?.name;
122
+ return typeof name === "string" && name ? name : "Error";
123
+ }
124
+
125
+ /**
126
+ * The SDK builds its thrown messages from raw data: `Codex Exec exited with
127
+ * code 1: <the binary's stderr>` and `Failed to parse item: <the raw line>`
128
+ * (dist/index.js of 0.162.1). Stderr can hold config lines, local paths and
129
+ * the user name; the raw line can hold the prompt or command output. With
130
+ * outputs hidden only this fixed summary is exported.
131
+ */
132
+ export function errorSummary(error: unknown): string {
133
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
134
+ const exited = /^Codex Exec exited with (code -?\d+|signal [A-Z0-9]+)/.exec(message);
135
+ if (exited) return `Codex Exec exited with ${exited[1]}`;
136
+ if (message.startsWith("Failed to parse item")) return "Failed to parse item";
137
+ return errorName(error);
138
+ }
139
+
140
+ function finite(value: unknown): number | undefined {
141
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
142
+ }
143
+
144
+ type UsageTotals = {
145
+ input_tokens?: number;
146
+ cached_input_tokens?: number;
147
+ cache_write_input_tokens?: number;
148
+ output_tokens?: number;
149
+ reasoning_output_tokens?: number;
150
+ };
151
+
152
+ const USAGE_KEYS = [
153
+ "input_tokens",
154
+ "cached_input_tokens",
155
+ "cache_write_input_tokens",
156
+ "output_tokens",
157
+ "reasoning_output_tokens",
158
+ ] as const;
159
+
160
+ function usageTotals(usage: UsageLike): UsageTotals {
161
+ const totals: UsageTotals = {};
162
+ for (const key of USAGE_KEYS) {
163
+ const value = finite(usage[key]);
164
+ if (value !== undefined) totals[key] = value;
165
+ }
166
+ return totals;
167
+ }
168
+
169
+ function maxUsage(a: UsageTotals, b: UsageTotals): UsageTotals {
170
+ const out: UsageTotals = {};
171
+ for (const key of USAGE_KEYS) {
172
+ const value = Math.max(a[key] ?? 0, b[key] ?? 0);
173
+ if (a[key] !== undefined || b[key] !== undefined) out[key] = value;
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** `current - previous` per key, or `undefined` when any key went down. */
179
+ function subtractUsage(current: UsageTotals, previous: UsageTotals): UsageTotals | undefined {
180
+ const delta: UsageTotals = {};
181
+ for (const key of USAGE_KEYS) {
182
+ const value = current[key];
183
+ if (value === undefined) continue;
184
+ const diff = value - (previous[key] ?? 0);
185
+ if (diff < 0) return undefined;
186
+ delta[key] = diff;
187
+ }
188
+ return delta;
189
+ }
190
+
191
+ const MAX_TRACKED_THREADS = 1000;
192
+
193
+ /**
194
+ * Last running total seen per thread id in this process (LRU, 1000 threads).
195
+ * Exact while a thread's turns run in one process; see the README.
196
+ */
197
+ class UsageBaselines {
198
+ private readonly totals = new Map<string, UsageTotals>();
199
+
200
+ get(threadId: string): UsageTotals | undefined {
201
+ return this.totals.get(threadId);
202
+ }
203
+
204
+ set(threadId: string, totals: UsageTotals): void {
205
+ this.totals.delete(threadId);
206
+ this.totals.set(threadId, totals);
207
+ while (this.totals.size > MAX_TRACKED_THREADS) {
208
+ const oldest = this.totals.keys().next().value;
209
+ if (oldest === undefined) break;
210
+ this.totals.delete(oldest);
211
+ }
212
+ }
213
+
214
+ clear(): void {
215
+ this.totals.clear();
216
+ }
217
+
218
+ get size(): number {
219
+ return this.totals.size;
220
+ }
221
+ }
222
+
223
+ export const usageBaselines = new UsageBaselines();
224
+
225
+ interface ItemState {
226
+ span: Span;
227
+ type: string;
228
+ last: ThreadItemLike;
229
+ }
230
+
231
+ export class TurnTracer {
232
+ private readonly tracer: Tracer;
233
+ private readonly policy: ContentPolicy;
234
+ private readonly parentContext: Context;
235
+ private readonly startTimeMs: number;
236
+ private readonly input: InputLike | undefined;
237
+ private readonly turnOptions: TurnOptionsLike | undefined;
238
+ private readonly threadOptions: ThreadOptionsLike | undefined;
239
+ private readonly contextAttributes: Attributes;
240
+ private readonly appSetSession: boolean;
241
+ private readonly resumed: boolean;
242
+
243
+ private turn?: Span;
244
+ private turnContext?: Context;
245
+ private finished = false;
246
+ private threadId?: string;
247
+ private lastEventMs: number;
248
+ private finalResponse?: string;
249
+ private completed = false;
250
+ /** `turn.failed` text; the turn failed. */
251
+ private failedMessage?: string;
252
+ /** Last `error` event text. Fatal only if the turn ends without `turn.completed`. */
253
+ private streamErrorMessage?: string;
254
+ private streamErrorCount = 0;
255
+ private readonly items = new Map<string, ItemState>();
256
+ private anonymousItems = 0;
257
+
258
+ constructor(args: {
259
+ tracer: Tracer;
260
+ policy: ContentPolicy;
261
+ parentContext: Context;
262
+ input: InputLike | undefined;
263
+ turnOptions?: TurnOptionsLike;
264
+ threadOptions?: ThreadOptionsLike;
265
+ threadId?: string | null;
266
+ startTimeMs?: number;
267
+ }) {
268
+ this.tracer = args.tracer;
269
+ this.policy = args.policy;
270
+ this.parentContext = args.parentContext;
271
+ this.input = args.input;
272
+ this.turnOptions = args.turnOptions;
273
+ this.threadOptions = args.threadOptions;
274
+ this.startTimeMs = args.startTimeMs ?? clockMs();
275
+ this.lastEventMs = this.startTimeMs;
276
+ this.contextAttributes = safeContextAttributes(args.parentContext);
277
+ this.appSetSession = this.contextAttributes[S.SESSION_ID] != null;
278
+ // A thread with an id before its first event was resumed (resumeThread or a second run()).
279
+ this.resumed = typeof args.threadId === "string" && args.threadId.length > 0;
280
+ if (this.resumed) this.threadId = args.threadId as string;
281
+ }
282
+
283
+ private get hideErrorText(): boolean {
284
+ return this.policy.hideOutputs || this.policy.hideErrorDetails;
285
+ }
286
+
287
+ get isFinished(): boolean {
288
+ return this.finished;
289
+ }
290
+
291
+ /** Open the turn span. Called on the first `next()`; idempotent. */
292
+ start(): void {
293
+ if (this.turn || this.finished) return;
294
+ try {
295
+ const span = this.tracer.startSpan(
296
+ SpanNames.TURN,
297
+ { kind: SpanKind.INTERNAL, startTime: toHrTime(this.startTimeMs) },
298
+ this.parentContext,
299
+ );
300
+ this.turn = span;
301
+ this.turnContext = trace.setSpan(this.parentContext, span);
302
+ span.setAttributes(this.contextAttributes);
303
+ span.setAttributes(this.turnStartAttributes());
304
+ } catch (error) {
305
+ diag.debug(`${LOG}: failed to start turn span: ${error}`);
306
+ }
307
+ }
308
+
309
+ /** Record one SDK event. Never throws and never mutates the event. */
310
+ onEvent(event: unknown): void {
311
+ if (this.finished || !this.turn || !event || typeof event !== "object") return;
312
+ try {
313
+ const nowMs = clockMs();
314
+ const e = event as ThreadEventLike;
315
+ switch (e.type) {
316
+ case "thread.started":
317
+ if (typeof e.thread_id === "string" && e.thread_id) this.setThreadId(e.thread_id);
318
+ break;
319
+ case "turn.completed":
320
+ this.completed = true;
321
+ this.setUsage(e.usage);
322
+ break;
323
+ case "turn.failed":
324
+ this.failedMessage = typeof e.error?.message === "string" ? e.error.message : "turn failed";
325
+ break;
326
+ case "error":
327
+ // Codex also sends `error` for errors it retries ("Reconnecting... 1/5"),
328
+ // and the turn can still complete.
329
+ this.streamErrorCount += 1;
330
+ this.streamErrorMessage = typeof e.message === "string" ? e.message : "stream error";
331
+ this.turn.addEvent(
332
+ "codex.stream_error",
333
+ this.hideErrorText ? {} : { message: truncate(this.streamErrorMessage, ERROR_MESSAGE_MAX) },
334
+ );
335
+ break;
336
+ case "item.started":
337
+ this.onItemStarted(e.item, nowMs);
338
+ break;
339
+ case "item.updated":
340
+ this.onItemUpdated(e.item);
341
+ break;
342
+ case "item.completed":
343
+ this.onItemCompleted(e.item, nowMs);
344
+ break;
345
+ default:
346
+ break;
347
+ }
348
+ this.lastEventMs = nowMs;
349
+ } catch (error) {
350
+ diag.debug(`${LOG}: failed to record event: ${error}`);
351
+ }
352
+ }
353
+
354
+ /**
355
+ * End every open span for this turn, the turn span last. Idempotent.
356
+ * The spans always end, even if recording their attributes throws: item
357
+ * spans that already ended were exported with the turn as their parent.
358
+ */
359
+ finish(outcome: FinishOutcome): void {
360
+ if (this.finished) return;
361
+ this.finished = true;
362
+ const turn = this.turn;
363
+ if (!turn) return;
364
+ const endMs = clockMs();
365
+ try {
366
+ this.recordFinish(turn, outcome, endMs);
367
+ } catch (error) {
368
+ diag.debug(`${LOG}: failed to finish turn: ${error}`);
369
+ } finally {
370
+ for (const [id, state] of this.items) {
371
+ this.items.delete(id);
372
+ endSpan(state.span, endMs);
373
+ }
374
+ endSpan(turn, endMs);
375
+ }
376
+ }
377
+
378
+ /** Statuses and attributes for `finish()`; closes open item spans. */
379
+ private recordFinish(turn: Span, outcome: FinishOutcome, endMs: number): void {
380
+ // A stream error the turn recovered from (turn.completed followed) is not a failure.
381
+ const failedText =
382
+ this.failedMessage ?? (!this.completed && this.streamErrorMessage !== undefined ? this.streamErrorMessage : undefined);
383
+ const failedLabel = this.failedMessage !== undefined ? "turn failed" : "stream error";
384
+ const cancelled =
385
+ (outcome.kind === "returned" && !this.completed && failedText === undefined) ||
386
+ (outcome.kind === "error" && isAbortLike(outcome.error, this.turnOptions?.signal));
387
+ const errored = outcome.kind === "error" && !cancelled;
388
+ if (this.streamErrorCount > 0) turn.setAttribute(C.STREAM_ERROR_COUNT, this.streamErrorCount);
389
+
390
+ for (const [id, state] of this.items) {
391
+ this.items.delete(id);
392
+ try {
393
+ if (cancelled) {
394
+ state.span.setAttribute(C.CANCELLED, true);
395
+ state.span.setStatus({ code: SpanStatusCode.ERROR, message: "cancelled" });
396
+ } else if (errored || failedText !== undefined) {
397
+ state.span.setStatus({ code: SpanStatusCode.ERROR, message: "item not completed (turn failed)" });
398
+ } else {
399
+ // todo_list completes with the turn; anything else still open is incomplete, not failed.
400
+ if (state.type !== "todo_list") state.span.setAttribute(C.ITEM_INCOMPLETE, true);
401
+ this.applyItemAttributes(state.span, state.type, state.last);
402
+ }
403
+ } catch (error) {
404
+ diag.debug(`${LOG}: failed to record open item: ${error}`);
405
+ } finally {
406
+ endSpan(state.span, endMs);
407
+ }
408
+ }
409
+
410
+ if (this.finalResponse !== undefined && !this.policy.hideOutputs) {
411
+ turn.setAttribute(S.OUTPUT_VALUE, truncate(this.finalResponse, CONTENT_MAX));
412
+ turn.setAttribute(S.OUTPUT_MIME_TYPE, MimeType.TEXT);
413
+ }
414
+
415
+ if (cancelled) {
416
+ turn.setAttribute(C.CANCELLED, true);
417
+ turn.setStatus({ code: SpanStatusCode.ERROR, message: "cancelled" });
418
+ } else if (errored) {
419
+ const error = (outcome as { error: unknown }).error;
420
+ turn.setAttribute(C.ERROR_TYPE, errorName(error));
421
+ if (this.policy.hideErrorDetails) {
422
+ turn.recordException({ name: errorName(error) });
423
+ turn.setStatus({ code: SpanStatusCode.ERROR });
424
+ } else if (this.policy.hideOutputs) {
425
+ // Error text is content (stderr, raw lines, paths): a fixed summary, no stack trace.
426
+ const summary = errorSummary(error);
427
+ turn.setAttribute(C.ERROR_MESSAGE, summary);
428
+ turn.recordException({ name: errorName(error), message: summary });
429
+ turn.setStatus({ code: SpanStatusCode.ERROR, message: summary });
430
+ } else {
431
+ const message = truncate(errorMessage(error), ERROR_MESSAGE_MAX);
432
+ turn.setAttribute(C.ERROR_MESSAGE, message);
433
+ if (error instanceof Error) turn.recordException(error);
434
+ turn.setStatus({ code: SpanStatusCode.ERROR, message });
435
+ }
436
+ } else if (failedText !== undefined) {
437
+ // Provider error bodies and Codex messages can carry content: text only with outputs visible.
438
+ const message = this.policy.hideOutputs ? failedLabel : truncate(failedText, ERROR_MESSAGE_MAX);
439
+ if (!this.policy.hideErrorDetails) turn.setAttribute(C.ERROR_MESSAGE, message);
440
+ turn.setStatus({ code: SpanStatusCode.ERROR, ...(this.policy.hideErrorDetails ? {} : { message }) });
441
+ } else {
442
+ turn.setStatus({ code: SpanStatusCode.OK });
443
+ }
444
+ }
445
+
446
+ private turnStartAttributes(): Attributes {
447
+ const attributes: Attributes = {
448
+ [S.FI_SPAN_KIND]: "AGENT",
449
+ [S.GEN_AI_SPAN_KIND]: "AGENT",
450
+ [S.AGENT_NAME]: AGENT_NAME,
451
+ [S.PROVIDER]: PROVIDER_NAME,
452
+ [C.THREAD_RESUMED]: this.resumed,
453
+ };
454
+ const options = this.threadOptions ?? {};
455
+ if (typeof options.model === "string" && options.model) attributes[S.MODEL_NAME] = options.model;
456
+ if (typeof options.sandboxMode === "string") attributes[C.SANDBOX_MODE] = options.sandboxMode;
457
+ if (typeof options.approvalPolicy === "string") attributes[C.APPROVAL_POLICY] = options.approvalPolicy;
458
+ if (typeof options.modelReasoningEffort === "string") attributes[C.REASONING_EFFORT] = options.modelReasoningEffort;
459
+ if (typeof options.webSearchMode === "string") attributes[C.WEB_SEARCH_MODE] = options.webSearchMode;
460
+
461
+ const { prompt, images } = normalizeInput(this.input);
462
+ attributes[C.INPUT_LENGTH] = prompt.length;
463
+ if (images > 0) attributes[C.INPUT_IMAGE_COUNT] = images;
464
+ if (!this.policy.hideInputs) {
465
+ attributes[S.INPUT_VALUE] = truncate(prompt, CONTENT_MAX);
466
+ attributes[S.INPUT_MIME_TYPE] = MimeType.TEXT;
467
+ }
468
+ if (this.threadId) Object.assign(attributes, this.sessionAttributes(this.threadId));
469
+ return attributes;
470
+ }
471
+
472
+ private sessionAttributes(threadId: string): Attributes {
473
+ const attributes: Attributes = { [C.THREAD_ID]: threadId, [S.CONVERSATION_ID]: threadId };
474
+ // An app that set session.id through fi-core context keeps it.
475
+ if (!this.appSetSession) attributes[S.SESSION_ID] = threadId;
476
+ return attributes;
477
+ }
478
+
479
+ private setThreadId(threadId: string): void {
480
+ this.threadId = threadId;
481
+ this.turn?.setAttributes(this.sessionAttributes(threadId));
482
+ // A new thread starts at zero, even if this first turn fails or is cancelled:
483
+ // the next turn then promotes its delta (which includes this turn's spend).
484
+ if (!this.resumed && !usageBaselines.get(threadId)) usageBaselines.set(threadId, {});
485
+ }
486
+
487
+ /**
488
+ * `turn.completed.usage` is the thread's running total, not the turn's own
489
+ * spend (measured on 0.162.1: each resumed turn reports the previous total
490
+ * plus its own requests, also across processes). The promoted
491
+ * `gen_ai.usage.*` keys carry only this turn's new spend: the total minus
492
+ * the last total this process saw for the thread. A new thread starts at
493
+ * zero. A resumed thread with no total seen in this process promotes nothing
494
+ * (`codex.usage.baseline_unknown`); later turns promote their delta.
495
+ * A failed or cancelled turn reports no usage, so its spend lands on the
496
+ * thread's next completed turn. Running totals are always on
497
+ * `codex.cumulative.*`.
498
+ */
499
+ private setUsage(usage: UsageLike | undefined): void {
500
+ if (!usage || !this.turn) return;
501
+ const cumulative = usageTotals(usage);
502
+ const attributes: Record<string, AttributeValue> = {};
503
+ for (const [key, value] of Object.entries(cumulative)) {
504
+ if (value !== undefined) attributes[`${C.CUMULATIVE_PREFIX}${key}`] = value;
505
+ }
506
+ let baseline = this.threadId ? usageBaselines.get(this.threadId) : undefined;
507
+ if (!baseline && !this.resumed) baseline = {};
508
+ if (!baseline) {
509
+ if (this.threadId) usageBaselines.set(this.threadId, cumulative);
510
+ attributes[C.USAGE_BASELINE_UNKNOWN] = true;
511
+ this.turn.setAttributes(attributes);
512
+ return;
513
+ }
514
+ let turnUsage = subtractUsage(cumulative, baseline);
515
+ if (turnUsage) {
516
+ if (this.threadId) usageBaselines.set(this.threadId, cumulative);
517
+ } else {
518
+ // The total went down (a reset, or an SDK that reports per-turn usage): use it as
519
+ // reported, and keep the larger baseline so a later turn is not counted twice.
520
+ turnUsage = cumulative;
521
+ attributes[C.USAGE_NON_MONOTONIC] = true;
522
+ if (this.threadId) usageBaselines.set(this.threadId, maxUsage(baseline, cumulative));
523
+ }
524
+ const { input_tokens: input, output_tokens: output } = turnUsage;
525
+ // OpenAI accounting: input_tokens includes cached tokens, output_tokens includes reasoning.
526
+ if (input !== undefined) attributes[S.INPUT_TOKENS] = input;
527
+ if (output !== undefined) attributes[S.OUTPUT_TOKENS] = output;
528
+ if (input !== undefined && output !== undefined) attributes[S.TOTAL_TOKENS] = input + output;
529
+ if (turnUsage.cached_input_tokens !== undefined) {
530
+ attributes[S.CACHE_READ_TOKENS] = turnUsage.cached_input_tokens;
531
+ attributes[C.USAGE_CACHED_INPUT_TOKENS] = turnUsage.cached_input_tokens;
532
+ }
533
+ if (turnUsage.cache_write_input_tokens !== undefined) {
534
+ attributes[S.CACHE_WRITE_TOKENS] = turnUsage.cache_write_input_tokens;
535
+ attributes[C.USAGE_CACHE_WRITE_INPUT_TOKENS] = turnUsage.cache_write_input_tokens;
536
+ }
537
+ if (turnUsage.reasoning_output_tokens !== undefined) {
538
+ attributes[S.REASONING_TOKENS] = turnUsage.reasoning_output_tokens;
539
+ attributes[C.USAGE_REASONING_OUTPUT_TOKENS] = turnUsage.reasoning_output_tokens;
540
+ }
541
+ this.turn.setAttributes(attributes);
542
+ }
543
+
544
+ private itemKey(item: ThreadItemLike): string {
545
+ if (typeof item.id === "string" && item.id) return item.id;
546
+ this.anonymousItems += 1;
547
+ return `__anonymous_${this.anonymousItems}`;
548
+ }
549
+
550
+ private onItemStarted(item: ThreadItemLike | undefined, nowMs: number): void {
551
+ if (!item || typeof item !== "object") return;
552
+ const key = this.itemKey(item);
553
+ if (this.items.has(key)) return;
554
+ const state = this.startItemSpan(item, nowMs, true);
555
+ if (state) this.items.set(key, state);
556
+ }
557
+
558
+ private onItemUpdated(item: ThreadItemLike | undefined): void {
559
+ if (!item || typeof item !== "object" || typeof item.id !== "string") return;
560
+ const state = this.items.get(item.id);
561
+ if (state) state.last = item;
562
+ }
563
+
564
+ private onItemCompleted(item: ThreadItemLike | undefined, nowMs: number): void {
565
+ if (!item || typeof item !== "object") return;
566
+ const key = typeof item.id === "string" && item.id ? item.id : undefined;
567
+ let state = key ? this.items.get(key) : undefined;
568
+ if (key) this.items.delete(key);
569
+ if (!state) {
570
+ // Completed-only items (agent_message, reasoning) start at the previous event.
571
+ state = this.startItemSpan(item, this.lastEventMs, false);
572
+ }
573
+ if (!state) return;
574
+ state.last = item;
575
+ if (item.type === "agent_message" && typeof item.text === "string") this.finalResponse = item.text;
576
+ try {
577
+ this.applyItemAttributes(state.span, state.type, item);
578
+ this.applyItemStatus(state.span, state.type, item);
579
+ } catch (error) {
580
+ diag.debug(`${LOG}: failed to record item: ${error}`);
581
+ } finally {
582
+ endSpan(state.span, Math.max(nowMs, this.lastEventMs));
583
+ }
584
+ }
585
+
586
+ private startItemSpan(item: ThreadItemLike, startMs: number, startedEvent: boolean): ItemState | undefined {
587
+ const type = typeof item.type === "string" ? item.type : "unknown";
588
+ const spec = ITEM_SPANS[type] ?? { name: `codex.${type}`, ...UNKNOWN_ITEM_SPAN };
589
+ const attributes: Attributes = {
590
+ [S.FI_SPAN_KIND]: spec.kind,
591
+ [S.GEN_AI_SPAN_KIND]: spec.kind,
592
+ [C.ITEM_TYPE]: type,
593
+ [C.ITEM_STARTED_EVENT]: startedEvent,
594
+ };
595
+ if (typeof item.id === "string") {
596
+ attributes[C.ITEM_ID] = item.id;
597
+ if (spec.tool) attributes[S.TOOL_CALL_ID] = item.id;
598
+ }
599
+ if (this.threadId && !this.appSetSession) attributes[S.SESSION_ID] = this.threadId;
600
+ if (spec.tool) {
601
+ const toolName = type === "mcp_tool_call" && typeof item.tool === "string" && item.tool ? item.tool : type;
602
+ attributes[S.TOOL_NAME] = toolName;
603
+ attributes[S.GEN_AI_TOOL_NAME] = toolName;
604
+ }
605
+ if (type === "agent_message") {
606
+ attributes[S.PROVIDER] = PROVIDER_NAME;
607
+ const model = this.threadOptions?.model;
608
+ if (typeof model === "string" && model) attributes[S.MODEL_NAME] = model;
609
+ }
610
+ const span = this.tracer.startSpan(
611
+ spec.name,
612
+ { kind: SpanKind.INTERNAL, startTime: toHrTime(startMs), attributes },
613
+ this.turnContext,
614
+ );
615
+ return { span, type, last: item };
616
+ }
617
+
618
+ /** Structural fields always; content only when the policy allows it. */
619
+ private applyItemAttributes(span: Span, type: string, item: ThreadItemLike): void {
620
+ const attributes: Attributes = {};
621
+ if (typeof item.status === "string") attributes[C.ITEM_STATUS] = item.status;
622
+ const input = (value: string) => {
623
+ if (!this.policy.hideInputs) {
624
+ attributes[S.INPUT_VALUE] = truncate(value, CONTENT_MAX);
625
+ }
626
+ };
627
+ const output = (value: string, mime: MimeType = MimeType.TEXT) => {
628
+ if (!this.policy.hideOutputs) {
629
+ attributes[S.OUTPUT_VALUE] = truncate(value, CONTENT_MAX);
630
+ attributes[S.OUTPUT_MIME_TYPE] = mime;
631
+ }
632
+ };
633
+ switch (type) {
634
+ case "agent_message":
635
+ case "reasoning":
636
+ if (typeof item.text === "string") {
637
+ attributes[C.TEXT_LENGTH] = item.text.length;
638
+ output(item.text);
639
+ }
640
+ break;
641
+ case "command_execution":
642
+ if (typeof item.exit_code === "number") attributes[C.COMMAND_EXIT_CODE] = item.exit_code;
643
+ if (typeof item.command === "string") input(item.command);
644
+ if (typeof item.aggregated_output === "string") {
645
+ attributes[C.COMMAND_OUTPUT_LENGTH] = item.aggregated_output.length;
646
+ if (item.status !== "failed" || !this.policy.hideErrorDetails) output(item.aggregated_output);
647
+ }
648
+ break;
649
+ case "file_change": {
650
+ const changes = Array.isArray(item.changes) ? item.changes : [];
651
+ attributes[C.FILE_CHANGE_COUNT] = changes.length;
652
+ const kinds = [...new Set(changes.map((c) => c?.kind).filter((k): k is string => typeof k === "string"))];
653
+ if (kinds.length) attributes[C.FILE_CHANGE_KINDS] = kinds.sort().join(",");
654
+ // Paths can name private files: they are input content.
655
+ if (changes.length) input(safeJson(changes));
656
+ break;
657
+ }
658
+ case "mcp_tool_call": {
659
+ if (typeof item.server === "string") attributes[C.MCP_SERVER] = item.server;
660
+ if (typeof item.tool === "string") attributes[C.MCP_TOOL] = item.tool;
661
+ // `result` is `null` on item.started and on a failed call (see ThreadItemLike).
662
+ if (item.arguments != null) input(safeJson(item.arguments));
663
+ const result = item.result;
664
+ if (result != null && (item.status !== "failed" || !this.policy.hideErrorDetails)) {
665
+ output(safeJson(result.content ?? result), MimeType.JSON);
666
+ }
667
+ break;
668
+ }
669
+ case "web_search":
670
+ if (typeof item.query === "string") input(item.query);
671
+ break;
672
+ case "todo_list": {
673
+ const todos = Array.isArray(item.items) ? item.items : [];
674
+ attributes[C.TODO_COUNT] = todos.length;
675
+ attributes[C.TODO_COMPLETED] = todos.filter((t) => t?.completed === true).length;
676
+ if (todos.length) output(safeJson(todos), MimeType.JSON);
677
+ break;
678
+ }
679
+ case "error":
680
+ // A non-fatal error item: Codex sends warnings, deprecation notices and model
681
+ // reroutes this way. Its text can quote config lines or paths: outputs only.
682
+ attributes[C.WARNING] = true;
683
+ if (typeof item.message === "string") {
684
+ attributes[C.TEXT_LENGTH] = item.message.length;
685
+ if (!this.hideErrorText) attributes[C.ERROR_MESSAGE] = truncate(item.message, ERROR_MESSAGE_MAX);
686
+ }
687
+ break;
688
+ default:
689
+ break;
690
+ }
691
+ span.setAttributes(attributes);
692
+ }
693
+
694
+ private applyItemStatus(span: Span, type: string, item: ThreadItemLike): void {
695
+ if (type === "error") {
696
+ // Non-fatal (`codex.warning=true`): status stays UNSET. Turn failures are on the turn span.
697
+ return;
698
+ }
699
+ if (item.status === "failed") {
700
+ let message = `${type} failed`;
701
+ if (type === "command_execution" && typeof item.exit_code === "number") {
702
+ message = `command failed (exit ${item.exit_code})`;
703
+ } else if (type === "mcp_tool_call" && typeof item.error?.message === "string" && !this.hideErrorText) {
704
+ // A tool's error text can carry tool output: only with outputs visible.
705
+ message = truncate(item.error.message, ERROR_MESSAGE_MAX);
706
+ }
707
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
708
+ return;
709
+ }
710
+ span.setStatus({ code: SpanStatusCode.OK });
711
+ }
712
+ }
713
+
714
+ function endSpan(span: Span, endMs: number): void {
715
+ try {
716
+ span.end(toHrTime(endMs));
717
+ } catch (error) {
718
+ diag.debug(`${LOG}: failed to end span: ${error}`);
719
+ }
720
+ }
721
+
722
+ function safeContextAttributes(ctx: Context): Attributes {
723
+ try {
724
+ return getAttributesFromContext(ctx);
725
+ } catch {
726
+ return {};
727
+ }
728
+ }
729
+