Class: ClaudeAgentSDK::Instrumentation::OTelObserver

Inherits:
Object
  • Object
show all
Includes:
Observer
Defined in:
lib/claude_agent_sdk/instrumentation/otel.rb

Overview

OpenTelemetry observer that emits spans for Claude Agent SDK messages.

Uses standard gen_ai.* semantic conventions recognized by Langfuse, Datadog, Jaeger, and other OTel-compatible backends.

Requires the opentelemetry-api gem at runtime. Users must configure opentelemetry-sdk and an exporter (e.g., opentelemetry-exporter-otlp) themselves before creating this observer.

Examples:

With Langfuse via OTLP

require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'claude_agent_sdk/instrumentation'

OpenTelemetry::SDK.configure do |c|
  c.service_name = 'my-app'
  c.add_span_processor(
    OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
      OpenTelemetry::Exporter::OTLP::Exporter.new(
        endpoint: 'https://cloud.langfuse.com/api/public/otel/v1/traces',
        headers: { 'Authorization' => "Basic #{auth}" }
      )
    )
  )
end

observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new
options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer])
ClaudeAgentSDK.query(prompt: "Hello", options: options) { |msg| ... }

Constant Summary collapse

TRACER_NAME =
'claude_agent_sdk'
MAX_ATTRIBUTE_LENGTH =
4096

Instance Method Summary collapse

Constructor Details

#initialize(tracer_name: TRACER_NAME, **default_attributes) ⇒ OTelObserver

Returns a new instance of OTelObserver.



43
44
45
46
47
48
49
50
51
52
53
54
55
56
# File 'lib/claude_agent_sdk/instrumentation/otel.rb', line 43

def initialize(tracer_name: TRACER_NAME, **default_attributes)
  require 'opentelemetry'
  @tracer = OpenTelemetry.tracer_provider.tracer(
    tracer_name,
    defined?(ClaudeAgentSDK::VERSION) ? ClaudeAgentSDK::VERSION : '0.0.0'
  )
  @default_attributes = default_attributes
  @root_span = nil
  @root_context = nil
  @tool_spans = {} # tool_use_id => span
  @first_user_input = nil # first user prompt of the current trace
  @pending_prompt = nil # prompt that belongs to the NEXT trace (see on_user_prompt)
  @last_assistant_text = nil # capture last assistant text for trace output
end

Instance Method Details

#on_closeObject



112
113
114
115
116
117
118
119
# File 'lib/claude_agent_sdk/instrumentation/otel.rb', line 112

def on_close
  finish_open_spans
  reset_session_buffers
  # Unlike the per-trace buffers, the pending next-trace prompt survives
  # end_trace/supersede resets by design — but the session is over now,
  # and a reused observer must not leak it into the next session.
  @pending_prompt = nil
end

#on_error(error) ⇒ Object

Recording-only by design: a Client session can survive an error (the user may rescue one bad message and keep receiving), so finishing here would orphan later spans and break the next turn. Finish ownership stays with end_trace/on_close; start_trace also finishes any dangling span from a previous trace as the never-disconnected backstop.



105
106
107
108
109
110
# File 'lib/claude_agent_sdk/instrumentation/otel.rb', line 105

def on_error(error)
  return unless @root_span

  @root_span.record_exception(error)
  @root_span.status = OpenTelemetry::Trace::Status.error(error.message)
end

#on_message(message) ⇒ Object



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/claude_agent_sdk/instrumentation/otel.rb', line 81

def on_message(message)
  case message
  when ClaudeAgentSDK::InitMessage
    start_trace(message)
  when ClaudeAgentSDK::AssistantMessage
    handle_assistant(message)
  when ClaudeAgentSDK::UserMessage
    handle_user(message)
  when ClaudeAgentSDK::ResultMessage
    end_trace(message)
  when ClaudeAgentSDK::APIRetryMessage
    record_retry_event(message)
  when ClaudeAgentSDK::RateLimitEvent
    record_rate_limit_event(message)
  when ClaudeAgentSDK::ToolProgressMessage
    record_tool_progress_event(message)
  end
end

#on_user_prompt(prompt) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/claude_agent_sdk/instrumentation/otel.rb', line 58

def on_user_prompt(prompt)
  text = prompt.to_s
  return if text.empty?

  if @root_span.nil?
    # Pre-init: buffered for the trace start_trace is about to open.
    # First prompt wins as the trace input.
    @first_user_input ||= text
  elsif @first_user_input.nil?
    # Open trace with no input yet (init arrived before the prompt).
    @first_user_input = text
    @root_span.set_attribute('input.value', truncate(text))
  else
    # The open trace already has its input, so this prompt belongs to
    # the NEXT trace: either the current turn was interrupted (/clear —
    # a superseding init follows with no ResultMessage) or the user
    # queued the next turn mid-stream (its init follows the current
    # ResultMessage). A first-prompt-forever latch here dropped these
    # prompts from the new trace entirely.
    @pending_prompt ||= text
  end
end