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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +101 -1
- data/lib/tracekit/config.rb +6 -2
- data/lib/tracekit/evaluator.rb +604 -0
- data/lib/tracekit/llm/anthropic_instrumentation.rb +218 -0
- data/lib/tracekit/llm/common.rb +118 -0
- data/lib/tracekit/llm/openai_instrumentation.rb +201 -0
- data/lib/tracekit/sdk.rb +29 -0
- data/lib/tracekit/snapshots/client.rb +119 -46
- data/lib/tracekit/snapshots/models.rb +6 -0
- data/lib/tracekit/version.rb +1 -1
- data/lib/tracekit.rb +10 -0
- metadata +10 -6
|
@@ -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
|