source dump of claude code
at main 927 lines 28 kB view raw
1/** 2 * Session Tracing for Claude Code using OpenTelemetry (BETA) 3 * 4 * This module provides a high-level API for creating and managing spans 5 * to trace Claude Code workflows. Each user interaction creates a root 6 * interaction span, which contains operation spans (LLM requests, tool calls, etc.). 7 * 8 * Requirements: 9 * - Enhanced telemetry is enabled via feature('ENHANCED_TELEMETRY_BETA') 10 * - Configure OTEL_TRACES_EXPORTER (console, otlp, etc.) 11 */ 12 13import { feature } from 'bun:bundle' 14import { context as otelContext, type Span, trace } from '@opentelemetry/api' 15import { AsyncLocalStorage } from 'async_hooks' 16import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 17import type { AssistantMessage, UserMessage } from '../../types/message.js' 18import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js' 19import { getTelemetryAttributes } from '../telemetryAttributes.js' 20import { 21 addBetaInteractionAttributes, 22 addBetaLLMRequestAttributes, 23 addBetaLLMResponseAttributes, 24 addBetaToolInputAttributes, 25 addBetaToolResultAttributes, 26 isBetaTracingEnabled, 27 type LLMRequestNewContext, 28 truncateContent, 29} from './betaSessionTracing.js' 30import { 31 endInteractionPerfettoSpan, 32 endLLMRequestPerfettoSpan, 33 endToolPerfettoSpan, 34 endUserInputPerfettoSpan, 35 isPerfettoTracingEnabled, 36 startInteractionPerfettoSpan, 37 startLLMRequestPerfettoSpan, 38 startToolPerfettoSpan, 39 startUserInputPerfettoSpan, 40} from './perfettoTracing.js' 41 42// Re-export for callers 43export type { Span } 44export { isBetaTracingEnabled, type LLMRequestNewContext } 45 46// Message type for API calls (UserMessage or AssistantMessage) 47type APIMessage = UserMessage | AssistantMessage 48 49type SpanType = 50 | 'interaction' 51 | 'llm_request' 52 | 'tool' 53 | 'tool.blocked_on_user' 54 | 'tool.execution' 55 | 'hook' 56 57interface SpanContext { 58 span: Span 59 startTime: number 60 attributes: Record<string, string | number | boolean> 61 ended?: boolean 62 perfettoSpanId?: string 63} 64 65// ALS stores SpanContext directly so it holds a strong reference while a span 66// is active. With that, activeSpans can use WeakRef — when ALS is cleared 67// (enterWith(undefined)) and no other code holds the SpanContext, GC can collect 68// it and the WeakRef goes stale. 69const interactionContext = new AsyncLocalStorage<SpanContext | undefined>() 70const toolContext = new AsyncLocalStorage<SpanContext | undefined>() 71const activeSpans = new Map<string, WeakRef<SpanContext>>() 72// Spans not stored in ALS (LLM request, blocked-on-user, tool execution, hook) 73// need a strong reference to prevent GC from collecting the SpanContext before 74// the corresponding end* function retrieves it. 75const strongSpans = new Map<string, SpanContext>() 76let interactionSequence = 0 77let _cleanupIntervalStarted = false 78 79const SPAN_TTL_MS = 30 * 60 * 1000 // 30 minutes 80 81function getSpanId(span: Span): string { 82 return span.spanContext().spanId || '' 83} 84 85/** 86 * Lazily start a background interval that evicts orphaned spans from activeSpans. 87 * 88 * Normal teardown calls endInteractionSpan / endToolSpan, which delete spans 89 * immediately. This interval is a safety net for spans that were never ended 90 * (e.g. aborted streams, uncaught exceptions mid-query) — without it they 91 * accumulate in activeSpans indefinitely, holding references to Span objects 92 * and the OpenTelemetry context chain. 93 * 94 * Initialized on the first startInteractionSpan call (not at module load) to 95 * avoid triggering the no-top-level-side-effects lint rule and to keep the 96 * interval from running in processes that never start a span. 97 * unref() prevents the timer from keeping the process alive after all other 98 * work is done. 99 */ 100function ensureCleanupInterval(): void { 101 if (_cleanupIntervalStarted) return 102 _cleanupIntervalStarted = true 103 const interval = setInterval(() => { 104 const cutoff = Date.now() - SPAN_TTL_MS 105 for (const [spanId, weakRef] of activeSpans) { 106 const ctx = weakRef.deref() 107 if (ctx === undefined) { 108 activeSpans.delete(spanId) 109 strongSpans.delete(spanId) 110 } else if (ctx.startTime < cutoff) { 111 if (!ctx.ended) ctx.span.end() // flush any recorded attributes to the exporter 112 activeSpans.delete(spanId) 113 strongSpans.delete(spanId) 114 } 115 } 116 }, 60_000) 117 if (typeof interval.unref === 'function') { 118 interval.unref() // Node.js / Bun: don't block process exit 119 } 120} 121 122/** 123 * Check if enhanced telemetry is enabled. 124 * Priority: env var override > ant build > GrowthBook gate 125 */ 126export function isEnhancedTelemetryEnabled(): boolean { 127 if (feature('ENHANCED_TELEMETRY_BETA')) { 128 const env = 129 process.env.CLAUDE_CODE_ENHANCED_TELEMETRY_BETA ?? 130 process.env.ENABLE_ENHANCED_TELEMETRY_BETA 131 if (isEnvTruthy(env)) { 132 return true 133 } 134 if (isEnvDefinedFalsy(env)) { 135 return false 136 } 137 return ( 138 process.env.USER_TYPE === 'ant' || 139 getFeatureValue_CACHED_MAY_BE_STALE('enhanced_telemetry_beta', false) 140 ) 141 } 142 return false 143} 144 145/** 146 * Check if any tracing is enabled (either standard enhanced telemetry OR beta tracing) 147 */ 148function isAnyTracingEnabled(): boolean { 149 return isEnhancedTelemetryEnabled() || isBetaTracingEnabled() 150} 151 152function getTracer() { 153 return trace.getTracer('com.anthropic.claude_code.tracing', '1.0.0') 154} 155 156function createSpanAttributes( 157 spanType: SpanType, 158 customAttributes: Record<string, string | number | boolean> = {}, 159): Record<string, string | number | boolean> { 160 const baseAttributes = getTelemetryAttributes() 161 162 const attributes: Record<string, string | number | boolean> = { 163 ...baseAttributes, 164 'span.type': spanType, 165 ...customAttributes, 166 } 167 168 return attributes 169} 170 171/** 172 * Start an interaction span. This wraps a user request -> Claude response cycle. 173 * This is now a root span that includes all session-level attributes. 174 * Sets the interaction context for all subsequent operations. 175 */ 176export function startInteractionSpan(userPrompt: string): Span { 177 ensureCleanupInterval() 178 179 // Start Perfetto span regardless of OTel tracing state 180 const perfettoSpanId = isPerfettoTracingEnabled() 181 ? startInteractionPerfettoSpan(userPrompt) 182 : undefined 183 184 if (!isAnyTracingEnabled()) { 185 // Still track Perfetto span even if OTel is disabled 186 if (perfettoSpanId) { 187 const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 188 const spanId = getSpanId(dummySpan) 189 const spanContextObj: SpanContext = { 190 span: dummySpan, 191 startTime: Date.now(), 192 attributes: {}, 193 perfettoSpanId, 194 } 195 activeSpans.set(spanId, new WeakRef(spanContextObj)) 196 interactionContext.enterWith(spanContextObj) 197 return dummySpan 198 } 199 return trace.getActiveSpan() || getTracer().startSpan('dummy') 200 } 201 202 const tracer = getTracer() 203 const isUserPromptLoggingEnabled = isEnvTruthy( 204 process.env.OTEL_LOG_USER_PROMPTS, 205 ) 206 const promptToLog = isUserPromptLoggingEnabled ? userPrompt : '<REDACTED>' 207 208 interactionSequence++ 209 210 const attributes = createSpanAttributes('interaction', { 211 user_prompt: promptToLog, 212 user_prompt_length: userPrompt.length, 213 'interaction.sequence': interactionSequence, 214 }) 215 216 const span = tracer.startSpan('claude_code.interaction', { 217 attributes, 218 }) 219 220 // Add experimental attributes (new_context) 221 addBetaInteractionAttributes(span, userPrompt) 222 223 const spanId = getSpanId(span) 224 const spanContextObj: SpanContext = { 225 span, 226 startTime: Date.now(), 227 attributes, 228 perfettoSpanId, 229 } 230 activeSpans.set(spanId, new WeakRef(spanContextObj)) 231 232 interactionContext.enterWith(spanContextObj) 233 234 return span 235} 236 237export function endInteractionSpan(): void { 238 const spanContext = interactionContext.getStore() 239 if (!spanContext) { 240 return 241 } 242 243 if (spanContext.ended) { 244 return 245 } 246 247 // End Perfetto span 248 if (spanContext.perfettoSpanId) { 249 endInteractionPerfettoSpan(spanContext.perfettoSpanId) 250 } 251 252 if (!isAnyTracingEnabled()) { 253 spanContext.ended = true 254 activeSpans.delete(getSpanId(spanContext.span)) 255 // Clear the store so async continuations created after this point (timers, 256 // promise callbacks, I/O) do not inherit a reference to the ended span. 257 // enterWith(undefined) is intentional: exit(() => {}) is a no-op because it 258 // only suppresses the store inside the callback and returns immediately. 259 interactionContext.enterWith(undefined) 260 return 261 } 262 263 const duration = Date.now() - spanContext.startTime 264 spanContext.span.setAttributes({ 265 'interaction.duration_ms': duration, 266 }) 267 268 spanContext.span.end() 269 spanContext.ended = true 270 activeSpans.delete(getSpanId(spanContext.span)) 271 interactionContext.enterWith(undefined) 272} 273 274export function startLLMRequestSpan( 275 model: string, 276 newContext?: LLMRequestNewContext, 277 messagesForAPI?: APIMessage[], 278 fastMode?: boolean, 279): Span { 280 // Start Perfetto span regardless of OTel tracing state 281 const perfettoSpanId = isPerfettoTracingEnabled() 282 ? startLLMRequestPerfettoSpan({ 283 model, 284 querySource: newContext?.querySource, 285 messageId: undefined, // Will be set in endLLMRequestSpan 286 }) 287 : undefined 288 289 if (!isAnyTracingEnabled()) { 290 // Still track Perfetto span even if OTel is disabled 291 if (perfettoSpanId) { 292 const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 293 const spanId = getSpanId(dummySpan) 294 const spanContextObj: SpanContext = { 295 span: dummySpan, 296 startTime: Date.now(), 297 attributes: { model }, 298 perfettoSpanId, 299 } 300 activeSpans.set(spanId, new WeakRef(spanContextObj)) 301 strongSpans.set(spanId, spanContextObj) 302 return dummySpan 303 } 304 return trace.getActiveSpan() || getTracer().startSpan('dummy') 305 } 306 307 const tracer = getTracer() 308 const parentSpanCtx = interactionContext.getStore() 309 310 const attributes = createSpanAttributes('llm_request', { 311 model: model, 312 'llm_request.context': parentSpanCtx ? 'interaction' : 'standalone', 313 speed: fastMode ? 'fast' : 'normal', 314 }) 315 316 const ctx = parentSpanCtx 317 ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 318 : otelContext.active() 319 const span = tracer.startSpan('claude_code.llm_request', { attributes }, ctx) 320 321 // Add query_source (agent name) if provided 322 if (newContext?.querySource) { 323 span.setAttribute('query_source', newContext.querySource) 324 } 325 326 // Add experimental attributes (system prompt, new_context) 327 addBetaLLMRequestAttributes(span, newContext, messagesForAPI) 328 329 const spanId = getSpanId(span) 330 const spanContextObj: SpanContext = { 331 span, 332 startTime: Date.now(), 333 attributes, 334 perfettoSpanId, 335 } 336 activeSpans.set(spanId, new WeakRef(spanContextObj)) 337 strongSpans.set(spanId, spanContextObj) 338 339 return span 340} 341 342/** 343 * End an LLM request span and attach response metadata. 344 * 345 * @param span - Optional. The exact span returned by startLLMRequestSpan(). 346 * IMPORTANT: When multiple LLM requests run in parallel (e.g., warmup requests, 347 * topic classifier, file path extractor, main thread), you MUST pass the specific span 348 * to ensure responses are attached to the correct request. Without it, responses may be 349 * incorrectly attached to whichever span happens to be "last" in the activeSpans map. 350 * 351 * If not provided, falls back to finding the most recent llm_request span (legacy behavior). 352 */ 353export function endLLMRequestSpan( 354 span?: Span, 355 metadata?: { 356 inputTokens?: number 357 outputTokens?: number 358 cacheReadTokens?: number 359 cacheCreationTokens?: number 360 success?: boolean 361 statusCode?: number 362 error?: string 363 attempt?: number 364 modelResponse?: string 365 /** Text output from the model (non-thinking content) */ 366 modelOutput?: string 367 /** Thinking/reasoning output from the model */ 368 thinkingOutput?: string 369 /** Whether the output included tool calls (look at tool spans for details) */ 370 hasToolCall?: boolean 371 /** Time to first token in milliseconds */ 372 ttftMs?: number 373 /** Time spent in pre-request setup before the successful attempt */ 374 requestSetupMs?: number 375 /** Timestamps (Date.now()) of each attempt start — used to emit retry sub-spans */ 376 attemptStartTimes?: number[] 377 }, 378): void { 379 let llmSpanContext: SpanContext | undefined 380 381 if (span) { 382 // Use the provided span directly - this is the correct approach for parallel requests 383 const spanId = getSpanId(span) 384 llmSpanContext = activeSpans.get(spanId)?.deref() 385 } else { 386 // Legacy fallback: find the most recent llm_request span 387 // WARNING: This can cause mismatched responses when multiple requests are in flight 388 llmSpanContext = Array.from(activeSpans.values()) 389 .findLast(r => { 390 const ctx = r.deref() 391 return ( 392 ctx?.attributes['span.type'] === 'llm_request' || 393 ctx?.attributes['model'] 394 ) 395 }) 396 ?.deref() 397 } 398 399 if (!llmSpanContext) { 400 // Span was already ended or never tracked 401 return 402 } 403 404 const duration = Date.now() - llmSpanContext.startTime 405 406 // End Perfetto span with full metadata 407 if (llmSpanContext.perfettoSpanId) { 408 endLLMRequestPerfettoSpan(llmSpanContext.perfettoSpanId, { 409 ttftMs: metadata?.ttftMs, 410 ttltMs: duration, // Time to last token is the total duration 411 promptTokens: metadata?.inputTokens, 412 outputTokens: metadata?.outputTokens, 413 cacheReadTokens: metadata?.cacheReadTokens, 414 cacheCreationTokens: metadata?.cacheCreationTokens, 415 success: metadata?.success, 416 error: metadata?.error, 417 requestSetupMs: metadata?.requestSetupMs, 418 attemptStartTimes: metadata?.attemptStartTimes, 419 }) 420 } 421 422 if (!isAnyTracingEnabled()) { 423 const spanId = getSpanId(llmSpanContext.span) 424 activeSpans.delete(spanId) 425 strongSpans.delete(spanId) 426 return 427 } 428 429 const endAttributes: Record<string, string | number | boolean> = { 430 duration_ms: duration, 431 } 432 433 if (metadata) { 434 if (metadata.inputTokens !== undefined) 435 endAttributes['input_tokens'] = metadata.inputTokens 436 if (metadata.outputTokens !== undefined) 437 endAttributes['output_tokens'] = metadata.outputTokens 438 if (metadata.cacheReadTokens !== undefined) 439 endAttributes['cache_read_tokens'] = metadata.cacheReadTokens 440 if (metadata.cacheCreationTokens !== undefined) 441 endAttributes['cache_creation_tokens'] = metadata.cacheCreationTokens 442 if (metadata.success !== undefined) 443 endAttributes['success'] = metadata.success 444 if (metadata.statusCode !== undefined) 445 endAttributes['status_code'] = metadata.statusCode 446 if (metadata.error !== undefined) endAttributes['error'] = metadata.error 447 if (metadata.attempt !== undefined) 448 endAttributes['attempt'] = metadata.attempt 449 if (metadata.hasToolCall !== undefined) 450 endAttributes['response.has_tool_call'] = metadata.hasToolCall 451 if (metadata.ttftMs !== undefined) 452 endAttributes['ttft_ms'] = metadata.ttftMs 453 454 // Add experimental response attributes (model_output, thinking_output) 455 addBetaLLMResponseAttributes(endAttributes, metadata) 456 } 457 458 llmSpanContext.span.setAttributes(endAttributes) 459 llmSpanContext.span.end() 460 461 const spanId = getSpanId(llmSpanContext.span) 462 activeSpans.delete(spanId) 463 strongSpans.delete(spanId) 464} 465 466export function startToolSpan( 467 toolName: string, 468 toolAttributes?: Record<string, string | number | boolean>, 469 toolInput?: string, 470): Span { 471 // Start Perfetto span regardless of OTel tracing state 472 const perfettoSpanId = isPerfettoTracingEnabled() 473 ? startToolPerfettoSpan(toolName, toolAttributes) 474 : undefined 475 476 if (!isAnyTracingEnabled()) { 477 // Still track Perfetto span even if OTel is disabled 478 if (perfettoSpanId) { 479 const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 480 const spanId = getSpanId(dummySpan) 481 const spanContextObj: SpanContext = { 482 span: dummySpan, 483 startTime: Date.now(), 484 attributes: { 'span.type': 'tool', tool_name: toolName }, 485 perfettoSpanId, 486 } 487 activeSpans.set(spanId, new WeakRef(spanContextObj)) 488 toolContext.enterWith(spanContextObj) 489 return dummySpan 490 } 491 return trace.getActiveSpan() || getTracer().startSpan('dummy') 492 } 493 494 const tracer = getTracer() 495 const parentSpanCtx = interactionContext.getStore() 496 497 const attributes = createSpanAttributes('tool', { 498 tool_name: toolName, 499 ...toolAttributes, 500 }) 501 502 const ctx = parentSpanCtx 503 ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 504 : otelContext.active() 505 const span = tracer.startSpan('claude_code.tool', { attributes }, ctx) 506 507 // Add experimental tool input attributes 508 if (toolInput) { 509 addBetaToolInputAttributes(span, toolName, toolInput) 510 } 511 512 const spanId = getSpanId(span) 513 const spanContextObj: SpanContext = { 514 span, 515 startTime: Date.now(), 516 attributes, 517 perfettoSpanId, 518 } 519 activeSpans.set(spanId, new WeakRef(spanContextObj)) 520 521 toolContext.enterWith(spanContextObj) 522 523 return span 524} 525 526export function startToolBlockedOnUserSpan(): Span { 527 // Start Perfetto span regardless of OTel tracing state 528 const perfettoSpanId = isPerfettoTracingEnabled() 529 ? startUserInputPerfettoSpan('tool_permission') 530 : undefined 531 532 if (!isAnyTracingEnabled()) { 533 // Still track Perfetto span even if OTel is disabled 534 if (perfettoSpanId) { 535 const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 536 const spanId = getSpanId(dummySpan) 537 const spanContextObj: SpanContext = { 538 span: dummySpan, 539 startTime: Date.now(), 540 attributes: { 'span.type': 'tool.blocked_on_user' }, 541 perfettoSpanId, 542 } 543 activeSpans.set(spanId, new WeakRef(spanContextObj)) 544 strongSpans.set(spanId, spanContextObj) 545 return dummySpan 546 } 547 return trace.getActiveSpan() || getTracer().startSpan('dummy') 548 } 549 550 const tracer = getTracer() 551 const parentSpanCtx = toolContext.getStore() 552 553 const attributes = createSpanAttributes('tool.blocked_on_user') 554 555 const ctx = parentSpanCtx 556 ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 557 : otelContext.active() 558 const span = tracer.startSpan( 559 'claude_code.tool.blocked_on_user', 560 { attributes }, 561 ctx, 562 ) 563 564 const spanId = getSpanId(span) 565 const spanContextObj: SpanContext = { 566 span, 567 startTime: Date.now(), 568 attributes, 569 perfettoSpanId, 570 } 571 activeSpans.set(spanId, new WeakRef(spanContextObj)) 572 strongSpans.set(spanId, spanContextObj) 573 574 return span 575} 576 577export function endToolBlockedOnUserSpan( 578 decision?: string, 579 source?: string, 580): void { 581 const blockedSpanContext = Array.from(activeSpans.values()) 582 .findLast( 583 r => r.deref()?.attributes['span.type'] === 'tool.blocked_on_user', 584 ) 585 ?.deref() 586 587 if (!blockedSpanContext) { 588 return 589 } 590 591 // End Perfetto span 592 if (blockedSpanContext.perfettoSpanId) { 593 endUserInputPerfettoSpan(blockedSpanContext.perfettoSpanId, { 594 decision, 595 source, 596 }) 597 } 598 599 if (!isAnyTracingEnabled()) { 600 const spanId = getSpanId(blockedSpanContext.span) 601 activeSpans.delete(spanId) 602 strongSpans.delete(spanId) 603 return 604 } 605 606 const duration = Date.now() - blockedSpanContext.startTime 607 const attributes: Record<string, string | number | boolean> = { 608 duration_ms: duration, 609 } 610 611 if (decision) { 612 attributes['decision'] = decision 613 } 614 if (source) { 615 attributes['source'] = source 616 } 617 618 blockedSpanContext.span.setAttributes(attributes) 619 blockedSpanContext.span.end() 620 621 const spanId = getSpanId(blockedSpanContext.span) 622 activeSpans.delete(spanId) 623 strongSpans.delete(spanId) 624} 625 626export function startToolExecutionSpan(): Span { 627 if (!isAnyTracingEnabled()) { 628 return trace.getActiveSpan() || getTracer().startSpan('dummy') 629 } 630 631 const tracer = getTracer() 632 const parentSpanCtx = toolContext.getStore() 633 634 const attributes = createSpanAttributes('tool.execution') 635 636 const ctx = parentSpanCtx 637 ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 638 : otelContext.active() 639 const span = tracer.startSpan( 640 'claude_code.tool.execution', 641 { attributes }, 642 ctx, 643 ) 644 645 const spanId = getSpanId(span) 646 const spanContextObj: SpanContext = { 647 span, 648 startTime: Date.now(), 649 attributes, 650 } 651 activeSpans.set(spanId, new WeakRef(spanContextObj)) 652 strongSpans.set(spanId, spanContextObj) 653 654 return span 655} 656 657export function endToolExecutionSpan(metadata?: { 658 success?: boolean 659 error?: string 660}): void { 661 if (!isAnyTracingEnabled()) { 662 return 663 } 664 665 const executionSpanContext = Array.from(activeSpans.values()) 666 .findLast(r => r.deref()?.attributes['span.type'] === 'tool.execution') 667 ?.deref() 668 669 if (!executionSpanContext) { 670 return 671 } 672 673 const duration = Date.now() - executionSpanContext.startTime 674 const attributes: Record<string, string | number | boolean> = { 675 duration_ms: duration, 676 } 677 678 if (metadata) { 679 if (metadata.success !== undefined) attributes['success'] = metadata.success 680 if (metadata.error !== undefined) attributes['error'] = metadata.error 681 } 682 683 executionSpanContext.span.setAttributes(attributes) 684 executionSpanContext.span.end() 685 686 const spanId = getSpanId(executionSpanContext.span) 687 activeSpans.delete(spanId) 688 strongSpans.delete(spanId) 689} 690 691export function endToolSpan(toolResult?: string, resultTokens?: number): void { 692 const toolSpanContext = toolContext.getStore() 693 694 if (!toolSpanContext) { 695 return 696 } 697 698 // End Perfetto span 699 if (toolSpanContext.perfettoSpanId) { 700 endToolPerfettoSpan(toolSpanContext.perfettoSpanId, { 701 success: true, 702 resultTokens, 703 }) 704 } 705 706 if (!isAnyTracingEnabled()) { 707 const spanId = getSpanId(toolSpanContext.span) 708 activeSpans.delete(spanId) 709 // Same reasoning as interactionContext above: clear so subsequent async 710 // work doesn't hold a stale reference to the ended tool span. 711 toolContext.enterWith(undefined) 712 return 713 } 714 715 const duration = Date.now() - toolSpanContext.startTime 716 const endAttributes: Record<string, string | number | boolean> = { 717 duration_ms: duration, 718 } 719 720 // Add experimental tool result attributes (new_context) 721 if (toolResult) { 722 const toolName = toolSpanContext.attributes['tool_name'] || 'unknown' 723 addBetaToolResultAttributes(endAttributes, toolName, toolResult) 724 } 725 726 if (resultTokens !== undefined) { 727 endAttributes['result_tokens'] = resultTokens 728 } 729 730 toolSpanContext.span.setAttributes(endAttributes) 731 toolSpanContext.span.end() 732 733 const spanId = getSpanId(toolSpanContext.span) 734 activeSpans.delete(spanId) 735 toolContext.enterWith(undefined) 736} 737 738function isToolContentLoggingEnabled(): boolean { 739 return isEnvTruthy(process.env.OTEL_LOG_TOOL_CONTENT) 740} 741 742/** 743 * Add a span event with tool content/output data. 744 * Only logs if OTEL_LOG_TOOL_CONTENT=1 is set. 745 * Truncates content if it exceeds MAX_CONTENT_SIZE. 746 */ 747export function addToolContentEvent( 748 eventName: string, 749 attributes: Record<string, string | number | boolean>, 750): void { 751 if (!isAnyTracingEnabled() || !isToolContentLoggingEnabled()) { 752 return 753 } 754 755 const currentSpanCtx = toolContext.getStore() 756 if (!currentSpanCtx) { 757 return 758 } 759 760 // Truncate string attributes that might be large 761 const processedAttributes: Record<string, string | number | boolean> = {} 762 for (const [key, value] of Object.entries(attributes)) { 763 if (typeof value === 'string') { 764 const { content, truncated } = truncateContent(value) 765 processedAttributes[key] = content 766 if (truncated) { 767 processedAttributes[`${key}_truncated`] = true 768 processedAttributes[`${key}_original_length`] = value.length 769 } 770 } else { 771 processedAttributes[key] = value 772 } 773 } 774 775 currentSpanCtx.span.addEvent(eventName, processedAttributes) 776} 777 778export function getCurrentSpan(): Span | null { 779 if (!isAnyTracingEnabled()) { 780 return null 781 } 782 783 return ( 784 toolContext.getStore()?.span ?? interactionContext.getStore()?.span ?? null 785 ) 786} 787 788export async function executeInSpan<T>( 789 spanName: string, 790 fn: (span: Span) => Promise<T>, 791 attributes?: Record<string, string | number | boolean>, 792): Promise<T> { 793 if (!isAnyTracingEnabled()) { 794 return fn(trace.getActiveSpan() || getTracer().startSpan('dummy')) 795 } 796 797 const tracer = getTracer() 798 const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore() 799 800 const finalAttributes = createSpanAttributes('tool', { 801 ...attributes, 802 }) 803 804 const ctx = parentSpanCtx 805 ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 806 : otelContext.active() 807 const span = tracer.startSpan(spanName, { attributes: finalAttributes }, ctx) 808 809 const spanId = getSpanId(span) 810 const spanContextObj: SpanContext = { 811 span, 812 startTime: Date.now(), 813 attributes: finalAttributes, 814 } 815 activeSpans.set(spanId, new WeakRef(spanContextObj)) 816 strongSpans.set(spanId, spanContextObj) 817 818 try { 819 const result = await fn(span) 820 span.end() 821 activeSpans.delete(spanId) 822 strongSpans.delete(spanId) 823 return result 824 } catch (error) { 825 if (error instanceof Error) { 826 span.recordException(error) 827 } 828 span.end() 829 activeSpans.delete(spanId) 830 strongSpans.delete(spanId) 831 throw error 832 } 833} 834 835/** 836 * Start a hook execution span. 837 * Only creates a span when beta tracing is enabled. 838 * @param hookEvent The hook event type (e.g., 'PreToolUse', 'PostToolUse') 839 * @param hookName The full hook name (e.g., 'PreToolUse:Write') 840 * @param numHooks The number of hooks being executed 841 * @param hookDefinitions JSON string of hook definitions for tracing 842 * @returns The span (or a dummy span if tracing is disabled) 843 */ 844export function startHookSpan( 845 hookEvent: string, 846 hookName: string, 847 numHooks: number, 848 hookDefinitions: string, 849): Span { 850 if (!isBetaTracingEnabled()) { 851 return trace.getActiveSpan() || getTracer().startSpan('dummy') 852 } 853 854 const tracer = getTracer() 855 const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore() 856 857 const attributes = createSpanAttributes('hook', { 858 hook_event: hookEvent, 859 hook_name: hookName, 860 num_hooks: numHooks, 861 hook_definitions: hookDefinitions, 862 }) 863 864 const ctx = parentSpanCtx 865 ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 866 : otelContext.active() 867 const span = tracer.startSpan('claude_code.hook', { attributes }, ctx) 868 869 const spanId = getSpanId(span) 870 const spanContextObj: SpanContext = { 871 span, 872 startTime: Date.now(), 873 attributes, 874 } 875 activeSpans.set(spanId, new WeakRef(spanContextObj)) 876 strongSpans.set(spanId, spanContextObj) 877 878 return span 879} 880 881/** 882 * End a hook execution span with outcome metadata. 883 * Only does work when beta tracing is enabled. 884 * @param span The span to end (returned from startHookSpan) 885 * @param metadata The outcome metadata for the hook execution 886 */ 887export function endHookSpan( 888 span: Span, 889 metadata?: { 890 numSuccess?: number 891 numBlocking?: number 892 numNonBlockingError?: number 893 numCancelled?: number 894 }, 895): void { 896 if (!isBetaTracingEnabled()) { 897 return 898 } 899 900 const spanId = getSpanId(span) 901 const spanContext = activeSpans.get(spanId)?.deref() 902 903 if (!spanContext) { 904 return 905 } 906 907 const duration = Date.now() - spanContext.startTime 908 const endAttributes: Record<string, string | number | boolean> = { 909 duration_ms: duration, 910 } 911 912 if (metadata) { 913 if (metadata.numSuccess !== undefined) 914 endAttributes['num_success'] = metadata.numSuccess 915 if (metadata.numBlocking !== undefined) 916 endAttributes['num_blocking'] = metadata.numBlocking 917 if (metadata.numNonBlockingError !== undefined) 918 endAttributes['num_non_blocking_error'] = metadata.numNonBlockingError 919 if (metadata.numCancelled !== undefined) 920 endAttributes['num_cancelled'] = metadata.numCancelled 921 } 922 923 spanContext.span.setAttributes(endAttributes) 924 spanContext.span.end() 925 activeSpans.delete(spanId) 926 strongSpans.delete(spanId) 927}