tracekit 0.2.2 → 0.2.4

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.
@@ -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