claude-agent-sdk 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dea2034cc231692b028b6bbd97c42a9ee0b940cb90ac88a17d52fcea14c116f6
4
- data.tar.gz: 54820d9a71cd55ce3e6d0e370b6f4aaa536054caf2b7369452cfc2788db4a610
3
+ metadata.gz: f58f68ab3fa3b156f329c2f1757840166e449cb74f0a8513522e7fddaa177daa
4
+ data.tar.gz: 4d2c107fce9aa21978c983b264799a0a795e2cc58bcac615a0a9786f982221d9
5
5
  SHA512:
6
- metadata.gz: 3a82f9b4e5dfe224110676b4e37c803d9e60054bea6dc463e91133cb1591b3075a19bda8471cd3b1034548b8f3737f3726f4e634ef4d509499fd389db936451e
7
- data.tar.gz: 351038143982dc2b621e138f90d9fff65f8ed0ce5eab25aa7eaa214563336a2070375c9a92be9cfb3ed79d419e12a5c149dde2e3a46cc96c83992fe94eefef82
6
+ metadata.gz: 656e3dc5eacd58edb5da35a0055771674e09fcff5a7754a79ab5fae4346ba34e4000e61d31d25743cf244cdb6241907b84696bd16ea63411af7dee70d9bc46af
7
+ data.tar.gz: 5da72d45e25794e720b011f19de10de076d1927e50863b13b1aa4ccde801dd6b697073e82025c281963752cb8408c1a0267cbe2ea7a9a233f3bee4c07e636b9e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.13.0] - 2026-04-03
9
+
10
+ ### Added
11
+
12
+ #### Observer Interface
13
+ - `Observer` module with `on_user_prompt`, `on_message`, `on_error`, `on_close` — all with no-op defaults
14
+ - `observers` option on `ClaudeAgentOptions` (default `[]`) — register observers for both `query()` and `Client`
15
+ - `resolve_observers` supports callable factories (lambdas) for thread-safe global defaults in Rails/Puma/Sidekiq
16
+ - `notify_observers` rescues per-observer errors so observers never crash the main pipeline
17
+
18
+ #### OpenTelemetry Instrumentation
19
+ - `ClaudeAgentSDK::Instrumentation::OTelObserver` — emits spans using `gen_ai.*` and OpenInference semantic conventions
20
+ - Span tree: `claude_agent.session` (root) → `claude_agent.generation` + `claude_agent.tool.*` (children)
21
+ - `langfuse.observation.type` set on all spans (`agent`/`generation`/`tool`) to enable Langfuse trace flow diagram
22
+ - `input.value`/`output.value` (OpenInference) for Langfuse Preview Input/Output fields
23
+ - `llm.token_count.*`, `llm.cost.total`, `llm.model_name` for full Langfuse cost/usage tracking
24
+ - `openinference.span.kind` (`AGENT`/`LLM`/`TOOL`) on all spans
25
+ - Events: `api_retry`, `rate_limit`, `tool_progress` recorded on root span
26
+ - Lazy `require 'opentelemetry'` — zero cost for users who don't use it
27
+
28
+ #### Examples
29
+ - `otel_langfuse_example.rb` — Langfuse-via-OTel setup with OTLP exporter
30
+ - `test_langfuse_otel.rb` — multi-tool integration test (Bash tool calls)
31
+
32
+ ### Changed
33
+ - README: added Observability section with Langfuse setup guide, span attribute reference, custom observer example, Rails initializer patterns
34
+ - README: split sandbox into CLI settings vs sandbox-runtime rows in comparison table
35
+ - README: updated recommended gem version to `~> 0.13.0`
36
+ - CLAUDE.md: documented observer/instrumentation architecture
37
+
8
38
  ## [0.12.0] - 2026-04-01
9
39
 
10
40
  Full Claude Code parity release — cross-referenced against the Claude Code source (`coreSchemas.ts`) and TypeScript SDK to bring every message type, hook event, and sandbox setting into the Ruby SDK.
data/README.md CHANGED
@@ -27,19 +27,19 @@ All three SDKs share the same underlying mechanism: they spawn the `claude` CLI
27
27
  | Permission callbacks | ✅ | ✅ | ✅ |
28
28
  | Structured output | ✅ | ✅ | ✅ |
29
29
  | All 24 message types | ✅ | partial | ✅ |
