diff --git a/core/agent-tracing/src/ClaudeAgentTracer.ts b/core/agent-tracing/src/ClaudeAgentTracer.ts index bc5be88c..84b2b671 100644 --- a/core/agent-tracing/src/ClaudeAgentTracer.ts +++ b/core/agent-tracing/src/ClaudeAgentTracer.ts @@ -12,6 +12,7 @@ import { type ClaudeContentBlock, type ClaudeTokenUsage, type IRunCost, + type CreateSessionOptions, RunStatus, type TracerConfig, applyTracerConfig, @@ -23,6 +24,7 @@ import { */ export class TraceSession { private traceId: string; + private threadId?: string; private rootRun: Run | null = null; private rootRunId: string; private startTime: number; @@ -30,9 +32,10 @@ export class TraceSession { private pendingToolUses = new Map(); private tracer: ClaudeAgentTracer; - constructor(tracer: ClaudeAgentTracer, sessionId?: string) { + constructor(tracer: ClaudeAgentTracer, options?: CreateSessionOptions) { this.tracer = tracer; - this.traceId = sessionId || randomUUID(); + this.traceId = options?.traceId || randomUUID(); + this.threadId = options?.threadId; this.rootRunId = randomUUID(); this.startTime = Date.now(); } @@ -61,8 +64,11 @@ export class TraceSession { } private handleInit(message: ClaudeMessage): void { - this.traceId = message.session_id || this.traceId; - this.rootRun = this.tracer.createRootRunInternal(message, this.startTime, this.traceId, this.rootRunId); + // threadId: prefer constructor option, fallback to init message's session_id + if (!this.threadId) { + this.threadId = message.session_id; + } + this.rootRun = this.tracer.createRootRunInternal(message, this.startTime, this.traceId, this.rootRunId, this.threadId); this.tracer.logTrace(this.rootRun, RunStatus.START); } @@ -213,14 +219,17 @@ export class ClaudeAgentTracer { * Create a new trace session for streaming message processing. * Use this for real-time tracing where messages arrive one-by-one. * + * @param options.traceId - Server-side trace ID for call chain linking. Defaults to a random UUID. + * @param options.threadId - Thread ID (Claude SDK session ID), recorded in metadata. + * * @example - * const session = claudeTracer.createSession(); + * const session = claudeTracer.createSession({ traceId: ctx.tracer.traceId, threadId }); * for await (const message of agent.run('task')) { * await session.processMessage(message); * } */ - public createSession(sessionId?: string): TraceSession { - return new TraceSession(this, sessionId); + public createSession(options?: CreateSessionOptions): TraceSession { + return new TraceSession(this, options); } /** @@ -315,8 +324,9 @@ export class ClaudeAgentTracer { * @internal * Create root run from init message (used by TraceSession) */ - createRootRunInternal(initMsg: ClaudeMessage, startTime: number, traceId: string, rootRunId?: string): Run { + createRootRunInternal(initMsg: ClaudeMessage, startTime: number, traceId: string, rootRunId?: string, threadId?: string): Run { const runId = rootRunId || initMsg.uuid || randomUUID(); + const resolvedThreadId = threadId || initMsg.session_id; return { id: runId, @@ -325,7 +335,7 @@ export class ClaudeAgentTracer { inputs: { tools: initMsg.tools || [], model: initMsg.model, - session_id: initMsg.session_id, + session_id: resolvedThreadId, mcp_servers: initMsg.mcp_servers, agents: initMsg.agents, slash_commands: initMsg.slash_commands, @@ -342,7 +352,7 @@ export class ClaudeAgentTracer { tags: [], extra: { metadata: { - thread_id: initMsg.session_id, + thread_id: resolvedThreadId, }, apiKeySource: initMsg.apiKeySource, claude_code_version: initMsg.claude_code_version, diff --git a/core/agent-tracing/src/types.ts b/core/agent-tracing/src/types.ts index 6416b487..3be8dd71 100644 --- a/core/agent-tracing/src/types.ts +++ b/core/agent-tracing/src/types.ts @@ -132,6 +132,14 @@ export const RunStatus = { } as const; export type RunStatus = (typeof RunStatus)[keyof typeof RunStatus]; +/** Options for creating a new trace session. */ +export interface CreateSessionOptions { + /** Server-side trace ID for linking to the request call chain. Defaults to a random UUID. */ + traceId?: string; + /** Thread ID (Claude SDK session ID), recorded in metadata. */ + threadId?: string; +} + /** User-facing config passed to tracer.configure() */ export interface TracerConfig { agentName?: string; diff --git a/core/agent-tracing/test/ClaudeAgentTracer.test.ts b/core/agent-tracing/test/ClaudeAgentTracer.test.ts index 7f6ca186..7b9981b7 100644 --- a/core/agent-tracing/test/ClaudeAgentTracer.test.ts +++ b/core/agent-tracing/test/ClaudeAgentTracer.test.ts @@ -212,10 +212,10 @@ describe('test/ClaudeAgentTracer.test.ts', () => { const toolEnd = toolRuns.find(e => e.status === RunStatus.END); assert(toolEnd, 'Should have tool end'); - // All runs share the same trace_id = session_id + // All runs share the same trace_id (auto-generated UUID, NOT session_id) const traceIds = new Set(capturedRuns.map(e => e.run.trace_id)); assert.strictEqual(traceIds.size, 1, `All runs should share one trace_id, got ${traceIds.size}`); - assert.strictEqual([ ...traceIds ][0], 'test-session-001', 'trace_id should match session_id'); + assert.notStrictEqual([ ...traceIds ][0], 'test-session-001', 'trace_id should NOT equal session_id'); // Root run should carry session_id as thread_id in extra.metadata const rootExtra = rootStart.run.extra as Record; @@ -240,6 +240,53 @@ describe('test/ClaudeAgentTracer.test.ts', () => { }); }); + describe('Separate traceId and sessionId', () => { + it('should use provided traceId and record threadId in metadata', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession({ + traceId: 'server-trace-abc', + threadId: 'my-thread-id', + }); + + const messages: SDKMessage[] = [ + createMockInit(), + createMockAssistantTextOnly(), + createMockResult(), + ]; + + for (const msg of messages) { + await session.processMessage(msg); + } + + // All runs should use the provided traceId + const traceIds = new Set(capturedRuns.map(e => e.run.trace_id)); + assert.strictEqual(traceIds.size, 1); + assert.strictEqual([ ...traceIds ][0], 'server-trace-abc', 'trace_id should be the server-side traceId'); + + // thread_id in metadata should be the sessionId, not the traceId + const rootStart = capturedRuns.find(e => !e.run.parent_run_id && e.status === RunStatus.START); + assert(rootStart); + const rootExtra = rootStart.run.extra as Record; + assert.strictEqual(rootExtra?.metadata?.thread_id, 'my-thread-id', 'thread_id should be the provided threadId'); + }); + + it('should fallback threadId to init message session_id when not provided', async () => { + const { claudeTracer, capturedRuns } = createTestEnv(); + const session = claudeTracer.createSession({ traceId: 'server-trace-xyz' }); + + await session.processMessage(createMockInit()); + await session.processMessage(createMockResult()); + + const rootStart = capturedRuns.find(e => !e.run.parent_run_id && e.status === RunStatus.START); + assert(rootStart); + const rootExtra = rootStart.run.extra as Record; + assert.strictEqual(rootExtra?.metadata?.thread_id, 'test-session-001', 'thread_id should fallback to init session_id'); + + const traceIds = new Set(capturedRuns.map(e => e.run.trace_id)); + assert.strictEqual([ ...traceIds ][0], 'server-trace-xyz'); + }); + }); + describe('Batch mode + text-only', () => { it('should trace a text-only response via processMessages', async () => { const { claudeTracer, capturedRuns } = createTestEnv();