tracekit 0.2.2 → 0.2.3

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: 7a2a8b53b2c100727c3161bd201a6e310135d603c7ac7ab1e8370417a4ff2342
4
- data.tar.gz: 710eb5980307b7a4e82a698a86ff1dea131af93a24b0785f9d88c07a1cb4288a
3
+ metadata.gz: 5e67785497c76c4ff7cbeed1a4cd229c5f70fb15a83c4ed1189cb109ad7963dc
4
+ data.tar.gz: 00a2956e0e8ec619accbc8f033dfe1ef5f09ac8be0d2484d345afbd056ff7bda
5
5
  SHA512:
6
- metadata.gz: 7548453f27b8b0781cda582feb75c25479e5abb5d2cf670c969055402dd6cea6d15582df725038112e35f277045d13baa6b82645295aa8b8a348349b4a0a0d17
7
- data.tar.gz: 9e103dc9c20d0a00182bc403df79cb7fe8043bc58adf68a63a76996ed0952183a5251b94f6c35fdc47957e71e1934ab4365d630a1dd2268d1c85afec71668727
6
+ metadata.gz: 35bfcf1a1ab26d68b05ca50fff003d8a53f30b4ce42afe69394b57fbe80dcd1b3595568b80545ec45d8bead93e6f1e02ef4f3ec2ebfabd952b37dad587798e60
7
+ data.tar.gz: 22b9312c9b1af79507f9b240ec98cd4d7c6855484d86703928f74a54a4b1f2558662b78dc6e18ef4ca27dcb72ab5e5d71c36b89a40f1333161fb8973b888c3b7
data/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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.2.3] - 2026-03-21
9
+
10
+ ### Added
11
+ - LLM auto-instrumentation for OpenAI and Anthropic APIs via Module#prepend
12
+ - Streaming support for both OpenAI (SSE chunks) and Anthropic (SSE events) chat completions
13
+ - Automatic capture of gen_ai.* semantic convention attributes (model, provider, tokens, cost, latency, finish_reason)
14
+ - Content capture option for request/response messages (TRACEKIT_LLM_CAPTURE_CONTENT env var)
15
+ - Tool call detection and instrumentation for function calling
16
+ - PII scrubbing for captured LLM content
17
+ - Provider auto-detection via LoadError handling
18
+ - StreamWrapper for OpenAI and AnthropicStreamWrapper for Anthropic streaming responses
19
+ - Anthropic cache token tracking (cache_creation_input_tokens, cache_read_input_tokens)
20
+
21
+ ### Changed
22
+ - SDK init auto-detects and patches OpenAI::Client#chat and Anthropic::Client::Messages#create when gems are present
23
+
8
24
  ## [0.1.0] - 2024-02-04
9
25
 
10
26
  ### Added
@@ -136,3 +152,4 @@ This is the first production-ready release of the TraceKit Ruby SDK. It provides
136
152
  ---
137
153
 
138
154
  [0.1.0]: https://github.com/Tracekit-Dev/ruby-sdk/releases/tag/v0.1.0
155
+ [0.2.3]: https://github.com/Tracekit-Dev/ruby-sdk/releases/tag/v0.2.3
data/README.md CHANGED
@@ -24,6 +24,7 @@ TraceKit Ruby SDK provides production-ready distributed tracing, metrics, and co
24
24
  - **Code Monitoring**: Live production debugging with non-breaking snapshots
25
25
  - **Security Scanning**: Automatic detection of sensitive data (PII, credentials)
26
26
  - **Local UI Auto-Detection**: Automatically sends traces to local TraceKit UI
27
+ - **LLM Auto-Instrumentation**: Zero-config tracing of OpenAI and Anthropic API calls via Module#prepend
27
28
  - **Rails Auto-Configuration**: Zero-configuration setup via Railtie
28
29
  - **Rack Middleware**: Automatic request instrumentation for any Rack application
29
30
  - **Thread-Safe Metrics**: Concurrent metric collection with automatic buffering
@@ -377,6 +378,100 @@ sdk.capture_snapshot("process-data", { batch_size: 100 })
377
378
  - The SDK automatically retries after the cooldown period
378
379
  - Thread-safe via `Mutex` — safe for multi-threaded Ruby applications (Puma, Sidekiq)
379
380
 
