Skip to content

Commit ff2c970

Browse files
committed
Add tracer for eval frameworks + debugging
## About This change adds lightweight, built‑in tracing primitives so eval frameworks (and debugging tools) can capture spans and events around model interactions. The motivation is to provide a structured, zero‑dependency way to observe chat/respond lifecycles without external instrumentation, while keeping the API simple and opt‑in.
1 parent d3326c7 commit ff2c970

9 files changed

Lines changed: 300 additions & 1 deletion

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ bot.messages.select(&:assistant?).each { |m| puts "[#{m.role}] #{m.content}" }
116116
- 📦 Zero runtime deps (stdlib-only)
117117
- 🧩 Pluggable JSON adapters (JSON, Oj, Yajl, etc)
118118
- ♻️ Optional persistent HTTP pool (net-http-persistent)
119+
- 🧭 Built-in tracing primitives for eval frameworks
119120

120121
#### Chat, Agents
121122
- 🧠 Stateless + stateful chat (completions + responses)
@@ -300,6 +301,21 @@ end
300301
bot.chat(prompt)
301302
```
302303

304+
#### Tracer
305+
306+
The following example demonstrates how to use the built-in tracing primitives
307+
to capture spans and events for a chat interaction. This can be useful for
308+
debugging, logging, or feeding into an eval framework:
309+
310+
```ruby
311+
require "llm"
312+
313+
llm = LLM.openai(key: ENV["OPENAI_SECRET"], trace: true)
314+
bot = LLM::Bot.new(llm)
315+
bot.chat "Hello world"
316+
pp bot.tracer.to_h
317+
```
318+
303319
### Schema
304320

305321
All LLM providers except Anthropic and DeepSeek allow a client to describe

lib/llm.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
module LLM
44
require "stringio"
5+
require_relative "llm/tracer"
56
require_relative "llm/json_adapter"
67
require_relative "llm/error"
78
require_relative "llm/contract"

lib/llm/bot.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Bot
4141
# @option params [Array<LLM::Function>, nil] :tools Defaults to nil
4242
def initialize(provider, params = {})
4343
@provider = provider
44+
@tracer = provider.tracer
4445
@params = {model: provider.default_model, schema: nil}.compact.merge!(params)
4546
@messages = LLM::Buffer.new(provider)
4647
end
@@ -58,13 +59,20 @@ def initialize(provider, params = {})
5859
# response = bot.chat("Hello, what is your name?")
5960
# puts response.choices[0].content
6061
def chat(prompt, params = {})
62+
span = start_span("llm.chat", provider: @provider.class.name, model: @params[:model])
6163
prompt, params, messages = fetch(prompt, params)
6264
params = params.merge(messages: [*@messages.to_a, *messages])
6365
params = @params.merge(params)
66+
event("llm.chat.input", {
67+
prompt_size: prompt.to_s.bytesize,
68+
message_count: params[:messages]&.length,
69+
stream: !!params[:stream]
70+
})
6471
res = @provider.complete(prompt, params)
6572
@messages.concat [LLM::Message.new(params[:role] || :user, prompt)]
6673
@messages.concat messages
6774
@messages.concat [res.choices[-1]]
75+
end_span(span)
6876
res
6977
end
7078

@@ -82,14 +90,24 @@ def chat(prompt, params = {})
8290
# res = bot.respond("What is the capital of France?")
8391
# puts res.output_text
8492
def respond(prompt, params = {})
93+
span = start_span("llm.respond", {
94+
provider: @provider.class.name,
95+
model: @params[:model]
96+
})
8597
prompt, params, messages = fetch(prompt, params)
8698
res_id = @messages.find(&:assistant?)&.response&.response_id
8799
params = params.merge(previous_response_id: res_id, input: messages).compact
88100
params = @params.merge(params)
101+
event("llm.respond.input", {
102+
prompt_size: prompt.to_s.bytesize,
103+
message_count: messages.length,
104+
stream: !!params[:stream]
105+
})
89106
res = @provider.responses.create(prompt, params)
90107
@messages.concat [LLM::Message.new(params[:role] || :user, prompt)]
91108
@messages.concat messages
92109
@messages.concat [res.choices[-1]]
110+
end_span(span)
93111
res
94112
end
95113

@@ -101,6 +119,13 @@ def inspect
101119
"@messages=#{@messages.inspect}>"
102120
end
103121

122+
##
123+
# Returns the tracer for this bot
124+
# @return [LLM::Tracer::Tracer, LLM::Tracer::Null]
125+
def tracer
126+
@tracer
127+
end
128+
104129
##
105130
# Returns an array of functions that can be called
106131
# @return [Array<LLM::Function>]
@@ -173,5 +198,17 @@ def fetch(prompt, params)
173198
params.merge!(role: prompt.role)
174199
[prompt.content, params, messages]
175200
end
201+
202+
def start_span(...)
203+
@tracer.start_span(...)
204+
end
205+
206+
def end_span(...)
207+
@tracer.end_span(...)
208+
end
209+
210+
def event(...)
211+
@tracer.event(...)
212+
end
176213
end
177214
end

lib/llm/provider.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,23 @@ def self.clients = @@clients
3030
# @param [Boolean] persistent
3131
# Whether to use a persistent connection.
3232
# Requires the net-http-persistent gem.
33-
def initialize(key:, host:, port: 443, timeout: 60, ssl: true, persistent: false)
33+
def initialize(key:, host:, port: 443, timeout: 60, ssl: true, persistent: false, trace: false)
3434
@key = key
3535
@host = host
3636
@port = port
3737
@timeout = timeout
3838
@ssl = ssl
3939
@client = persistent ? persistent_client : transient_client
40+
@tracer = trace ? LLM::Tracer::Tracer.new : LLM::Tracer::Null.new
4041
@base_uri = URI("#{ssl ? "https" : "http"}://#{host}:#{port}/")
4142
end
4243

44+
##
45+
# @return [LLM::Tracer::Tracer, LLM::Tracer::Null]
46+
def tracer
47+
@tracer
48+
end
49+
4350
##
4451
# Returns an inspection of the provider object
4552
# @return [String]

lib/llm/tracer.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
##
4+
# The {LLM::Tracer LLM::Tracer} provides a tracer for debugging,
5+
# logging, or feeding into an eval framework.
6+
module LLM::Tracer
7+
require_relative "tracer/span"
8+
require_relative "tracer/event"
9+
require_relative "tracer/tracer"
10+
require_relative "tracer/null"
11+
end

lib/llm/tracer/event.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
require "time"
4+
5+
module LLM::Tracer
6+
class Event
7+
##
8+
# A point-in-time event.
9+
# @see LLM::Tracer::Tracer
10+
##
11+
# @return [String]
12+
attr_reader :name, :time, :attrs, :span_id
13+
14+
##
15+
# @param [String, Symbol] name
16+
# @param [Hash] attrs
17+
# @param [String, nil] span_id
18+
def initialize(name, attrs = {}, span_id: nil)
19+
@name = name.to_s
20+
@time = Time.now.utc
21+
@attrs = attrs.dup
22+
@span_id = span_id
23+
end
24+
25+
##
26+
# @return [Hash]
27+
def to_h
28+
{
29+
name: @name,
30+
time: @time.iso8601(6),
31+
span_id: @span_id,
32+
attrs: @attrs.dup
33+
}
34+
end
35+
end
36+
end

lib/llm/tracer/null.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
module LLM::Tracer
4+
class Null
5+
##
6+
# A no-op tracer that discards spans and events.
7+
# @see LLM::Tracer::Tracer
8+
##
9+
# @return [nil]
10+
def start_span(*) = nil
11+
##
12+
# @return [nil]
13+
def end_span(*) = nil
14+
##
15+
# @return [nil]
16+
def event(*) = nil
17+
##
18+
# @return [nil]
19+
def error(*) = nil
20+
21+
##
22+
# @yield [span] Yields nil
23+
# @return [void]
24+
def with_span(*)
25+
yield(nil)
26+
end
27+
end
28+
end

lib/llm/tracer/span.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# frozen_string_literal: true
2+
3+
require "securerandom"
4+
require "time"
5+
6+
module LLM::Tracer
7+
##
8+
# Minimal tracing primitives for eval frameworks.
9+
#
10+
# @example Basic usage
11+
# tracer = LLM::Tracer::Tracer.new
12+
# # ... perform requests
13+
# pp tracer.to_h
14+
##
15+
# A timed span with attributes.
16+
# @see LLM::Tracer::Tracer
17+
class Span
18+
##
19+
# @return [String]
20+
attr_reader :id, :name, :parent_id, :started_at, :ended_at, :attrs
21+
22+
##
23+
# @param [String, Symbol] name
24+
# @param [Hash] attrs
25+
# @param [String, nil] parent_id
26+
def initialize(name, attrs = {}, parent_id: nil)
27+
@id = SecureRandom.hex(8)
28+
@name = name.to_s
29+
@parent_id = parent_id
30+
@attrs = attrs.dup
31+
@started_at = Time.now.utc
32+
@start_mono = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33+
@ended_at = nil
34+
end
35+
36+
##
37+
# @param [Hash] attrs
38+
# @return [LLM::Tracer::Span]
39+
def finish(attrs = {})
40+
return self if @ended_at
41+
@ended_at = Time.now.utc
42+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_mono) * 1000.0).round(3)
43+
@attrs.merge!(attrs) if attrs && !attrs.empty?
44+
@attrs[:duration_ms] ||= duration_ms
45+
self
46+
end
47+
48+
##
49+
# @return [Hash]
50+
def to_h
51+
{
52+
id: @id,
53+
name: @name,
54+
parent_id: @parent_id,
55+
started_at: @started_at.iso8601(6),
56+
ended_at: @ended_at&.iso8601(6),
57+
attrs: @attrs.dup
58+
}
59+
end
60+
end
61+
end

lib/llm/tracer/tracer.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# frozen_string_literal: true
2+
3+
module LLM::Tracer
4+
##
5+
# A simple in-memory tracer for prototyping.
6+
#
7+
# @example
8+
# tracer = LLM::Tracer::Tracer.new
9+
# # ... perform requests
10+
# puts tracer.to_h
11+
class Tracer
12+
##
13+
# @return [Array<LLM::Tracer::Span>]
14+
attr_reader :spans, :events
15+
16+
##
17+
# @return [LLM::Tracer::Tracer]
18+
def initialize
19+
@spans = []
20+
@events = []
21+
end
22+
23+
##
24+
# Start a span and set it as current in this thread.
25+
# @param [String, Symbol] name
26+
# @param [Hash] attrs
27+
# @return [LLM::Tracer::Span]
28+
def start_span(name, attrs = {})
29+
span = Span.new(name, attrs, parent_id: current_span&.id)
30+
@spans << span
31+
stack << span
32+
span
33+
end
34+
35+
##
36+
# End a span.
37+
# @param [LLM::Tracer::Span, nil] span
38+
# @param [Hash] attrs
39+
# @return [LLM::Tracer::Span, nil]
40+
def end_span(span = nil, attrs = {})
41+
span ||= current_span
42+
return unless span
43+
span.finish(attrs)
44+
stack.pop if stack.last == span
45+
span
46+
end
47+
48+
##
49+
# Record a point-in-time event.
50+
# @param [String, Symbol] name
51+
# @param [Hash] attrs
52+
# @return [LLM::Tracer::Event]
53+
def event(name, attrs = {})
54+
@events << Event.new(name, attrs, span_id: current_span&.id)
55+
end
56+
57+
##
58+
# Record an error event.
59+
# @param [Exception] err
60+
# @param [Hash] attrs
61+
# @return [LLM::Tracer::Event]
62+
def error(err, attrs = {})
63+
event("error", {
64+
error: {class: err.class.name, message: err.message}
65+
}.merge(attrs))
66+
end
67+
68+
##
69+
# Start a span, yield it, then end the span.
70+
# @param [String, Symbol] name
71+
# @param [Hash] attrs
72+
# @yield [span] The new span
73+
# @return [void]
74+
def with_span(name, attrs = {})
75+
span = start_span(name, attrs)
76+
yield(span)
77+
ensure
78+
end_span(span)
79+
end
80+
81+
##
82+
# Serialize spans and events.
83+
# @return [Hash]
84+
def to_h
85+
{spans: @spans.map(&:to_h), events: @events.map(&:to_h)}
86+
end
87+
88+
private
89+
90+
def stack
91+
Thread.current[stack_key] ||= []
92+
end
93+
94+
def current_span
95+
stack.last
96+
end
97+
98+
def stack_key
99+
@stack_key ||= :"llm.tracer.stack.#{object_id}"
100+
end
101+
end
102+
end

0 commit comments

Comments
 (0)