From 1a1573c987390d78038417e6f98293e50fda0467 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 23:46:46 -0700 Subject: [PATCH] chore(daemon): remove dead manual OTel builders Delete the unused hand-rolled span builders, baggage plumbing, and builder-only attribute keys after the SDK migration. Keep @opentelemetry/api at runtime and move sdk-trace-base to development dependencies for the in-memory exporter tests. --- package-lock.json | 140 +------------ package.json | 6 +- src/genaiSpans.ts | 505 +--------------------------------------------- 3 files changed, 7 insertions(+), 644 deletions(-) diff --git a/package-lock.json b/package-lock.json index e97f106..90d2b1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,6 @@ "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", "uuidv7": "1.2.1", "weave": "^0.16.3" }, @@ -23,6 +18,7 @@ }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" @@ -709,34 +705,11 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", - "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", - "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/core": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -748,65 +721,11 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz", - "integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-exporter-base": "0.219.0", - "@opentelemetry/otlp-transformer": "0.219.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", - "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-transformer": "0.219.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", - "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-logs": "0.219.0", - "@opentelemetry/sdk-metrics": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -819,44 +738,11 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", - "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", - "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -870,27 +756,11 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", - "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.8.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" diff --git a/package.json b/package.json index 583de74..2392af5 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", "uuidv7": "1.2.1", "weave": "^0.16.3" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index f818192..4090ae2 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -4,22 +4,8 @@ // Attribute-key constants and formatting helpers typed against the `weave` SDK. -import { - Attributes, - Baggage, - Span, - SpanKind, - Tracer, - Context, - TimeInput, - context as otelContext, - propagation, - trace, -} from '@opentelemetry/api'; -import type { ReadableSpan, Span as SdkSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Attributes } from '@opentelemetry/api'; import type { MessagePart, Tool, Turn, Usage } from 'weave'; -import { extractAssistantTextBlocks } from './parser.js'; -import type { AssistantCallDetail } from './parser.js'; import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; import type { UsageSummary } from './parser.js'; @@ -57,7 +43,6 @@ export const ATTR = { ERROR_TYPE: 'error.type', // Weave extensions - claude_code per-turn metadata - WEAVE_SESSION_ID: 'weave.claude_code.session.id', WEAVE_CWD: 'weave.claude_code.cwd', WEAVE_SOURCE: 'weave.claude_code.source', WEAVE_PLUGIN_VERSION: 'weave.claude_code.plugin.version', @@ -83,17 +68,6 @@ export const ATTR = { EVT_PERMISSION_RESOLVED: 'weave.permission_resolved', EVT_PERMISSION_APPROVED: 'weave.permission.approved', EVT_PERMISSION_SUGGESTIONS: 'weave.permission.suggestions', - // Legacy keys used only by the hand-rolled builders below; deleted next PR. - PROVIDER_NAME: 'gen_ai.provider.name', - AGENT_DESCRIPTION: 'gen_ai.agent.description', - AGENT_VERSION: 'gen_ai.agent.version', - REQUEST_MODEL: 'gen_ai.request.model', - USAGE_REASONING_TOKENS: 'gen_ai.usage.reasoning_tokens', - TOOL_NAME: 'gen_ai.tool.name', - TOOL_CALL_ID: 'gen_ai.tool.call.id', - TOOL_CALL_ARGUMENTS: 'gen_ai.tool.call.arguments', - TOOL_CALL_RESULT: 'gen_ai.tool.call.result', - OUTPUT_TYPE: 'gen_ai.output.type', } as const; /** Top-level `gen_ai.agent.name` fallback; users override via settings @@ -280,480 +254,3 @@ export function toolDisplayName(toolName: string, input: Record } } } - -/** Common prefix for all integration-identity attributes. The span processor - * copies baggage entries under this prefix onto each span. */ -export const WEAVE_INTEGRATION_PREFIX = 'weave.integration.'; - -/** - * Build the per-session integration Baggage. `name` is the fixed integration - * id; `version` is the plugin version; `meta` is free-form per-session context - * flattened to `weave.integration.meta.` (falsy values skipped). The - * daemon activates this baggage for each session event so - * `IntegrationBaggageSpanProcessor` stamps it onto every span the event emits. - */ -export function createIntegrationBaggage(args: { - version: string; - meta?: Record; -}): Baggage { - const entries: Record = { - [ATTR.WEAVE_INTEGRATION_NAME]: { value: INTEGRATION_NAME }, - [ATTR.WEAVE_INTEGRATION_VERSION]: { value: args.version }, - }; - if (args.meta) { - for (const [key, value] of Object.entries(args.meta)) { - if (value) entries[`${WEAVE_INTEGRATION_META_PREFIX}${key}`] = { value }; - } - } - return propagation.createBaggage(entries); -} - -/** - * Copies `weave.integration.*` baggage entries off the active context onto each - * span at start. This is how integration identity reaches every span (turn - * root and all children) from a single per-session baggage attribution, instead - * of stamping each builder. Runs at `onStart` because attributes set after a - * span ends are dropped; the copy is a one-time snapshot (baggage is static per - * session). Baggage itself is never exported, only the copied attributes. - */ -export class IntegrationBaggageSpanProcessor implements SpanProcessor { - onStart(span: SdkSpan, parentContext: Context): void { - const baggage = propagation.getBaggage(parentContext); - if (!baggage) return; - for (const [key, entry] of baggage.getAllEntries()) { - if (key.startsWith(WEAVE_INTEGRATION_PREFIX)) { - span.setAttribute(key, entry.value); - } - } - } - onEnd(_span: ReadableSpan): void {} - forceFlush(): Promise { - return Promise.resolve(); - } - shutdown(): Promise { - return Promise.resolve(); - } -} - -// Values for `gen_ai.operation.name`. `invoke_agent`, `chat`, and -// `execute_tool` are well-known values from the OTel GenAI semantic conventions -// (https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/registry/attributes/gen-ai.md#gen-ai-operation-name); -// the spec mandates the well-known value whenever one applies. `assistant_text` -// and `thinking` have no well-known equivalent, so they're spec-permitted custom -// values — the model's natural-language output and its private reasoning, each -// emitted as a `chat` child so they interleave with sibling `execute_tool` spans. -export const OP = { - INVOKE_AGENT: 'invoke_agent', - CHAT: 'chat', - EXECUTE_TOOL: 'execute_tool', - ASSISTANT_TEXT: 'assistant_text', - THINKING: 'thinking', -} as const; - -/** - * Context carrying `parent` as the active span for child-span creation. - * Builds on `context.active()` (not `ROOT_CONTEXT`) so baggage on the active - * context propagates to children — relevant if we ever wire baggage for - * cross-process trace continuity. - */ -export function ctxWithParent(parent: Span): Context { - return trace.setSpan(otelContext.active(), parent); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Span builders -// ───────────────────────────────────────────────────────────────────────────── - -type TurnSpanArgs = { - /** Current process's Claude Code session id — stamped on the span as a - * debug breadcrumb (`weave.claude_code.session.id`). Per resume, this - * changes; the conversation id does not. */ - sessionId: string; - /** Stitching key for the multi-turn conversation. For resumed sessions, - * this is the root ancestor's session id (so turns from before and after - * resume share `gen_ai.conversation.id`). For fresh sessions, equals - * `sessionId`. */ - conversationId: string; - prompt: string; - cwd: string; - source: string; - pluginVersion: string; - /** Top-level agent name; becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. Defaults to `DEFAULT_AGENT_NAME`; - * the daemon resolves any user override before calling. */ - agentName: string; - requestModel?: string; - displayName?: string; - /** Loaded instruction-file contents (global/project CLAUDE.md, .claude/rules, - * @-imports) in load order, stamped as `gen_ai.system_instructions` (one text - * part per file) when non-empty. */ - systemInstructions?: string[]; -}; - -/** - * Start a turn span. Each turn is the root of its own trace; the Weave Agents - * backend stitches turns into a conversation via `gen_ai.conversation.id`. - * Session-level metadata (cwd, source, plugin.version) is stamped on every - * turn span so it's queryable without a separate session-level span. - */ -export function startTurnSpan(tracer: Tracer, args: TurnSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentName, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.WEAVE_SESSION_ID]: args.sessionId, - [ATTR.WEAVE_CWD]: args.cwd, - [ATTR.WEAVE_SOURCE]: args.source, - [ATTR.WEAVE_PLUGIN_VERSION]: args.pluginVersion, - [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: args.prompt }]), - }; - if (args.requestModel) attrs[ATTR.REQUEST_MODEL] = args.requestModel; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - if (args.systemInstructions?.length) { - attrs[ATTR.SYSTEM_INSTRUCTIONS] = jsonStr( - args.systemInstructions.map((content) => ({ type: 'text', content })), - ); - } - - // `weave.integration.*` is not set here — it rides the active session baggage - // and is stamped on this span (and all children) by - // IntegrationBaggageSpanProcessor at onStart. - // - // No parent span in context — turn spans are roots, one trace per turn. (The - // active context carries integration baggage but no span, so this stays a - // root.) - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ); -} - -type InvokeAgentSpanArgs = { - /** Agent type label — becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. For Claude Code subagents this is the - * `subagent_type` from the spawning `Agent` tool call (e.g. "Explore", - * "general-purpose"). */ - agentType: string; - /** Stitching key inherited from the parent turn span. */ - conversationId: string; - /** Plugin version, stamped as `gen_ai.agent.version` for parity with the - * outer turn span. */ - pluginVersion: string; - /** Initial input passed to the agent — typically the firing prompt from - * the parent agent's `Agent` tool call. Stamped as - * `gen_ai.input.messages`. */ - inputMessages?: unknown; - /** tool_use_id of the parent's `Agent` tool call. Stamped as a - * back-pointer attribute so queries can correlate the subagent - * invocation with the spawning tool call. */ - spawningToolCallId?: string; - displayName?: string; -}; - -/** - * Start a nested `invoke_agent` span — used for subagents Claude Code - * dispatches via the `Agent` tool. Child of the parent turn (or, for nested - * subagent calls, of the spawning subagent's invoke_agent span). Subagent - * `chat` spans and any tool calls the subagent runs parent under this span, - * which the Weave Agents chat view renders as an `agent_start` lifecycle - * marker followed by the subagent's own assistant text. - */ -export function startInvokeAgentSpan( - tracer: Tracer, - parentSpan: Span, - args: InvokeAgentSpanArgs, -): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentType, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, - }; - if (args.inputMessages !== undefined) { - attrs[ATTR.INPUT_MESSAGES] = jsonStr(args.inputMessages); - } - if (args.spawningToolCallId) { - attrs[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID] = args.spawningToolCallId; - } - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentType}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ToolSpanArgs = { - toolName: string; - toolUseId: string; - toolInput: Record; - /** Stitching key — same as the enclosing turn's `gen_ai.conversation.id`. */ - conversationId: string; - displayName?: string; -}; - -export function startToolSpan(tracer: Tracer, parentSpan: Span, args: ToolSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.EXECUTE_TOOL, - [ATTR.TOOL_NAME]: args.toolName, - [ATTR.TOOL_CALL_ID]: args.toolUseId, - [ATTR.TOOL_CALL_ARGUMENTS]: jsonStr(args.toolInput), - [ATTR.CONVERSATION_ID]: args.conversationId, - }; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.EXECUTE_TOOL} ${args.toolName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ChatSpanArgs = { - /** Stitching key — same value as the parent turn span's - * `gen_ai.conversation.id`. For subagent chats this is suffixed with - * `:${agent_id}` upstream so the subagent's calls form their own - * conversation under the spawning tool span. */ - conversationId: string; - model: string; - startedAt: TimeInput; - endedAt: TimeInput; - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; -}; - -/** - * Emit a chat span as a child of `parentSpan`. The span is started AND ended - * inside this helper. Used by code paths that construct the chat span from - * transcript data after the fact (SubagentStop, TeammateIdle). For the main - * agent path — where the chat span parents the assistant_text / thinking / - * execute_tool spans that occur during the API call — use `startChatSpan` / - * `finalizeChatSpan` instead. - */ -export function emitChatSpan( - tracer: Tracer, - parentSpan: Span, - args: ChatSpanArgs, -): void { - const span = startChatSpan(tracer, parentSpan, { - conversationId: args.conversationId, - model: args.model, - startedAt: args.startedAt, - }); - finalizeChatSpan(span, { - usage: args.usage, - reasoningTokens: args.reasoningTokens, - responseId: args.responseId, - finishReasons: args.finishReasons, - inputMessages: args.inputMessages, - outputMessages: args.outputMessages, - endedAt: args.endedAt, - }); -} - -type StartChatSpanArgs = { - conversationId: string; - model?: string; - startedAt: TimeInput; -}; - -/** - * Start a chat span (open). Caller is responsible for emitting any child - * spans and calling `finalizeChatSpan` with the usage data and end time. - * - * `model` is optional at open time — Anthropic returns it in the response, so - * it may not be known until the assistant message is parsed. When omitted, - * the span name uses a placeholder; `finalizeChatSpan` overwrites the name - * with the actual model once it's known. - */ -export function startChatSpan( - tracer: Tracer, - parentSpan: Span, - args: StartChatSpanArgs, -): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.CHAT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_TYPE]: 'text', - }; - if (args.model) { - attrs[ATTR.REQUEST_MODEL] = args.model; - const provider = providerFromModel(args.model); - if (provider) attrs[ATTR.PROVIDER_NAME] = provider; - } - const name = args.model ? `${OP.CHAT} ${args.model}` : OP.CHAT; - return tracer.startSpan( - name, - { kind: SpanKind.CLIENT, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); -} - -type FinalizeChatSpanArgs = { - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; - /** If set and the span was opened without a model, attaches the model - * attribute and updates the span name. */ - model?: string; - endedAt?: TimeInput; -}; - -/** Stamp usage / response attrs on an open chat span and end it. */ -export function finalizeChatSpan(span: Span, args: FinalizeChatSpanArgs): void { - // OTel `input_tokens` is the total prompt; Anthropic splits it into three - // disjoint fields (uncached + cache_read + cache_creation), so sum them. - // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md - const totalInputTokens = - args.usage.input_tokens - + (args.usage.cache_read_input_tokens ?? 0) - + (args.usage.cache_creation_input_tokens ?? 0); - - span.setAttribute(ATTR.USAGE_INPUT_TOKENS, totalInputTokens); - span.setAttribute(ATTR.USAGE_OUTPUT_TOKENS, args.usage.output_tokens); - if (args.usage.cache_read_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_READ_INPUT_TOKENS, args.usage.cache_read_input_tokens); - } - if (args.usage.cache_creation_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS, args.usage.cache_creation_input_tokens); - } - if (args.reasoningTokens !== undefined && args.reasoningTokens > 0) { - span.setAttribute(ATTR.USAGE_REASONING_TOKENS, args.reasoningTokens); - } - if (args.responseId) { - span.setAttribute(ATTR.RESPONSE_ID, args.responseId); - } - if (args.finishReasons?.length) { - span.setAttribute(ATTR.RESPONSE_FINISH_REASONS, args.finishReasons); - } - if (args.inputMessages !== undefined) { - span.setAttribute(ATTR.INPUT_MESSAGES, jsonStr(args.inputMessages)); - } - if (args.outputMessages !== undefined) { - span.setAttribute(ATTR.OUTPUT_MESSAGES, jsonStr(args.outputMessages)); - } - if (args.model) { - span.setAttribute(ATTR.REQUEST_MODEL, args.model); - const provider = providerFromModel(args.model); - if (provider) span.setAttribute(ATTR.PROVIDER_NAME, provider); - span.updateName(`${OP.CHAT} ${args.model}`); - } - span.end(args.endedAt); -} - -type AssistantTextSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one text content block from an assistant message. - * Renders in the trace tree between sibling `execute_tool` spans so the - * model's natural interleave (say something → call tool → say something → - * call tool) is visible. Carries the text on `gen_ai.output.messages` so - * Weave's UI shows the content; no token attributes — tokens live on the - * parent chat span. - */ -export function emitAssistantTextSpan( - tracer: Tracer, - parentSpan: Span, - args: AssistantTextSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.ASSISTANT_TEXT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'text', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.ASSISTANT_TEXT, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -type ThinkingSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one thinking content block. Like - * `emitAssistantTextSpan` but for `{type: 'thinking'}` blocks — Claude's - * private reasoning surfaced in its content stream. Kept distinct so callers - * can hide thinking spans in the UI without hiding ordinary assistant text. - */ -export function emitThinkingSpan( - tracer: Tracer, - parentSpan: Span, - args: ThinkingSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.THINKING, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'thinking', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.THINKING, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -/** - * Walk a parsed list of per-message details and emit one chat span per - * assistant message. `parentSpan` is the turn-level span (for the main agent) - * or the spawning Agent tool span (for a subagent). - */ -export function emitChatSpansFromAssistantCalls( - tracer: Tracer, - parentSpan: Span, - conversationId: string, - calls: AssistantCallDetail[], -): void { - for (const c of calls) { - if (!c.model) continue; - const startedAt = parseTimestamp(c.prevTimestamp) ?? parseTimestamp(c.timestamp) ?? new Date(); - const endedAt = parseTimestamp(c.timestamp) ?? new Date(); - emitChatSpan(tracer, parentSpan, { - conversationId, - model: c.model, - startedAt, - endedAt, - usage: c.usage, - reasoningTokens: c.reasoningTokens, - responseId: c.responseId, - finishReasons: c.finishReason ? [c.finishReason] : undefined, - outputMessages: c.contentBlocks.length - ? [{ role: 'assistant', content: assistantBlocksToText(c.contentBlocks), parts: c.contentBlocks }] - : undefined, - }); - } -} - - -function assistantBlocksToText(blocks: unknown[]): string { - return extractAssistantTextBlocks(blocks).join('\n'); -} - -export function promptSnippet(prompt: string, maxLen = 60): string { - return snippet(prompt, maxLen); -}