Something went wrong. Try again.
claude code plugin that sends telemetry to otlp destinations (traces)
Something went wrong. Try again.
14 kB · 422 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423/** * OTel span construction from parsed Turns. * * Uses the OTel SDK's Tracer API to create proper spans with parent-child * relationships, then flushes them via the OTLP exporter. * * Hierarchy: * Turn (SERVER span) * ├── chat model-name (CLIENT span) — LLM call * ├── ToolA (INTERNAL span) * ├── ToolB (INTERNAL span) * ├── chat model-name (CLIENT span) — next LLM call * └── ToolC (INTERNAL span) */
import { getTracer, setNextTraceId, setNextSpanId, createRemoteParentContext, flushTraces, SpanKind, SpanStatusCode, ROOT_CONTEXT, trace,} from "./otel.js";import { ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_RESPONSE_FINISH_REASONS, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_TYPE, ATTR_GEN_AI_AGENT_ID, ATTR_GEN_AI_AGENT_NAME, GEN_AI_SYSTEM_VALUE_ANTHROPIC, GEN_AI_OPERATION_NAME_VALUE_CHAT, GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL,} from "@opentelemetry/semantic-conventions/incubating";import type { Turn, Usage, SessionState, DeferredAgentTool } from "./types.js";import { readTranscript, groupIntoTurns } from "./transcript.js";import * as logger from "./logger.js";
// ─── Helpers ───────────────────────────────────────────────────────────────
function isoToMillis(iso: string): number { return new Date(iso).getTime();}
function usageAttributes(usage: Usage): Record<string, number> { const inputTokens = (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
const attrs: Record<string, number> = { [ATTR_GEN_AI_USAGE_INPUT_TOKENS]: inputTokens, [ATTR_GEN_AI_USAGE_OUTPUT_TOKENS]: usage.output_tokens ?? 0, };
if (usage.cache_read_input_tokens) { attrs[ATTR_GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = usage.cache_read_input_tokens; } if (usage.cache_creation_input_tokens) { attrs[ATTR_GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS] = usage.cache_creation_input_tokens; }
return attrs;}
// ─── Span creation ─────────────────────────────────────────────────────────
export interface TraceTurnOptions { turn: Turn; sessionId: string; turnNum: number; traceId: string; /** If set, the Turn span becomes a child of this span. */ parentSpanId?: string; /** Pre-generated span ID for the Turn span (for cross-hook references). */ turnSpanId?: string; /** Override end time (ms) for the Turn span. */ turnEndTimeMs?: number; /** Deferred Agent tool info from PostToolUse, keyed by agent_id. */ taskRunMap?: Record<string, DeferredAgentTool>; /** Tool start times from PreToolUse, keyed by tool_use_id. */ toolStartTimes?: Record<string, number>;}
/** * Create OTel spans for a single Turn via the SDK Tracer. * * @returns Map of agent_id → Agent tool span_id (for subagent linking). */export function traceTurn(options: TraceTurnOptions): Record<string, string> { const { turn, sessionId, turnNum, traceId, parentSpanId, turnSpanId, turnEndTimeMs, taskRunMap, toolStartTimes, } = options;
const tracer = getTracer(); const turnStartMs = isoToMillis(turn.userTimestamp);
// Build parent context for the Turn span. let turnParentCtx = ROOT_CONTEXT; if (parentSpanId) { // Child of an existing span (e.g., external parent or subagent chain). turnParentCtx = createRemoteParentContext(traceId, parentSpanId); } else { // Root span — force the trace ID so all spans share it. setNextTraceId(traceId); }
// Force the Turn span's ID if pre-generated by UserPromptSubmit. if (turnSpanId) setNextSpanId(turnSpanId);
const turnSpan = tracer.startSpan(`Claude Code Turn ${turnNum}`, { kind: SpanKind.SERVER, startTime: new Date(turnStartMs), attributes: { [ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_ANTHROPIC, [ATTR_GEN_AI_CONVERSATION_ID]: sessionId, "session.id": sessionId, "claude_code.turn_number": turnNum, }, }, turnParentCtx);
// Context for child spans (LLM calls, tools). const turnCtx = trace.setSpan(ROOT_CONTEXT, turnSpan);
let lastEndMs = turnStartMs; const agentSpanMap: Record<string, string> = {};
for (const llmCall of turn.llmCalls) { const llmStartMs = isoToMillis(llmCall.startTime); const llmEndMs = isoToMillis(llmCall.endTime);
// Tool spans — children of Turn (siblings of LLM spans). for (const toolCall of llmCall.toolCalls) { const toolResultTime = toolCall.result?.timestamp ?? llmCall.endTime; const toolEndMs = isoToMillis(toolResultTime); let toolStartMs: number;
if (toolStartTimes?.[toolCall.tool_use.id]) { toolStartMs = toolStartTimes[toolCall.tool_use.id]; } else { toolStartMs = Math.min(llmEndMs, toolEndMs); }
// For Agent tools, prefer PostToolUse timing. if (toolCall.agentId && taskRunMap?.[toolCall.agentId]) { toolStartMs = taskRunMap[toolCall.agentId].start_time; }
const toolSpan = tracer.startSpan(toolCall.tool_use.name, { kind: SpanKind.INTERNAL, startTime: new Date(toolStartMs), attributes: { [ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL, [ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_ANTHROPIC, [ATTR_GEN_AI_TOOL_NAME]: toolCall.tool_use.name, [ATTR_GEN_AI_TOOL_CALL_ID]: toolCall.tool_use.id, [ATTR_GEN_AI_TOOL_TYPE]: "extension", [ATTR_GEN_AI_CONVERSATION_ID]: sessionId, "session.id": sessionId, ...(toolCall.agentId ? { [ATTR_GEN_AI_AGENT_ID]: toolCall.agentId, } : {}), }, }, turnCtx);
if (toolCall.agentId) { agentSpanMap[toolCall.agentId] = toolSpan.spanContext().spanId; }
toolSpan.end(new Date(toolEndMs)); lastEndMs = Math.max(lastEndMs, toolEndMs); }
// LLM span — child of Turn. const assistantEndMs = llmCall.toolCalls.length > 0 ? lastEndMs : llmEndMs; const finishReason = llmCall.content.some((b) => b.type === "tool_use") ? "tool_calls" : "stop";
const llmAttrs: Record<string, string | number | boolean | string[]> = { [ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_ANTHROPIC, [ATTR_GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT, [ATTR_GEN_AI_REQUEST_MODEL]: llmCall.model, [ATTR_GEN_AI_RESPONSE_MODEL]: llmCall.model, [ATTR_GEN_AI_RESPONSE_FINISH_REASONS]: [finishReason], [ATTR_GEN_AI_CONVERSATION_ID]: sessionId, "session.id": sessionId, ...usageAttributes(llmCall.usage), };
if (llmCall.messageId) { llmAttrs[ATTR_GEN_AI_RESPONSE_ID] = llmCall.messageId; }
if (llmCall.synthetic) { llmAttrs["claude_code.synthetic"] = true; }
const llmSpan = tracer.startSpan(`chat ${llmCall.model}`, { kind: SpanKind.CLIENT, startTime: new Date(llmStartMs), attributes: llmAttrs, }, turnCtx);
llmSpan.end(new Date(assistantEndMs)); lastEndMs = Math.max(lastEndMs, assistantEndMs); }
// Complete the Turn span. const turnEndMs = turnEndTimeMs ?? lastEndMs; turnSpan.setStatus( turn.isComplete ? { code: SpanStatusCode.OK } : { code: SpanStatusCode.ERROR, message: "Interrupted" }, ); turnSpan.end(new Date(turnEndMs));
const status = turn.isComplete ? "complete" : "interrupted"; logger.log( `Traced turn ${turnNum}: ${turnSpan.spanContext().spanId} with ${turn.llmCalls.length} LLM call(s) [${status}]`, );
return agentSpanMap;}
// ─── Interrupted turn recovery ──────────────────────────────────────────────
export async function closeInterruptedTurn(options: { sessionId: string; sessionState: SessionState; transcriptPath: string | undefined; stateFilePath: string;}): Promise<{ lastLine: number; turnsTraced: number }> { const { sessionId, sessionState, transcriptPath } = options;
let lastLine = sessionState.last_line; let turnsTraced = 0;
const traceId = sessionState.current_trace_id; const turnSpanId = sessionState.current_turn_span_id; const parentSpanId = sessionState.current_parent_span_id;
if (!traceId || !turnSpanId) { logger.warn("Cannot close interrupted turn: missing trace_id or turn_span_id"); return { lastLine, turnsTraced }; }
// Trace content from the transcript. if (transcriptPath) { try { const { messages, lastLine: newLastLine } = readTranscript( transcriptPath, sessionState.last_line, ); if (messages.length > 0) { const turns = groupIntoTurns(messages); if (turns.length > 0) { const lastTurn = turns[turns.length - 1]; lastTurn.isComplete = false;
traceTurn({ turn: lastTurn, sessionId, turnNum: sessionState.turn_count + 1, traceId, parentSpanId, turnSpanId, turnEndTimeMs: Date.now(), taskRunMap: sessionState.task_run_map, toolStartTimes: sessionState.tool_start_times, });
lastLine = newLastLine; turnsTraced = 1; } } } catch (err) { logger.error(`Failed to trace interrupted turn transcript: ${err}`); } }
// If no transcript content, create a minimal Turn span with error. if (turnsTraced === 0) { const tracer = getTracer(); const turnNum = sessionState.current_turn_number ?? sessionState.turn_count + 1; const startMs = sessionState.current_turn_start ?? Date.now();
let parentCtx = ROOT_CONTEXT; if (parentSpanId) { parentCtx = createRemoteParentContext(traceId, parentSpanId); } else { setNextTraceId(traceId); } setNextSpanId(turnSpanId);
const turnSpan = tracer.startSpan(`Claude Code Turn ${turnNum}`, { kind: SpanKind.SERVER, startTime: new Date(startMs), attributes: { [ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_ANTHROPIC, [ATTR_GEN_AI_CONVERSATION_ID]: sessionId, "session.id": sessionId, "claude_code.turn_number": turnNum, }, }, parentCtx);
turnSpan.setStatus({ code: SpanStatusCode.ERROR, message: "User interrupt" }); turnSpan.end(); }
await flushTraces(); return { lastLine, turnsTraced };}
// ─── Subagent tracing ──────────────────────────────────────────────────────
export interface PendingSubagent { agent_id: string; agent_type: string; agent_transcript_path: string; session_id: string;}
/** * Trace pending subagents queued by SubagentStop. * Creates a chain span for each subagent and nests its turns underneath. */export function tracePendingSubagents(options: { pendingSubagents: PendingSubagent[]; agentSpanMap: Record<string, string>; taskRunMap: Record<string, DeferredAgentTool>; traceId: string; sessionId: string;}): void { const { pendingSubagents, agentSpanMap, taskRunMap, traceId, sessionId } = options; const tracer = getTracer();
for (const sub of pendingSubagents) { try { const parentToolSpanId = agentSpanMap[sub.agent_id]; if (!parentToolSpanId) { logger.error( `No Agent tool span found for ${sub.agent_id} — cannot trace subagent`, ); continue; }
const toolName = sub.agent_type || "Agent"; const deferred = taskRunMap[sub.agent_id]; const subStartMs = deferred?.start_time ?? Date.now(); const subEndMs = deferred?.end_time ?? Date.now();
logger.debug( `Processing subagent ${toolName} (${sub.agent_id}) under span ${parentToolSpanId}`, );
// Read subagent transcript. const { messages } = readTranscript(sub.agent_transcript_path, -1); if (messages.length === 0) { logger.debug(`Empty subagent transcript: ${sub.agent_transcript_path}`); continue; }
const subTurns = groupIntoTurns(messages);
// Chain span — groups all subagent turns under the Agent tool span. const parentCtx = createRemoteParentContext(traceId, parentToolSpanId); const chainSpan = tracer.startSpan(`${toolName} Subagent`, { kind: SpanKind.INTERNAL, startTime: new Date(subStartMs), attributes: { [ATTR_GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_ANTHROPIC, [ATTR_GEN_AI_AGENT_NAME]: toolName, [ATTR_GEN_AI_AGENT_ID]: sub.agent_id, [ATTR_GEN_AI_CONVERSATION_ID]: sessionId, "session.id": sessionId, }, }, parentCtx);
const chainSpanId = chainSpan.spanContext().spanId; chainSpan.end(new Date(subEndMs));
// Trace each subagent turn under the chain span. for (let i = 0; i < subTurns.length; i++) { traceTurn({ turn: subTurns[i], sessionId, turnNum: i + 1, traceId, parentSpanId: chainSpanId, }); }
logger.log( `Traced subagent ${toolName} (${sub.agent_id}): ${subTurns.length} turn(s)`, ); } catch (err) { logger.error(`Failed to trace subagent ${sub.agent_id}: ${err}`); } }}