381
+ ## LLM Instrumentation
382
+
383
+ TraceKit automatically instruments OpenAI and Anthropic API calls when the gems are present. No manual setup required — the SDK patches clients at init via `Module#prepend`.
384
+
385
+ ### Supported Gems
386
+
387
+ - **[ruby-openai](https://github.com/alexrudall/ruby-openai)** (~> 7.0) — `OpenAI::Client#chat`
388
+ - **[anthropic](https://github.com/alexrudall/anthropic)** (~> 0.3) — `Anthropic::Client#messages`
389
+
390
+ ### Usage
391
+
392
+ ```ruby
393
+ # Just use the gems normally — TraceKit instruments automatically
394
+
395
+ # OpenAI
396
+ client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
397
+ response = client.chat(parameters: {
398
+ model: "gpt-4o-mini",
399
+ messages: [{ role: "user", content: "Hello!" }],
400
+ max_tokens: 100
401
+ })
402
+
403
+ # Anthropic
404
+ client = Anthropic::Client.new(access_token: ENV["ANTHROPIC_API_KEY"])
405
+ response = client.messages(parameters: {
406
+ model: "claude-sonnet-4-20250514",
407
+ max_tokens: 100,
408
+ messages: [{ role: "user", content: "Hello!" }]
409
+ })
410
+ ```
411
+
412
+ ### Streaming
413
+
414
+ Both streaming and non-streaming calls are instrumented:
415
+
416
+ ```ruby
417
+ # OpenAI streaming
418
+ client.chat(parameters: {
419
+ model: "gpt-4o-mini",
420
+ messages: [{ role: "user", content: "Tell me a story" }],
421
+ stream: proc { |chunk, _bytesize|
422
+ print chunk.dig("choices", 0, "delta", "content")
423
+ }
424
+ })
425
+
426
+ # Anthropic streaming
427
+ client.messages(parameters: {
428
+ model: "claude-sonnet-4-20250514",
429
+ max_tokens: 200,
430
+ messages: [{ role: "user", content: "Tell me a story" }],
431
+ stream: proc { |event|
432
+ if event["type"] == "content_block_delta"
433
+ print event.dig("delta", "text")
434
+ end
435
+ }
436
+ })
437
+ ```
438
+
439
+ ### Captured Attributes
440
+
441
+ Each LLM call creates a span with [GenAI semantic convention](https://opentelemetry.io/docs/specs/semconv/gen-ai/) attributes:
442
+
443
+ | Attribute | Description |
444
+ |-----------|-------------|
445
+ | `gen_ai.system` | `openai` or `anthropic` |
446
+ | `gen_ai.request.model` | Model name (e.g., `gpt-4o-mini`) |
447
+ | `gen_ai.request.max_tokens` | Max tokens requested |
448
+ | `gen_ai.response.model` | Model used in response |
449
+ | `gen_ai.response.id` | Response ID |
450
+ | `gen_ai.response.finish_reason` | `stop`, `end_turn`, etc. |
451
+ | `gen_ai.usage.input_tokens` | Prompt tokens used |
452
+ | `gen_ai.usage.output_tokens` | Completion tokens used |
453
+
454
+ ### Content Capture
455
+
456
+ Input/output content capture is **disabled by default** for privacy. Enable it with:
457
+
458
+ ```bash
459
+ TRACEKIT_LLM_CAPTURE_CONTENT=true
460
+ ```
461
+
462
+ ### Configuration
463
+
464
+ LLM instrumentation is enabled by default when OpenAI or Anthropic gems are detected. To disable:
465
+
466
+ ```ruby
467
+ Tracekit.configure do |config|
468
+ config.llm = { enabled: false } # Disable all LLM instrumentation
469
+ config.llm = { openai: false } # Disable OpenAI only
470
+ config.llm = { anthropic: false } # Disable Anthropic only
471
+ config.llm = { capture_content: true } # Enable content capture via config
472
+ end
473
+ ```
474
+
380
475
  ## Distributed Tracing
381
476
 
382
477
  The SDK automatically:
@@ -452,6 +547,10 @@ ruby-sdk/
452
547
  │ │ ├── sdk.rb # Main SDK class
453
548
  │ │ ├── railtie.rb # Rails auto-configuration
454
549
  │ │ ├── middleware.rb # Rack middleware
550
+ │ │ ├── llm/ # LLM auto-instrumentation
551
+ │ │ │ ├── common.rb # Shared helpers, PII scrubbing
552
+ │ │ │ ├── openai_instrumentation.rb # OpenAI Module#prepend
553
+ │ │ │ └── anthropic_instrumentation.rb # Anthropic Module#prepend
455
554
  │ │ ├── metrics/ # Metrics implementation
456
555
  │ │ ├── security/ # Security scanning
457
556
  │ │ └── snapshots/ # Code monitoring
@@ -523,6 +622,7 @@ bundle exec rails server -p 5002
523
622
  - `GET /api/call-go` - Call Go test service
524
623
  - `GET /api/call-node` - Call Node test service
525
624
  - `GET /api/call-all` - Call all test services
625
+ - `GET /api/llm` - LLM instrumentation test (OpenAI + Anthropic, streaming + non-streaming)
526
626
 
527
627
  See [ruby-test/README.md](ruby-test/README.md) for details.
528
628
 
@@ -597,4 +697,4 @@ Built on [OpenTelemetry](https://opentelemetry.io/) - the industry standard for
597
697
  ---
598
698
 
599
699
  **Repository**: git@github.com:Tracekit-Dev/ruby-sdk.git
600
- **Version**: v0.2.0
700
+ **Version**: v0.2.3
@@ -6,7 +6,8 @@ module Tracekit
6
6
  class Config
7
7
  attr_reader :api_key, :service_name, :endpoint, :use_ssl, :environment,
8
8
  :service_version, :enable_code_monitoring,
9
- :code_monitoring_poll_interval, :local_ui_port, :sampling_rate
9
+ :code_monitoring_poll_interval, :local_ui_port, :sampling_rate,
10
+ :llm
10
11
 
11
12
  def initialize(builder)
12
13
  @api_key = builder.api_key
@@ -19,6 +20,7 @@ module Tracekit
19
20
  @code_monitoring_poll_interval = builder.code_monitoring_poll_interval || 30
20
21
  @local_ui_port = builder.local_ui_port || 9999
21
22
  @sampling_rate = builder.sampling_rate || 1.0
23
+ @llm = (builder.llm || {}).freeze
22
24
 
23
25
  validate!
24
26
  freeze # Make configuration immutable
@@ -35,7 +37,8 @@ module Tracekit
35
37
  class Builder
36
38
  attr_accessor :api_key, :service_name, :endpoint, :use_ssl, :environment,
37
39
  :service_version, :enable_code_monitoring,
38
- :code_monitoring_poll_interval, :local_ui_port, :sampling_rate
40
+ :code_monitoring_poll_interval, :local_ui_port, :sampling_rate,
41
+ :llm
39
42
 
40
43
  def initialize
41
44
  # Set defaults in builder
@@ -47,6 +50,7 @@ module Tracekit
47
50
  @code_monitoring_poll_interval = 30
48
51
  @local_ui_port = 9999
49
52
  @sampling_rate = 1.0
53
+ @llm = { enabled: true, openai: true, anthropic: true, capture_content: false }
50
54
  end
51
55
  end
52
56
 
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "common"
4
+
5
+ module Tracekit
6
+ module LLM
7
+ module AnthropicInstrumentation
8
+ module_function
9
+
10
+ def install(tracer)
11
+ begin
12
+ require "anthropic"
13
+ rescue LoadError
14
+ # anthropic gem not available, check if it's already defined (e.g. in tests)
15
+ return false unless defined?(::Anthropic::Client)
16
+ end
17
+
18
+ return false unless defined?(::Anthropic::Client)
19
+
20
+ instrumentation_mod = Module.new do
21
+ define_method(:messages) do |**params|
22
+ # When called with no parameters, return the Messages::Client (for batches etc.)
23
+ return super(**params) unless params[:parameters]
24
+
25
+ parameters = params[:parameters]
26
+ model = parameters[:model] || parameters["model"] || "unknown"
27
+ stream_proc = parameters[:stream] || parameters["stream"]
28
+ is_streaming = stream_proc.is_a?(Proc)
29
+ capture = Common.capture_content?
30
+
31
+ span = tracer.start_span("chat #{model}", kind: :client)
32
+
33
+ begin
34
+ Common.set_request_attributes(span,
35
+ provider: "anthropic",
36
+ model: model,
37
+ max_tokens: parameters[:max_tokens] || parameters["max_tokens"],
38
+ temperature: parameters[:temperature] || parameters["temperature"],
39
+ top_p: parameters[:top_p] || parameters["top_p"]
40
+ )
41
+
42
+ # Capture input content
43
+ if capture
44
+ system_prompt = parameters[:system] || parameters["system"]
45
+ Common.capture_system_instructions(span, system_prompt) if system_prompt
46
+ messages = parameters[:messages] || parameters["messages"]
47
+ Common.capture_input_messages(span, messages) if messages
48
+ end
49
+
50
+ if is_streaming
51
+ # Wrap the user's stream proc to accumulate span data
52
+ accumulator = AnthropicStreamAccumulator.new(span, capture)
53
+ wrapper_proc = proc do |event|
54
+ accumulator.process_event(event)
55
+ stream_proc.call(event)
56
+ end
57
+
58
+ # Replace stream proc with our wrapper
59
+ wrapped_params = parameters.merge(stream: wrapper_proc)
60
+ result = super(parameters: wrapped_params)
61
+ accumulator.finalize
62
+ result
63
+ else
64
+ result = super(**params)
65
+ handle_anthropic_response(span, result, capture)
66
+ result
67
+ end
68
+ rescue => e
69
+ Common.set_error_attributes(span, e)
70
+ span.finish
71
+ raise
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def handle_anthropic_response(span, result, capture)
78
+ # Anthropic response: { id, type, role, content, model, stop_reason, usage }
79
+ content_blocks = result["content"] || result[:content] || []
80
+ usage = result["usage"] || result[:usage] || {}
81
+
82
+ Common.set_response_attributes(span,
83
+ model: result["model"] || result[:model],
84
+ id: result["id"] || result[:id],
85
+ finish_reasons: [(result["stop_reason"] || result[:stop_reason])].compact,
86
+ input_tokens: usage["input_tokens"] || usage[:input_tokens],
87
+ output_tokens: usage["output_tokens"] || usage[:output_tokens]
88
+ )
89
+
90
+ # Cache tokens (Anthropic-specific)
91
+ cache_creation = usage["cache_creation_input_tokens"] || usage[:cache_creation_input_tokens]
92
+ cache_read = usage["cache_read_input_tokens"] || usage[:cache_read_input_tokens]
93
+ span.set_attribute("gen_ai.usage.cache_creation.input_tokens", cache_creation) if cache_creation
94
+ span.set_attribute("gen_ai.usage.cache_read.input_tokens", cache_read) if cache_read
95
+
96
+ # Tool calls from content blocks
97
+ content_blocks.each do |block|
98
+ block_type = block["type"] || block[:type]
99
+ if block_type == "tool_use"
100
+ input_val = block["input"] || block[:input]
101
+ args = input_val.is_a?(String) ? input_val : JSON.generate(input_val)
102
+ Common.record_tool_call(span,
103
+ name: block["name"] || block[:name] || "unknown",
104
+ id: block["id"] || block[:id],
105
+ arguments: args
106
+ )
107
+ end
108
+ end
109
+
110
+ # Output content capture
111
+ if capture && content_blocks.any?
112
+ Common.capture_output_messages(span, content_blocks)
113
+ end
114
+ rescue => _e
115
+ # Never break user code
116
+ ensure
117
+ span.finish
118
+ end
119
+ end
120
+
121
+ ::Anthropic::Client.prepend(instrumentation_mod)
122
+ true
123
+ end
124
+
125
+ # Accumulates streaming event data for span attributes
126
+ class AnthropicStreamAccumulator
127
+ def initialize(span, capture_content)
128
+ @span = span
129
+ @capture = capture_content
130
+ @model = nil
131
+ @id = nil
132
+ @stop_reason = nil
133
+ @input_tokens = nil
134
+ @output_tokens = nil
135
+ @cache_creation_tokens = nil
136
+ @cache_read_tokens = nil
137
+ @output_chunks = []
138
+ @tool_calls = {}
139
+ @current_block_index = 0
140
+ end
141
+
142
+ def process_event(event)
143
+ event_type = event["type"] || event[:type]
144
+
145
+ case event_type
146
+ when "message_start"
147
+ message = event["message"] || event[:message] || {}
148
+ @model = message["model"] || message[:model]
149
+ @id = message["id"] || message[:id]
150
+ usage = message["usage"] || message[:usage] || {}
151
+ @input_tokens = usage["input_tokens"] || usage[:input_tokens]
152
+ @cache_creation_tokens = usage["cache_creation_input_tokens"] || usage[:cache_creation_input_tokens]
153
+ @cache_read_tokens = usage["cache_read_input_tokens"] || usage[:cache_read_input_tokens]
154
+
155
+ when "content_block_start"
156
+ @current_block_index = event["index"] || event[:index] || @current_block_index
157
+ cb = event["content_block"] || event[:content_block] || {}
158
+ if (cb["type"] || cb[:type]) == "tool_use"
159
+ @tool_calls[@current_block_index] = {
160
+ name: cb["name"] || cb[:name] || "unknown",
161
+ id: cb["id"] || cb[:id],
162
+ arguments: ""
163
+ }
164
+ end
165
+
166
+ when "content_block_delta"
167
+ delta = event["delta"] || event[:delta] || {}
168
+ delta_type = delta["type"] || delta[:type]
169
+ if delta_type == "text_delta" && @capture
170
+ text = delta["text"] || delta[:text]
171
+ @output_chunks << text if text
172
+ elsif delta_type == "input_json_delta"
173
+ partial = delta["partial_json"] || delta[:partial_json]
174
+ idx = event["index"] || event[:index] || @current_block_index
175
+ if partial && @tool_calls[idx]
176
+ @tool_calls[idx][:arguments] += partial
177
+ end
178
+ end
179
+
180
+ when "message_delta"
181
+ delta = event["delta"] || event[:delta] || {}
182
+ @stop_reason = delta["stop_reason"] || delta[:stop_reason] if delta["stop_reason"] || delta[:stop_reason]
183
+ usage = event["usage"] || event[:usage] || {}
184
+ @output_tokens = usage["output_tokens"] || usage[:output_tokens] if usage["output_tokens"] || usage[:output_tokens]
185
+ end
186
+ rescue => _e
187
+ # Never fail on event processing
188
+ end
189
+
190
+ def finalize
191
+ Common.set_response_attributes(@span,
192
+ model: @model,
193
+ id: @id,
194
+ finish_reasons: @stop_reason ? [@stop_reason] : nil,
195
+ input_tokens: @input_tokens,
196
+ output_tokens: @output_tokens
197
+ )
198
+
199
+ @span.set_attribute("gen_ai.usage.cache_creation.input_tokens", @cache_creation_tokens) if @cache_creation_tokens
200
+ @span.set_attribute("gen_ai.usage.cache_read.input_tokens", @cache_read_tokens) if @cache_read_tokens
201
+
202
+ @tool_calls.each_value do |tc|
203
+ Common.record_tool_call(@span, **tc)
204
+ end
205
+
206
+ if @capture && @output_chunks.any?
207
+ full_content = @output_chunks.join
208
+ Common.capture_output_messages(@span, [{ "type" => "text", "text" => full_content }])
209
+ end
210
+ rescue => _e
211
+ # Never break user code
212
+ ensure
213
+ @span.finish
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tracekit
6
+ module LLM
7
+ module Common
8
+ # Pattern-based PII regexes (all replaced with plain [REDACTED])
9
+ SENSITIVE_KEY_PATTERN = /\A(password|passwd|pwd|secret|token|key|credential|api_key|apikey)\z/i
10
+ EMAIL_PATTERN = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/
11
+ SSN_PATTERN = /\b\d{3}-\d{2}-\d{4}\b/
12
+ CREDIT_CARD_PATTERN = /\b\d{4}[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b/
13
+ AWS_KEY_PATTERN = /\bAKIA[0-9A-Z]{16}\b/
14
+ BEARER_PATTERN = /Bearer\s+[A-Za-z0-9\-._~+\/]+=*/
15
+ STRIPE_PATTERN = /\bsk_live_[a-zA-Z0-9]+/
16
+ JWT_PATTERN = /\beyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/
17
+ PRIVATE_KEY_PATTERN = /-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----/
18
+
19
+ CONTENT_PATTERNS = [
20
+ EMAIL_PATTERN, SSN_PATTERN, CREDIT_CARD_PATTERN, AWS_KEY_PATTERN,
21
+ BEARER_PATTERN, STRIPE_PATTERN, JWT_PATTERN, PRIVATE_KEY_PATTERN
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ def scrub_pii(content)
27
+ # Try JSON key-based scrubbing first
28
+ begin
29
+ parsed = JSON.parse(content)
30
+ scrubbed = scrub_object(parsed)
31
+ return JSON.generate(scrubbed)
32
+ rescue JSON::ParserError
33
+ # Not JSON, fall through to pattern scrubbing
34
+ end
35
+ scrub_patterns(content)
36
+ end
37
+
38
+ def scrub_patterns(str)
39
+ result = str.dup
40
+ CONTENT_PATTERNS.each { |pat| result.gsub!(pat, "[REDACTED]") }
41
+ result
42
+ end
43
+
44
+ def scrub_object(obj)
45
+ case obj
46
+ when Hash
47
+ obj.each_with_object({}) do |(k, v), h|
48
+ if SENSITIVE_KEY_PATTERN.match?(k.to_s)
49
+ h[k] = "[REDACTED]"
50
+ else
51
+ h[k] = scrub_object(v)
52
+ end
53
+ end
54
+ when Array
55
+ obj.map { |item| scrub_object(item) }
56
+ when String
57
+ scrub_patterns(obj)
58
+ else
59
+ obj
60
+ end
61
+ end
62
+
63
+ def capture_content?
64
+ env_val = ENV["TRACEKIT_LLM_CAPTURE_CONTENT"]
65
+ return env_val.downcase == "true" || env_val == "1" if env_val
66
+ false
67
+ end
68
+
69
+ def set_request_attributes(span, provider:, model:, max_tokens: nil, temperature: nil, top_p: nil)
70
+ span.set_attribute("gen_ai.operation.name", "chat")
71
+ span.set_attribute("gen_ai.system", provider)
72
+ span.set_attribute("gen_ai.request.model", model)
73
+ span.set_attribute("gen_ai.request.max_tokens", max_tokens) if max_tokens
74
+ span.set_attribute("gen_ai.request.temperature", temperature) if temperature
75
+ span.set_attribute("gen_ai.request.top_p", top_p) if top_p
76
+ end
77
+
78
+ def set_response_attributes(span, model: nil, id: nil, finish_reasons: nil, input_tokens: nil, output_tokens: nil)
79
+ span.set_attribute("gen_ai.response.model", model) if model
80
+ span.set_attribute("gen_ai.response.id", id) if id
81
+ span.set_attribute("gen_ai.response.finish_reasons", finish_reasons) if finish_reasons&.any?
82
+ span.set_attribute("gen_ai.usage.input_tokens", input_tokens) if input_tokens
83
+ span.set_attribute("gen_ai.usage.output_tokens", output_tokens) if output_tokens
84
+ end
85
+
86
+ def set_error_attributes(span, error)
87
+ span.set_attribute("error.type", error.class.name)
88
+ span.status = OpenTelemetry::Trace::Status.error(error.message)
89
+ span.record_exception(error)
90
+ end
91
+
92
+ def record_tool_call(span, name:, id: nil, arguments: nil)
93
+ attrs = { "gen_ai.tool.name" => name }
94
+ attrs["gen_ai.tool.call.id"] = id if id
95
+ attrs["gen_ai.tool.call.arguments"] = arguments if arguments
96
+ span.add_event("gen_ai.tool.call", attributes: attrs)
97
+ end
98
+
99
+ def capture_input_messages(span, messages)
100
+ return unless messages
101
+ serialized = JSON.generate(messages)
102
+ span.set_attribute("gen_ai.input.messages", scrub_pii(serialized))
103
+ end
104
+
105
+ def capture_output_messages(span, content)
106
+ return unless content
107
+ serialized = JSON.generate(content)
108
+ span.set_attribute("gen_ai.output.messages", scrub_pii(serialized))
109
+ end
110
+
111
+ def capture_system_instructions(span, system)
112
+ return unless system
113
+ serialized = system.is_a?(String) ? system : JSON.generate(system)
114
+ span.set_attribute("gen_ai.system_instructions", scrub_pii(serialized))
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "common"
4
+
5
+ module Tracekit
6
+ module LLM
7
+ module OpenAIInstrumentation
8
+ module_function
9
+
10
+ def install(tracer)
11
+ # Try to load the OpenAI gem
12
+ begin
13
+ require "openai"
14
+ rescue LoadError
15
+ # openai gem not available, check if it's already defined (e.g. in tests)
16
+ return false unless defined?(::OpenAI::Client)
17
+ end
18
+
19
+ client_class = ::OpenAI::Client
20
+ return false unless client_class
21
+
22
+ # Create the prepend module dynamically with tracer closure
23
+ instrumentation_mod = Module.new do
24
+ define_method(:chat) do |parameters: {}|
25
+ model = parameters[:model] || parameters["model"] || "unknown"
26
+ stream_proc = parameters[:stream] || parameters["stream"]
27
+ is_streaming = stream_proc.is_a?(Proc)
28
+ capture = Common.capture_content?
29
+
30
+ span = tracer.start_span("chat #{model}", kind: :client)
31
+
32
+ begin
33
+ Common.set_request_attributes(span,
34
+ provider: "openai",
35
+ model: model,
36
+ max_tokens: parameters[:max_tokens] || parameters["max_tokens"] || parameters[:max_completion_tokens] || parameters["max_completion_tokens"],
37
+ temperature: parameters[:temperature] || parameters["temperature"],
38
+ top_p: parameters[:top_p] || parameters["top_p"]
39
+ )
40
+
41
+ # Capture input content
42
+ if capture
43
+ messages = parameters[:messages] || parameters["messages"]
44
+ if messages
45
+ system_msgs = messages.select { |m| (m[:role] || m["role"]) == "system" }
46
+ non_system = messages.reject { |m| (m[:role] || m["role"]) == "system" }
47
+ Common.capture_system_instructions(span, system_msgs) if system_msgs.any?
48
+ Common.capture_input_messages(span, non_system)
49
+ end
50
+ end
51
+
52
+ if is_streaming
53
+ # ruby-openai handles streaming via proc callback internally.
54
+ # The chat method returns the final response hash, not an enumerator.
55
+ # We wrap the user's proc to accumulate span data from each chunk.
56
+ accumulator = OpenAIStreamAccumulator.new(span, capture)
57
+ wrapper_proc = proc do |chunk, bytesize|
58
+ accumulator.process_chunk(chunk)
59
+ # Call original proc with same args
60
+ if stream_proc.arity == 2 || stream_proc.arity < 0
61
+ stream_proc.call(chunk, bytesize)
62
+ else
63
+ stream_proc.call(chunk)
64
+ end
65
+ end
66
+
67
+ # Inject stream_options.include_usage for token counting
68
+ params = parameters.dup
69
+ so = params[:stream_options] || params["stream_options"] || {}
70
+ unless so[:include_usage] || so["include_usage"]
71
+ params[:stream_options] = so.merge(include_usage: true)
72
+ end
73
+ params[:stream] = wrapper_proc
74
+
75
+ result = super(parameters: params)
76
+ accumulator.finalize
77
+ result
78
+ else
79
+ result = super(parameters: parameters)
80
+
81
+ # Non-streaming response handling
82
+ handle_response(span, result, capture)
83
+ result
84
+ end
85
+ rescue => e
86
+ Common.set_error_attributes(span, e)
87
+ span.finish
88
+ raise
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def handle_response(span, result, capture)
95
+ choices = result.dig("choices") || []
96
+ Common.set_response_attributes(span,
97
+ model: result["model"],
98
+ id: result["id"],
99
+ finish_reasons: choices.map { |c| c["finish_reason"] }.compact,
100
+ input_tokens: result.dig("usage", "prompt_tokens"),
101
+ output_tokens: result.dig("usage", "completion_tokens")
102
+ )
103
+
104
+ # Tool calls
105
+ choices.each do |choice|
106
+ (choice.dig("message", "tool_calls") || []).each do |tc|
107
+ Common.record_tool_call(span,
108
+ name: tc.dig("function", "name") || "unknown",
109
+ id: tc["id"],
110
+ arguments: tc.dig("function", "arguments")
111
+ )
112
+ end
113
+ end
114
+
115
+ # Output content capture
116
+ if capture && choices.any?
117
+ output_msgs = choices.map { |c| c["message"] }.compact
118
+ Common.capture_output_messages(span, output_msgs) if output_msgs.any?
119
+ end
120
+ rescue => _e
121
+ # Never break user code
122
+ ensure
123
+ span.finish
124
+ end
125
+ end
126
+
127
+ client_class.prepend(instrumentation_mod)
128
+ true
129
+ end
130
+
131
+ # Accumulates streaming chunk data for span attributes via proc interception
132
+ class OpenAIStreamAccumulator
133
+ def initialize(span, capture_content)
134
+ @span = span
135
+ @capture = capture_content
136
+ @model = nil
137
+ @id = nil
138
+ @finish_reason = nil
139
+ @input_tokens = nil
140
+ @output_tokens = nil
141
+ @output_chunks = []
142
+ @tool_calls = {}
143
+ end
144
+
145
+ def process_chunk(chunk)
146
+ @model ||= chunk.dig("model")
147
+ @id ||= chunk.dig("id")
148
+
149
+ if (usage = chunk["usage"])
150
+ @input_tokens = usage["prompt_tokens"] if usage["prompt_tokens"]
151
+ @output_tokens = usage["completion_tokens"] if usage["completion_tokens"]
152
+ end
153
+
154
+ (chunk["choices"] || []).each do |choice|
155
+ @finish_reason = choice["finish_reason"] if choice["finish_reason"]
156
+ delta = choice["delta"] || {}
157
+ @output_chunks << delta["content"] if @capture && delta["content"]
158
+
159
+ (delta["tool_calls"] || []).each do |tc|
160
+ idx = tc["index"] || 0
161
+ if @tool_calls[idx]
162
+ @tool_calls[idx][:arguments] = (@tool_calls[idx][:arguments] || "") + (tc.dig("function", "arguments") || "")
163
+ else
164
+ @tool_calls[idx] = {
165
+ name: tc.dig("function", "name") || "unknown",
166
+ id: tc["id"],
167
+ arguments: tc.dig("function", "arguments") || ""
168
+ }
169
+ end
170
+ end
171
+ end
172
+ rescue => _e
173
+ # Never fail on chunk processing
174
+ end
175
+
176
+ def finalize
177
+ Common.set_response_attributes(@span,
178
+ model: @model,
179
+ id: @id,
180
+ finish_reasons: @finish_reason ? [@finish_reason] : nil,
181
+ input_tokens: @input_tokens,
182
+ output_tokens: @output_tokens
183
+ )
184
+
185
+ @tool_calls.each_value do |tc|
186
+ Common.record_tool_call(@span, **tc)
187
+ end
188
+
189
+ if @capture && @output_chunks.any?
190
+ full_content = @output_chunks.join
191
+ Common.capture_output_messages(@span, [{ "role" => "assistant", "content" => full_content }])
192
+ end
193
+ rescue => _e
194
+ # Never break user code
195
+ ensure
196
+ @span.finish
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
data/lib/tracekit/sdk.rb CHANGED
@@ -90,6 +90,9 @@ module Tracekit
90
90
  # Initialize OpenTelemetry tracer
91
91
  setup_tracing(traces_endpoint)
92
92
 
93
+ # Initialize LLM instrumentation (auto-detect providers)
94
+ setup_llm_instrumentation if defined?(Tracekit::LLM)
95
+
93
96
  # Initialize metrics registry
94
97
  @metrics_registry = Metrics::Registry.new(metrics_endpoint, config.api_key, config.service_name)
95
98
 
@@ -152,6 +155,32 @@ module Tracekit
152
155
 
153
156
  private
154
157
 
158
+ def setup_llm_instrumentation
159
+ llm_config = @config.llm || {}
160
+ return unless llm_config.fetch(:enabled, true)
161
+
162
+ tracer = OpenTelemetry.tracer_provider.tracer("tracekit-llm", Tracekit::VERSION)
163
+
164
+ # Set capture_content env var from config if not already set
165
+ if llm_config[:capture_content] && !ENV.key?("TRACEKIT_LLM_CAPTURE_CONTENT")
166
+ ENV["TRACEKIT_LLM_CAPTURE_CONTENT"] = "true"
167
+ end
168
+
169
+ if llm_config.fetch(:openai, true)
170
+ if Tracekit::LLM::OpenAIInstrumentation.install(tracer)
171
+ puts "TraceKit: OpenAI LLM instrumentation enabled"
172
+ end
173
+ end
174
+
175
+ if llm_config.fetch(:anthropic, true)
176
+ if Tracekit::LLM::AnthropicInstrumentation.install(tracer)
177
+ puts "TraceKit: Anthropic LLM instrumentation enabled"
178
+ end
179
+ end
180
+ rescue => e
181
+ puts "TraceKit: LLM instrumentation setup failed: #{e.message}"
182
+ end
183
+
155
184
  def setup_tracing(traces_endpoint)
156
185
  OpenTelemetry::SDK.configure do |c|
157
186
  c.service_name = @config.service_name
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tracekit
4
- VERSION = "0.2.2"
4
+ VERSION = "0.2.3"
5
5
  end
data/lib/tracekit.rb CHANGED
@@ -23,6 +23,15 @@ require_relative "tracekit/local_ui_detector"
23
23
  require_relative "tracekit/snapshots/models"
24
24
  require_relative "tracekit/snapshots/client"
25
25
 
26
+ # LLM instrumentation
27
+ begin
28
+ require_relative "tracekit/llm/common"
29
+ require_relative "tracekit/llm/openai_instrumentation"
30
+ require_relative "tracekit/llm/anthropic_instrumentation"
31
+ rescue LoadError
32
+ # LLM instrumentation not available
33
+ end
34
+
26
35
  # Core SDK
27
36
  require_relative "tracekit/sdk"
28
37
  require_relative "tracekit/middleware"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tracekit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - TraceKit
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-08 00:00:00.000000000 Z
11
+ date: 2026-03-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opentelemetry-sdk
@@ -150,6 +150,9 @@ files:
150
150
  - lib/tracekit.rb
151
151
  - lib/tracekit/config.rb
152
152
  - lib/tracekit/endpoint_resolver.rb
153
+ - lib/tracekit/llm/anthropic_instrumentation.rb
154
+ - lib/tracekit/llm/common.rb
155
+ - lib/tracekit/llm/openai_instrumentation.rb
153
156
  - lib/tracekit/local_ui/detector.rb
154
157
  - lib/tracekit/local_ui_detector.rb
155
158
  - lib/tracekit/metrics/counter.rb
@@ -173,7 +176,7 @@ metadata:
173
176
  homepage_uri: https://github.com/Tracekit-Dev/ruby-sdk
174
177
  source_code_uri: https://github.com/Tracekit-Dev/ruby-sdk
175
178
  changelog_uri: https://github.com/Tracekit-Dev/ruby-sdk/blob/main/CHANGELOG.md
176
- post_install_message:
179
+ post_install_message:
177
180
  rdoc_options: []
178
181
  require_paths:
179
182
  - lib
@@ -188,8 +191,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
191
  - !ruby/object:Gem::Version
189
192
  version: '0'
190
193
  requirements: []
191
- rubygems_version: 3.5.3
192
- signing_key:
194
+ rubygems_version: 3.0.3.1
195
+ signing_key:
193
196
  specification_version: 4
194
197
  summary: TraceKit Ruby SDK - OpenTelemetry-based APM for Ruby applications
195
198
  test_files: []