30
- | Full sandbox settings | ✅ | partial | ✅ |
30
+ | [Sandbox](https://github.com/anthropic-experimental/sandbox-runtime) settings | ✅ | partial | ✅ |
31
31
  | Bare mode (`--bare`) | ✅ | ✅ | ✅ |
32
32
  | File checkpointing & rewind | ✅ | ✅ | ✅ |
33
33
  | Session browsing & mutations | ✅ | ✅ | ✅ |
34
34
  | Programmatic subagents | ✅ | ✅ | ✅ |
35
35
  | Bundled CLI binary | ✅ | ✅ | — (install `claude` separately) |
36
+ | Observability (OTel / Langfuse) | via [Arize](https://github.com/Arize-ai/openinference) | — | ✅ (built-in) |
36
37
  | Custom transport (pluggable I/O) | — | — | ✅ |
37
38
  | Rails integration | — | — | ✅ |
38
- | Global config defaults | — | — | ✅ |
39
39
 
40
- **Where Ruby goes further:** Custom transport support lets you swap the subprocess for any I/O layer (e.g., connect to a remote Claude Code instance over SSH or a container). Rails integration provides a `configure` block for initializers and plays well with ActionCable for real-time streaming. The Ruby SDK also has full typed coverage for all 24 CLI message types and all 27 hook events — some of which the Python SDK hasn't typed yet (falling through to generic `SystemMessage`).
40
+ **Where Ruby goes further:** Built-in OpenTelemetry observer with Langfuse flow diagram support — no third-party instrumentation library needed. Custom transport support lets you swap the subprocess for any I/O layer (e.g., connect to a remote Claude Code instance over SSH or a container). Rails integration provides a `configure` block for initializers with thread-safe observer factories, and plays well with ActionCable for real-time streaming. Full typed coverage for all 24 CLI message types and all 27 hook events — some of which the Python SDK hasn't typed yet.
41
41
 
42
- **What's missing:** The Ruby gem does not bundle the `claude` CLI binary. You need to install Claude Code separately (`npm install -g @anthropic-ai/claude-code`).
42
+ **What's missing:** The Ruby gem does not bundle the `claude` CLI binary (`npm install -g @anthropic-ai/claude-code`).
43
43
 
44
44
  <details>
45
45
  <summary><strong>Implementation differences from the official SDKs</strong></summary>
@@ -89,6 +89,7 @@ All three SDKs spawn `claude` CLI as a subprocess with stream-JSON over stdin/st
89
89
  - [File Checkpointing & Rewind](#file-checkpointing--rewind)
90
90
  - [Session Browsing](#session-browsing)
91
91
  - [Session Mutations](#session-mutations)
92
+ - [Observability (OpenTelemetry / Langfuse)](#observability-opentelemetry--langfuse)
92
93
  - [Rails Integration](#rails-integration)
93
94
  - [Types](#types)
94
95
  - [Error Handling](#error-handling)
@@ -105,7 +106,7 @@ Add this line to your application's Gemfile:
105
106
  gem 'claude-agent-sdk', github: 'ya-luotao/claude-agent-sdk-ruby'
106
107
 
107
108
  # Or use a stable version from RubyGems
108
- gem 'claude-agent-sdk', '~> 0.11.0'
109
+ gem 'claude-agent-sdk', '~> 0.13.0'
109
110
  ```
110
111
 
111
112
  And then execute:
@@ -822,7 +823,7 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
822
823
 
823
824
  ## Sandbox Settings
824
825
 
825
- Run commands in an isolated sandbox for additional security:
826
+ Configure [sandbox-runtime](https://github.com/anthropic-experimental/sandbox-runtime) restrictions (network policy, filesystem access) via the CLI's `--sandbox` flag. The CLI handles OS-level process isolation using `srt`.
826
827
 
827
828
  ```ruby
828
829
  sandbox = ClaudeAgentSDK::SandboxSettings.new(
@@ -1006,6 +1007,137 @@ ClaudeAgentSDK.tag_session(
1006
1007
 
1007
1008
  > **Note:** Session mutations use append-only JSONL writes with `O_WRONLY | O_APPEND` (no `O_CREAT`) for TOCTOU safety. They are safe to call while the session is open in a CLI process.
1008
1009
 
1010
+ ## Observability (OpenTelemetry / Langfuse)
1011
+
1012
+ The SDK includes a built-in **observer interface** and an **OpenTelemetry observer** for tracing agent sessions. Traces are emitted using standard `gen_ai.*` semantic conventions, compatible with Langfuse, Jaeger, Datadog, and any OTel backend.
1013
+
1014
+ ### How It Works
1015
+
1016
+ Register observers via `ClaudeAgentOptions`. The SDK calls `on_message` for every parsed message in both `query()` and `Client`, and `on_close` when the session ends. Observer errors are silently rescued so they never crash your application.
1017
+
1018
+ ```
1019
+ claude_agent.session (root span — one per query/session)
1020
+ ├── claude_agent.generation (per AssistantMessage, with model + token usage)
1021
+ ├── claude_agent.tool.Bash (per tool call, open on ToolUseBlock, close on ToolResultBlock)
1022
+ ├── claude_agent.tool.Read
1023
+ ├── claude_agent.generation
1024
+ └── ...
1025
+ ```
1026
+
1027
+ ### Setup with Langfuse
1028
+
1029
+ **1. Install the OTel gems** (not bundled with the SDK — you choose your exporter):
1030
+
1031
+ ```bash
1032
+ gem install opentelemetry-sdk opentelemetry-exporter-otlp
1033
+ ```
1034
+
1035
+ Or add to your Gemfile:
1036
+
1037
+ ```ruby
1038
+ gem 'opentelemetry-sdk', '~> 1.4'
1039
+ gem 'opentelemetry-exporter-otlp', '~> 0.28'
1040
+ ```
1041
+
1042
+ **2. Configure the OTel SDK** to export to your Langfuse instance:
1043
+
1044
+ ```ruby
1045
+ require 'base64'
1046
+ require 'opentelemetry/sdk'
1047
+ require 'opentelemetry/exporter/otlp'
1048
+
1049
+ # Langfuse authenticates via Basic Auth over OTLP
1050
+ public_key = ENV['LANGFUSE_PUBLIC_KEY']
1051
+ secret_key = ENV['LANGFUSE_SECRET_KEY']
1052
+ auth = Base64.strict_encode64("#{public_key}:#{secret_key}")
1053
+
1054
+ # Self-hosted or cloud: https://cloud.langfuse.com (EU) / https://us.cloud.langfuse.com (US)
1055
+ langfuse_host = ENV.fetch('LANGFUSE_HOST', 'https://cloud.langfuse.com')
1056
+
1057
+ OpenTelemetry::SDK.configure do |c|
1058
+ c.service_name = 'my-agent-app'
1059
+ c.add_span_processor(
1060
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
1061
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
1062
+ endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
1063
+ headers: {
1064
+ 'Authorization' => "Basic #{auth}",
1065
+ 'x-langfuse-ingestion-version' => '4'
1066
+ }
1067
+ )
1068
+ )
1069
+ )
1070
+ end
1071
+ ```
1072
+
1073
+ **3. Create the observer and run a query:**
1074
+
1075
+ ```ruby
1076
+ require 'claude_agent_sdk'
1077
+ require 'claude_agent_sdk/instrumentation'
1078
+
1079
+ observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new(
1080
+ 'langfuse.session.id' => 'my-session-123', # optional: group traces by session
1081
+ 'user.id' => 'user-42' # optional: tag with user ID
1082
+ )
1083
+
1084
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
1085
+ observers: [observer],
1086
+ allowed_tools: ['Bash', 'Read'],
1087
+ permission_mode: 'bypassPermissions'
1088
+ )
1089
+
1090
+ ClaudeAgentSDK.query(prompt: "List files in /tmp", options: options) do |msg|
1091
+ if msg.is_a?(ClaudeAgentSDK::AssistantMessage)
1092
+ msg.content.each do |block|
1093
+ puts block.text if block.is_a?(ClaudeAgentSDK::TextBlock)
1094
+ end
1095
+ end
1096
+ end
1097
+
1098
+ # For long-running apps, flush before exit:
1099
+ # OpenTelemetry.tracer_provider.shutdown
1100
+ ```
1101
+
1102
+ ### Span Attributes
1103
+
1104
+ The OTel observer sets attributes using both `gen_ai.*` (OTel GenAI) and OpenInference conventions for maximum backend compatibility:
1105
+
1106
+ | Span | Type | Key Attributes |
1107
+ |------|------|----------------|
1108
+ | `claude_agent.session` | `agent` | `gen_ai.system`, `gen_ai.request.model`, `session.id`, `input.value`, `output.value`, `gen_ai.usage.cost`, `llm.cost.total` |
1109
+ | `claude_agent.generation` | `generation` | `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `output.value` |
1110
+ | `claude_agent.tool.*` | `tool` | `tool.name`, `input.value`, `output.value` |
1111
+
1112
+ Events (`api_retry`, `rate_limit`, `tool_progress`) are recorded on the root span.
1113
+
1114
+ The `langfuse.observation.type` attribute is set on each span (`agent`/`generation`/`tool`) to enable Langfuse's **trace flow diagram** (DAG graph visualization).
1115
+
1116
+ ### Custom Observers
1117
+
1118
+ Implement the `Observer` module to build your own instrumentation:
1119
+
1120
+ ```ruby
1121
+ class MyObserver
1122
+ include ClaudeAgentSDK::Observer
1123
+
1124
+ def on_message(message)
1125
+ case message
1126
+ when ClaudeAgentSDK::ResultMessage
1127
+ puts "Cost: $#{message.total_cost_usd}, Tokens: #{message.usage}"
1128
+ end
1129
+ end
1130
+
1131
+ def on_close
1132
+ puts "Session ended"
1133
+ end
1134
+ end
1135
+
1136
+ options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [MyObserver.new])
1137
+ ```
1138
+
1139
+ For a complete multi-tool example, see [examples/otel_langfuse_example.rb](examples/otel_langfuse_example.rb).
1140
+
1009
1141
  ## Rails Integration
1010
1142
 
1011
1143
  The SDK integrates well with Rails applications. Here are common patterns:
@@ -1153,6 +1285,55 @@ options = ClaudeAgentSDK::ClaudeAgentOptions.new(
1153
1285
  )
1154
1286
  ```
1155
1287
 
1288
+ ### Observability in Rails
1289
+
1290
+ Add OpenTelemetry tracing to your Rails app with a single initializer:
1291
+
1292
+ ```ruby
1293
+ # config/initializers/opentelemetry.rb
1294
+ require 'base64'
1295
+ require 'opentelemetry/sdk'
1296
+ require 'opentelemetry/exporter/otlp'
1297
+
1298
+ if ENV['LANGFUSE_PUBLIC_KEY'].present?
1299
+ auth = Base64.strict_encode64("#{ENV['LANGFUSE_PUBLIC_KEY']}:#{ENV['LANGFUSE_SECRET_KEY']}")
1300
+ langfuse_host = ENV.fetch('LANGFUSE_HOST', 'https://cloud.langfuse.com')
1301
+
1302
+ OpenTelemetry::SDK.configure do |c|
1303
+ c.service_name = Rails.application.class.module_parent_name.underscore
1304
+ c.add_span_processor(
1305
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
1306
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
1307
+ endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
1308
+ headers: {
1309
+ 'Authorization' => "Basic #{auth}",
1310
+ 'x-langfuse-ingestion-version' => '4'
1311
+ }
1312
+ )
1313
+ )
1314
+ )
1315
+ end
1316
+ end
1317
+ ```
1318
+
1319
+ ```ruby
1320
+ # config/initializers/claude_agent_sdk.rb
1321
+ require 'claude_agent_sdk/instrumentation'
1322
+
1323
+ ClaudeAgentSDK.configure do |config|
1324
+ config.default_options = {
1325
+ permission_mode: 'bypassPermissions',
1326
+ observers: ENV['LANGFUSE_PUBLIC_KEY'].present? ? [
1327
+ # Use a lambda so each query gets a fresh observer instance (thread-safe).
1328
+ # A single shared instance would have its span state clobbered by concurrent requests.
1329
+ -> { ClaudeAgentSDK::Instrumentation::OTelObserver.new }
1330
+ ] : []
1331
+ }
1332
+ end
1333
+ ```
1334
+
1335
+ Then every `ClaudeAgentSDK.query` and `Client` session automatically gets traced — no per-call wiring needed. The lambda factory ensures each request gets its own observer with isolated span state, safe for concurrent Puma/Sidekiq workers.
1336
+
1156
1337
  For complete examples, see:
1157
1338
  - [examples/rails_actioncable_example.rb](examples/rails_actioncable_example.rb)
1158
1339
  - [examples/rails_background_job_example.rb](examples/rails_background_job_example.rb)
@@ -1499,6 +1680,12 @@ See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-co
1499
1680
  | [examples/fallback_model_example.rb](examples/fallback_model_example.rb) | Fallback model configuration |
1500
1681
  | [examples/extended_thinking_example.rb](examples/extended_thinking_example.rb) | Extended thinking (API parity) |
1501
1682
 
1683
+ ### Observability
1684
+
1685
+ | Example | Description |
1686
+ |---------|-------------|
1687
+ | [examples/otel_langfuse_example.rb](examples/otel_langfuse_example.rb) | OpenTelemetry tracing with Langfuse backend |
1688
+
1502
1689
  ### Rails Integration
1503
1690
 
1504
1691
  | Example | Description |
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative '../observer'
5
+
6
+ module ClaudeAgentSDK
7
+ module Instrumentation
8
+ # OpenTelemetry observer that emits spans for Claude Agent SDK messages.
9
+ #
10
+ # Uses standard gen_ai.* semantic conventions recognized by Langfuse, Datadog,
11
+ # Jaeger, and other OTel-compatible backends.
12
+ #
13
+ # Requires the `opentelemetry-api` gem at runtime. Users must configure
14
+ # `opentelemetry-sdk` and an exporter (e.g., `opentelemetry-exporter-otlp`)
15
+ # themselves before creating this observer.
16
+ #
17
+ # @example With Langfuse via OTLP
18
+ # require 'opentelemetry/sdk'
19
+ # require 'opentelemetry/exporter/otlp'
20
+ # require 'claude_agent_sdk/instrumentation'
21
+ #
22
+ # OpenTelemetry::SDK.configure do |c|
23
+ # c.service_name = 'my-app'
24
+ # c.add_span_processor(
25
+ # OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
26
+ # OpenTelemetry::Exporter::OTLP::Exporter.new(
27
+ # endpoint: 'https://cloud.langfuse.com/api/public/otel/v1/traces',
28
+ # headers: { 'Authorization' => "Basic #{auth}" }
29
+ # )
30
+ # )
31
+ # )
32
+ # end
33
+ #
34
+ # observer = ClaudeAgentSDK::Instrumentation::OTelObserver.new
35
+ # options = ClaudeAgentSDK::ClaudeAgentOptions.new(observers: [observer])
36
+ # ClaudeAgentSDK.query(prompt: "Hello", options: options) { |msg| ... }
37
+ class OTelObserver
38
+ include ClaudeAgentSDK::Observer
39
+
40
+ TRACER_NAME = 'claude_agent_sdk'
41
+ MAX_ATTRIBUTE_LENGTH = 4096
42
+
43
+ def initialize(tracer_name: TRACER_NAME, **default_attributes)
44
+ require 'opentelemetry'
45
+ @tracer = OpenTelemetry.tracer_provider.tracer(
46
+ tracer_name,
47
+ defined?(ClaudeAgentSDK::VERSION) ? ClaudeAgentSDK::VERSION : '0.0.0'
48
+ )
49
+ @default_attributes = default_attributes
50
+ @root_span = nil
51
+ @root_context = nil
52
+ @tool_spans = {} # tool_use_id => span
53
+ @first_user_input = nil # capture first user prompt for trace input
54
+ @last_assistant_text = nil # capture last assistant text for trace output
55
+ end
56
+
57
+ def on_user_prompt(prompt)
58
+ return if @first_user_input # only capture the first prompt
59
+
60
+ @first_user_input = prompt.to_s
61
+ # If root span already exists, set immediately; otherwise start_trace will apply it
62
+ @root_span&.set_attribute('input.value', truncate(@first_user_input)) unless @first_user_input.empty?
63
+ end
64
+
65
+ def on_message(message)
66
+ case message
67
+ when ClaudeAgentSDK::InitMessage
68
+ start_trace(message)
69
+ when ClaudeAgentSDK::AssistantMessage
70
+ handle_assistant(message)
71
+ when ClaudeAgentSDK::UserMessage
72
+ handle_user(message)
73
+ when ClaudeAgentSDK::ResultMessage
74
+ end_trace(message)
75
+ when ClaudeAgentSDK::APIRetryMessage
76
+ record_retry_event(message)
77
+ when ClaudeAgentSDK::RateLimitEvent
78
+ record_rate_limit_event(message)
79
+ when ClaudeAgentSDK::ToolProgressMessage
80
+ record_tool_progress_event(message)
81
+ end
82
+ end
83
+
84
+ def on_error(error)
85
+ return unless @root_span
86
+
87
+ @root_span.record_exception(error)
88
+ @root_span.status = OpenTelemetry::Trace::Status.error(error.message)
89
+ end
90
+
91
+ def on_close
92
+ @tool_spans.each_value(&:finish)
93
+ @tool_spans.clear
94
+ @root_span&.finish
95
+ @root_span = nil
96
+ @root_context = nil
97
+ end
98
+
99
+ private
100
+
101
+ def start_trace(message)
102
+ attrs = {
103
+ # gen_ai semantic conventions (recognized by Langfuse, Datadog, etc.)
104
+ 'gen_ai.system' => 'anthropic',
105
+ 'gen_ai.request.model' => message.model,
106
+ # OpenInference conventions (recognized by Langfuse, Arize)
107
+ 'openinference.span.kind' => 'AGENT',
108
+ 'llm.model_name' => message.model,
109
+ 'input.mime_type' => 'text/plain',
110
+ 'output.mime_type' => 'text/plain',
111
+ # Langfuse: 'agent' type triggers the trace flow diagram (DAG graph)
112
+ 'langfuse.observation.type' => 'agent',
113
+ # Session tracking
114
+ 'session.id' => message.session_id
115
+ }.merge(@default_attributes)
116
+
117
+ attrs['claude_code.version'] = message.claude_code_version if message.respond_to?(:claude_code_version) && message.claude_code_version
118
+ attrs['claude_code.cwd'] = message.cwd if message.respond_to?(:cwd) && message.cwd
119
+ attrs['claude_code.permission_mode'] = message.permission_mode if message.respond_to?(:permission_mode) && message.permission_mode
120
+
121
+ @root_span = @tracer.start_span('claude_agent.session', attributes: compact_attrs(attrs))
122
+ @root_context = OpenTelemetry::Trace.context_with_span(@root_span)
123
+
124
+ # Apply buffered prompt if on_user_prompt was called before InitMessage arrived
125
+ @root_span.set_attribute('input.value', truncate(@first_user_input)) if @first_user_input && !@first_user_input.empty?
126
+ end
127
+
128
+ def handle_assistant(message)
129
+ return unless @root_context
130
+
131
+ # Extract text content for gen_ai.completion
132
+ text_parts = []
133
+ tool_use_blocks = []
134
+
135
+ (message.content || []).each do |block|
136
+ case block
137
+ when ClaudeAgentSDK::TextBlock
138
+ text_parts << block.text
139
+ when ClaudeAgentSDK::ToolUseBlock
140
+ tool_use_blocks << block
141
+ end
142
+ end
143
+
144
+ # Track last assistant text for trace output
145
+ combined_text = text_parts.join("\n")
146
+ @last_assistant_text = combined_text unless combined_text.empty?
147
+
148
+ # Create generation span
149
+ usage = message.usage || {}
150
+ input_tokens = usage[:input_tokens] || usage['input_tokens']
151
+ output_tokens = usage[:output_tokens] || usage['output_tokens']
152
+ attrs = {
153
+ 'openinference.span.kind' => 'LLM',
154
+ 'langfuse.observation.type' => 'generation',
155
+ 'gen_ai.response.model' => message.model,
156
+ 'llm.model_name' => message.model,
157
+ 'gen_ai.usage.input_tokens' => input_tokens,
158
+ 'gen_ai.usage.output_tokens' => output_tokens,
159
+ 'gen_ai.completion' => truncate(combined_text),
160
+ # OpenInference: Langfuse maps output.value to the Preview Output field
161
+ 'output.value' => truncate(combined_text)
162
+ }
163
+
164
+ OpenTelemetry::Context.with_current(@root_context) do
165
+ span = @tracer.start_span('claude_agent.generation', attributes: compact_attrs(attrs))
166
+ span.finish
167
+ end
168
+
169
+ # Start tool spans for any ToolUseBlocks
170
+ tool_use_blocks.each { |block| start_tool_span(block) }
171
+ end
172
+
173
+ def handle_user(message)
174
+ return unless @root_context
175
+
176
+ content = message.content
177
+ return unless content.is_a?(Array)
178
+
179
+ content.each do |block|
180
+ case block
181
+ when ClaudeAgentSDK::ToolResultBlock
182
+ end_tool_span(block)
183
+ end
184
+ end
185
+ end
186
+
187
+ def end_trace(message)
188
+ return unless @root_span
189
+
190
+ usage = message.usage || {}
191
+ input_tokens = usage[:input_tokens] || usage['input_tokens']
192
+ output_tokens = usage[:output_tokens] || usage['output_tokens']
193
+ total_tokens = (input_tokens || 0) + (output_tokens || 0) if input_tokens || output_tokens
194
+
195
+ # Set trace output (last assistant response — shown in Langfuse UI)
196
+ # ResultMessage.result has the final text; fall back to last tracked assistant text
197
+ trace_output = message.result || @last_assistant_text
198
+
199
+ attrs = {
200
+ # gen_ai conventions
201
+ 'gen_ai.usage.cost' => message.total_cost_usd,
202
+ 'gen_ai.usage.input_tokens' => input_tokens,
203
+ 'gen_ai.usage.output_tokens' => output_tokens,
204
+ # OpenInference conventions (Langfuse maps these to usage/cost)
205
+ 'llm.token_count.prompt' => input_tokens,
206
+ 'llm.token_count.completion' => output_tokens,
207
+ 'llm.token_count.total' => total_tokens,
208
+ 'llm.cost.total' => message.total_cost_usd,
209
+ # Trace output (Langfuse shows this in the trace detail view)
210
+ 'output.value' => truncate(trace_output),
211
+ # Session metadata
212
+ 'claude_agent.duration_ms' => message.duration_ms,
213
+ 'claude_agent.duration_api_ms' => message.duration_api_ms,
214
+ 'claude_agent.num_turns' => message.num_turns,
215
+ 'claude_agent.stop_reason' => message.stop_reason
216
+ }
217
+
218
+ @root_span.status = OpenTelemetry::Trace::Status.error(message.stop_reason || 'error') if message.is_error
219
+
220
+ @root_span.add_attributes(compact_attrs(attrs))
221
+ @root_span.finish
222
+ @root_span = nil
223
+ @root_context = nil
224
+ end
225
+
226
+ def start_tool_span(block)
227
+ return unless @root_context
228
+
229
+ input_json = truncate(safe_json(block.input))
230
+ attrs = {
231
+ 'openinference.span.kind' => 'TOOL',
232
+ 'langfuse.observation.type' => 'tool',
233
+ 'tool.name' => block.name,
234
+ # OpenInference: Langfuse maps these to the Preview Input/Output fields
235
+ 'input.value' => input_json,
236
+ 'input.mime_type' => 'application/json'
237
+ }
238
+
239
+ OpenTelemetry::Context.with_current(@root_context) do
240
+ span = @tracer.start_span("claude_agent.tool.#{block.name}", attributes: compact_attrs(attrs))
241
+ @tool_spans[block.id] = span
242
+ end
243
+ end
244
+
245
+ def end_tool_span(block)
246
+ span = @tool_spans.delete(block.tool_use_id)
247
+ return unless span
248
+
249
+ # OpenInference: Langfuse maps output.value to the Preview Output field
250
+ span.set_attribute('output.value', truncate(block.content.to_s))
251
+ span.status = OpenTelemetry::Trace::Status.error('tool error') if block.is_error
252
+ span.finish
253
+ end
254
+
255
+ def record_retry_event(message)
256
+ return unless @root_span
257
+
258
+ @root_span.add_event('api_retry', attributes: compact_attrs(
259
+ 'attempt' => message.attempt,
260
+ 'max_retries' => message.max_retries,
261
+ 'retry_delay_ms' => message.retry_delay_ms,
262
+ 'error_status' => message.error_status,
263
+ 'error' => message.error
264
+ ))
265
+ end
266
+
267
+ def record_rate_limit_event(message)
268
+ return unless @root_span
269
+
270
+ info = message.rate_limit_info
271
+ attrs = {}
272
+ if info
273
+ attrs['status'] = info.status if info.respond_to?(:status)
274
+ attrs['rate_limit_type'] = info.rate_limit_type if info.respond_to?(:rate_limit_type)
275
+ end
276
+ @root_span.add_event('rate_limit', attributes: compact_attrs(attrs))
277
+ end
278
+
279
+ def record_tool_progress_event(message)
280
+ return unless @root_span
281
+
282
+ @root_span.add_event('tool_progress', attributes: compact_attrs(
283
+ 'tool_name' => message.tool_name,
284
+ 'tool_use_id' => message.tool_use_id,
285
+ 'elapsed_time_seconds' => message.elapsed_time_seconds
286
+ ))
287
+ end
288
+
289
+ # Remove nil values from attributes hash (OTel rejects nil attribute values)
290
+ def compact_attrs(attrs)
291
+ attrs.compact
292
+ end
293
+
294
+ def truncate(str)
295
+ return nil unless str
296
+
297
+ str.length > MAX_ATTRIBUTE_LENGTH ? str[0...MAX_ATTRIBUTE_LENGTH] : str
298
+ end
299
+
300
+ def safe_json(obj)
301
+ JSON.generate(obj)
302
+ rescue StandardError
303
+ obj.to_s
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Instrumentation adapters for ClaudeAgentSDK.
4
+ # Each adapter lazy-requires its external gem, so loading this file has zero cost
5
+ # unless you instantiate a specific observer.
6
+ require_relative 'instrumentation/otel'
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClaudeAgentSDK
4
+ # Base module for message observers.
5
+ #
6
+ # Include this module and override the methods you care about.
7
+ # All methods have no-op defaults so observers only need to implement
8
+ # the callbacks relevant to their use case.
9
+ #
10
+ # Observers are registered via ClaudeAgentOptions#observers and are called
11
+ # for every parsed message in both query() and Client#receive_messages.
12
+ # Observer errors are rescued so they never crash the main message pipeline.
13
+ #
14
+ # @example Custom logging observer
15
+ # class LoggingObserver
16
+ # include ClaudeAgentSDK::Observer
17
+ #
18
+ # def on_message(message)
19
+ # puts "[#{message.class.name}] received"
20
+ # end
21
+ # end
22
+ module Observer
23
+ # Called with the user's prompt text (not echoed back by CLI in streaming mode).
24
+ # @param prompt [String] The user's prompt string
25
+ def on_user_prompt(prompt); end
26
+
27
+ # Called for every parsed message (typed object from MessageParser).
28
+ # @param message [Object] A typed message (AssistantMessage, ResultMessage, etc.)
29
+ def on_message(message); end
30
+
31
+ # Called when a transport or parse error occurs.
32
+ # @param error [Exception] The error that occurred
33
+ def on_error(error); end
34
+
35
+ # Called when the query or client disconnects. Use this to flush buffers.
36
+ def on_close; end
37
+ end
38
+ end
@@ -1720,7 +1720,7 @@ module ClaudeAgentSDK
1720
1720
  :output_format, :max_budget_usd, :max_thinking_tokens,
1721
1721
  :fallback_model, :plugins, :debug_stderr,
1722
1722
  :betas, :tools, :sandbox, :enable_file_checkpointing, :append_allowed_tools,
1723
- :thinking, :effort, :bare
1723
+ :thinking, :effort, :bare, :observers
1724
1724
 
1725
1725
  # Non-nil defaults for options that need them.
1726
1726
  # Keys absent from here default to nil.
@@ -1728,7 +1728,8 @@ module ClaudeAgentSDK
1728
1728
  allowed_tools: [], disallowed_tools: [], add_dirs: [],
1729
1729
  mcp_servers: {}, env: {}, extra_args: {},
1730
1730
  continue_conversation: false, include_partial_messages: false,
1731
- fork_session: false, enable_file_checkpointing: false
1731
+ fork_session: false, enable_file_checkpointing: false,
1732
+ observers: []
1732
1733
  }.freeze
1733
1734
 
1734
1735
  # Valid option names derived from attr_accessor declarations.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ClaudeAgentSDK
4
- VERSION = '0.12.0'
4
+ VERSION = '0.13.0'
5
5
  end
@@ -4,6 +4,7 @@ require_relative 'claude_agent_sdk/version'
4
4
  require_relative 'claude_agent_sdk/errors'
5
5
  require_relative 'claude_agent_sdk/configuration'
6
6
  require_relative 'claude_agent_sdk/types'
7
+ require_relative 'claude_agent_sdk/observer'
7
8
  require_relative 'claude_agent_sdk/transport'
8
9
  require_relative 'claude_agent_sdk/subprocess_cli_transport'
9
10
  require_relative 'claude_agent_sdk/message_parser'
@@ -17,6 +18,24 @@ require 'securerandom'
17
18
 
18
19
  # Claude Agent SDK for Ruby
19
20
  module ClaudeAgentSDK
21
+ # Resolve observers array: callables (Proc/lambda) are invoked to produce
22
+ # a fresh instance per query/session (thread-safe); plain objects are used as-is.
23
+ # Array() guards against nil (e.g., when observers: nil is passed explicitly).
24
+ def self.resolve_observers(observers)
25
+ Array(observers).map do |obs|
26
+ obs.respond_to?(:call) ? obs.call : obs
27
+ end
28
+ end
29
+
30
+ # Safely call a method on each observer, suppressing any errors.
31
+ def self.notify_observers(observers, method, *args)
32
+ observers.each do |obs|
33
+ obs.send(method, *args)
34
+ rescue StandardError
35
+ nil
36
+ end
37
+ end
38
+
20
39
  # Look up a value in a hash that may use symbol or string keys in camelCase or snake_case.
21
40
  # Returns the first non-nil value found, preserving false as a meaningful value.
22
41
  def self.flexible_fetch(hash, camel_key, snake_key)
@@ -120,6 +139,9 @@ module ClaudeAgentSDK
120
139
  configured_options = options.dup_with(permission_prompt_tool_name: 'stdio')
121
140
  end
122
141
 
142
+ # Resolve callable observers into fresh instances (thread-safe for global defaults)
143
+ resolved_observers = ClaudeAgentSDK.resolve_observers(configured_options.observers)
144
+
123
145
  Async do
124
146
  # Always use streaming mode with control protocol (matches Python SDK).
125
147
  # This sends agents via initialize request instead of CLI args,
@@ -174,6 +196,7 @@ module ClaudeAgentSDK
174
196
 
175
197
  # Send prompt(s) as user messages, then close stdin
176
198
  if prompt.is_a?(String)
199
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_user_prompt, prompt)
177
200
  message = {
178
201
  type: 'user',
179
202
  message: { role: 'user', content: prompt },
@@ -191,9 +214,13 @@ module ClaudeAgentSDK
191
214
  # Read and yield messages from the query handler (filters out control messages)
192
215
  query_handler.receive_messages do |data|
193
216
  message = MessageParser.parse(data)
194
- block.call(message) if message
217
+ if message
218
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_message, message)
219
+ block.call(message)
220
+ end
195
221
  end
196
222
  ensure
223
+ ClaudeAgentSDK.notify_observers(resolved_observers, :on_close)
197
224
  # query_handler.close stops the background read task and closes the transport
198
225
  if query_handler
199
226
  query_handler.close
@@ -308,6 +335,9 @@ module ClaudeAgentSDK
308
335
  @query_handler.start
309
336
  @query_handler.initialize_protocol
310
337
 
338
+ # Resolve callable observers into fresh instances (thread-safe for global defaults)
339
+ @resolved_observers = ClaudeAgentSDK.resolve_observers(@options.observers)
340
+
311
341
  @connected = true
312
342
 
313
343
  # Optionally send initial prompt/messages after connection is ready.
@@ -331,6 +361,7 @@ module ClaudeAgentSDK
331
361
  def query(prompt, session_id: 'default')
332
362
  raise CLIConnectionError, 'Not connected. Call connect() first' unless @connected
333
363
 
364
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_user_prompt, prompt)
334
365
  message = {
335
366
  type: 'user',
336
367
  message: { role: 'user', content: prompt },
@@ -349,7 +380,10 @@ module ClaudeAgentSDK
349
380
 
350
381
  @query_handler.receive_messages do |data|
351
382
  message = MessageParser.parse(data)
352
- block.call(message) if message
383
+ if message
384
+ ClaudeAgentSDK.notify_observers(@resolved_observers, :on_message, message)
385
+ block.call(message)
386
+ end
353
387
  end
354
388
  end
355
389
 
@@ -439,6 +473,7 @@ module ClaudeAgentSDK
439
473
  def disconnect
440
474
  return unless @connected
441
475
 
476
+ ClaudeAgentSDK.notify_observers(@resolved_observers || [], :on_close)
442
477
  @query_handler&.close
443
478
  @query_handler = nil
444
479
  @transport = nil
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-agent-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Community Contributors
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-01 00:00:00.000000000 Z
10
+ date: 2026-04-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: async
@@ -106,7 +106,10 @@ files:
106
106
  - lib/claude_agent_sdk.rb
107
107
  - lib/claude_agent_sdk/configuration.rb
108
108
  - lib/claude_agent_sdk/errors.rb
109
+ - lib/claude_agent_sdk/instrumentation.rb
110
+ - lib/claude_agent_sdk/instrumentation/otel.rb
109
111
  - lib/claude_agent_sdk/message_parser.rb
112
+ - lib/claude_agent_sdk/observer.rb
110
113
  - lib/claude_agent_sdk/query.rb
111
114
  - lib/claude_agent_sdk/sdk_mcp_server.rb
112
115
  - lib/claude_agent_sdk/session_mutations.rb