/ utils / telemetry / sessionTracing.ts
sessionTracing.ts
  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  
 13  import { feature } from 'bun:bundle'
 14  import { context as otelContext, type Span, trace } from '@opentelemetry/api'
 15  import { AsyncLocalStorage } from 'async_hooks'
 16  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
 17  import type { AssistantMessage, UserMessage } from '../../types/message.js'
 18  import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js'
 19  import { getTelemetryAttributes } from '../telemetryAttributes.js'
 20  import {
 21    addBetaInteractionAttributes,
 22    addBetaLLMRequestAttributes,
 23    addBetaLLMResponseAttributes,
 24    addBetaToolInputAttributes,
 25    addBetaToolResultAttributes,
 26    isBetaTracingEnabled,
 27    type LLMRequestNewContext,
 28    truncateContent,
 29  } from './betaSessionTracing.js'
 30  import {
 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
 43  export type { Span }
 44  export { isBetaTracingEnabled, type LLMRequestNewContext }
 45  
 46  // Message type for API calls (UserMessage or AssistantMessage)
 47  type APIMessage = UserMessage | AssistantMessage
 48  
 49  type SpanType =
 50    | 'interaction'
 51    | 'llm_request'
 52    | 'tool'
 53    | 'tool.blocked_on_user'
 54    | 'tool.execution'
 55    | 'hook'
 56  
 57  interface 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.
 69  const interactionContext = new AsyncLocalStorage<SpanContext | undefined>()
 70  const toolContext = new AsyncLocalStorage<SpanContext | undefined>()
 71  const 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.
 75  const strongSpans = new Map<string, SpanContext>()
 76  let interactionSequence = 0
 77  let _cleanupIntervalStarted = false
 78  
 79  const SPAN_TTL_MS = 30 * 60 * 1000 // 30 minutes
 80  
 81  function 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   */
100  function 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   */
126  export 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   */
148  function isAnyTracingEnabled(): boolean {
149    return isEnhancedTelemetryEnabled() || isBetaTracingEnabled()
150  }
151  
152  function getTracer() {
153    return trace.getTracer('com.anthropic.claude_code.tracing', '1.0.0')
154  }
155  
156  function 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   */
176  export 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  
237  export 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  
274  export 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   */
353  export 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  
466  export 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  
526  export 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  
577  export 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  
626  export 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  
657  export 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  
691  export 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  
738  function 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   */
747  export 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  
778  export 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  
788  export 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   */
844  export 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   */
887  export 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  }