activeagents-telemetry-ruby_llm 0.2.0 → 0.3.1

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: cf9f1bb148ecabe70a08facf887f60c55dd08f6683370bcb03cb33d1abad0dd1
4
- data.tar.gz: 81d06e3973b08667dbb972fe1a013d1be3785b9d9c2b3804a4dfbe5ae67fbaa5
3
+ metadata.gz: 255ca4c32e9f775c8b2e45ab431ae81ee410fa3002d66dce026ba06d948da4b6
4
+ data.tar.gz: 1afbdf608524f69045341e10cd7a6f722854cc49977e27ff511cafcc8368fa72
5
5
  SHA512:
6
- metadata.gz: 999b5249ec348dc4b5280aeddfb2c905af13b29948a85aed5b81ddfdfd2f920fd5d0607b0fd79313f5fad732de271e0db2d1ab23b7ce04e575bf93f90c967040
7
- data.tar.gz: 4ec2de8a1cb63605724e7497632b7463ee3e317c133d3bc05cc8131aa8ea1e9b379fdb0da172bf509809bb2ac193c2164bc3780b8844ed32c4c7225220475fb7
6
+ metadata.gz: 5b316442b6a5bb9044df92b691d6d4dee824d12522c45f4367d252cf7041aeee2be2060ce3a604f9b0e36b814c04e78ef5280646ad67ae7f893c29f0a1799c2b
7
+ data.tar.gz: 2fe33cb43e2a35659c2112decb662199b461f756e8ef63560e4e7b63151fde0fdcfa1a4f042c6f0419bead7e41787ec43ba51fdbcee58bc4cd1550014a3f56b5
data/README.md CHANGED
@@ -92,3 +92,44 @@ an explicit `flush!`.
92
92
  ```bash
93
93
  bundle exec rake test
94
94
  ```
95
+
96
+ ## Correlating evaluation traces
97
+
98
+ Use a separate identity for judge calls and attach stable evaluation identifiers
99
+ without changing the application's default agent resolver:
100
+
101
+ ```ruby
102
+ trace_ids = []
103
+ ActiveAgents::Telemetry::RubyLLM.with_agent(
104
+ "EvaluationJudge", action: "score",
105
+ attributes: { "eval.run_id" => run_id, "eval.result_id" => result_id },
106
+ on_trace: ->(trace) { trace_ids << trace.trace_id },
107
+ synchronous: true
108
+ ) do
109
+ judge_chat.ask(prompt)
110
+ end
111
+ ```
112
+
113
+ The callback receives each trace the reporter accepted for delivery, so an
114
+ evaluation result can retain the trace ID of that delivery attempt. Acceptance is
115
+ not ingestion: the trace passed the enabled, configured and sampling checks, but
116
+ a delivery that then fails is logged by the reporter rather than announced here,
117
+ and an asynchronous delivery can outlive a process that exits right away. A trace
118
+ dropped by `sample_rate` or by a disabled configuration is never announced.
119
+ `synchronous: true` delivers in the calling thread for this scope only, through
120
+ the same sampling and configuration checks as ordinary delivery; it does not
121
+ mutate the shared async configuration. Normal reporter error logging still
122
+ applies: synchronous delivery does not turn telemetry failures into application
123
+ exceptions. A turn keeps the scope it started under, so a turn left open by a
124
+ pending tool call and closed later by `flush!` still reports with this agent,
125
+ attributes and callback, and a turn that started outside any scope never adopts
126
+ one. Context
127
+ is restored after the block, including when it raises, and nested scopes use
128
+ their own attributes. A callback error is logged by exception class without
129
+ dropping the trace. Attribute redaction and content-capture settings continue to
130
+ apply.
131
+
132
+ Report publication and trace ingestion are separate operations. Persist each run
133
+ and result ID in the evaluation report and attach the same IDs to its response and
134
+ judge trace attributes. The application chooses whether to publish full report
135
+ content; this API does not enable body capture or upload reports automatically.
@@ -3,7 +3,7 @@
3
3
  module ActiveAgents
4
4
  module Telemetry
5
5
  module RubyLLM
6
- VERSION = "0.2.0"
6
+ VERSION = "0.3.1"
7
7
  end
8
8
  end
9
9
  end
@@ -43,7 +43,10 @@ module ActiveAgents
43
43
 
44
44
  DEFAULT_AGENT = { name: "RubyLLM::Chat", action: "chat" }.freeze
45
45
 
46
- State = Struct.new(:depth, :started_at, :tool_spans, :rounds, :tokens, :chat_key)
46
+ # Span token keys and the reader that answers each on a RubyLLM 2.x +Tokens+.
47
+ TOKEN_READERS = { "input" => :input, "output" => :output, "thinking" => :thinking }.freeze
48
+
49
+ State = Struct.new(:depth, :started_at, :tool_spans, :rounds, :tokens, :chat_key, :agent)
47
50
 
48
51
  class << self
49
52
  # Subscribes to RubyLLM's instrumentation.
@@ -91,17 +94,30 @@ module ActiveAgents
91
94
 
92
95
  attr_writer :reporter
93
96
 
94
- # Attributes traces inside the block to a named agent/action.
95
- def with_agent(name, action: "chat")
97
+ # Attributes traces inside the block to a named agent/action. Correlation
98
+ # attributes and the callback apply to this scope only. Short-lived
99
+ # evaluation commands can deliver synchronously without changing the
100
+ # application's shared reporter configuration.
101
+ #
102
+ # A turn keeps the scope it started under, so a turn left open by a
103
+ # pending tool call and closed later by `flush!` still reports as this
104
+ # agent, and a turn that started outside any scope never adopts one.
105
+ # `on_trace` runs only for a trace the reporter accepted, which means
106
+ # it passed the enabled, configured and sampling checks: a delivery
107
+ # that then fails is logged by the reporter, not announced here.
108
+ def with_agent(name, action: "chat", attributes: {}, on_trace: nil, synchronous: false)
96
109
  previous = Thread.current[AGENT_KEY]
97
- Thread.current[AGENT_KEY] = { name: name, action: action }
110
+ Thread.current[AGENT_KEY] = {
111
+ name: name, action: action, attributes: attributes.to_h.transform_keys(&:to_s),
112
+ on_trace: on_trace, synchronous: synchronous
113
+ }
98
114
  yield
99
115
  ensure
100
116
  Thread.current[AGENT_KEY] = previous
101
117
  end
102
118
 
103
119
  def state
104
- Thread.current[STATE_KEY] ||= State.new(0, nil, [], 0, Span::ZERO_TOKENS.dup, nil)
120
+ Thread.current[STATE_KEY] ||= State.new(0, nil, [], 0, Span::ZERO_TOKENS.dup, nil, nil)
105
121
  end
106
122
 
107
123
  def clear_state
@@ -126,7 +142,13 @@ module ActiveAgents
126
142
  flush! if turn.rounds.positive? && (turn.chat_key != chat_key || turn_expired?(turn))
127
143
  turn = state
128
144
  turn.chat_key = chat_key
129
- turn.started_at ||= Time.now
145
+ if turn.started_at.nil?
146
+ turn.started_at = Time.now
147
+ # Captured once, on the turn's first round, whether or not a scope
148
+ # is active: a turn that started unscoped stays unscoped even when
149
+ # a later scope's chat is what flushes it.
150
+ turn.agent = Thread.current[AGENT_KEY]
151
+ end
130
152
  end
131
153
  turn.depth += 1
132
154
  end
@@ -161,7 +183,7 @@ module ActiveAgents
161
183
  private
162
184
 
163
185
  def report_turn(payload, turn)
164
- agent = Thread.current[AGENT_KEY] || resolve_agent(payload) || DEFAULT_AGENT
186
+ agent = turn.agent || resolve_agent(payload) || DEFAULT_AGENT
165
187
  started_at = turn.started_at || Time.now
166
188
  finished_at = Time.now
167
189
  error = payload[:exception_object]
@@ -172,12 +194,12 @@ module ActiveAgents
172
194
  resource_attributes: configuration.resource_attributes
173
195
  )
174
196
 
175
- root_attributes = {
197
+ root_attributes = (agent[:attributes] || {}).merge(
176
198
  "agent.class" => agent[:name],
177
199
  "agent.action" => agent[:action],
178
200
  "agent.provider" => payload[:provider].to_s,
179
201
  "agent.model" => payload[:model].to_s
180
- }
202
+ )
181
203
  root_attributes.merge!(conversation_attributes(payload)) if configuration.capture_bodies?
182
204
 
183
205
  root = trace.span(
@@ -206,7 +228,14 @@ module ActiveAgents
206
228
  trace.add_span(tool_span)
207
229
  end
208
230
 
209
- reporter.report(trace)
231
+ accepted = agent[:synchronous] ? reporter.report(trace, sync: true) : reporter.report(trace)
232
+ notify_trace(agent[:on_trace], trace) if accepted
233
+ end
234
+
235
+ def notify_trace(callback, trace)
236
+ callback&.call(trace)
237
+ rescue StandardError => e
238
+ warn "[#{SDK_NAME}] on_trace failed: #{e.class}"
210
239
  end
211
240
 
212
241
  def turn_expired?(turn)
@@ -280,22 +309,39 @@ module ActiveAgents
280
309
  text.to_s[0, CONTENT_LIMIT]
281
310
  end
282
311
 
312
+ # Tokens for one round. RubyLLM 2.x puts the round's +Tokens+ on the
313
+ # payload and moves the per-message readers to +message.tokens+; 1.x
314
+ # has +input_tokens+ and friends on each assistant message and nothing
315
+ # on the payload. The payload is read first, then the messages the
316
+ # round added, with either generation's readers.
283
317
  def token_totals(payload)
318
+ tokens = payload_tokens(payload[:tokens]) || message_tokens(payload)
319
+ tokens["total"] = tokens.values.sum
320
+ tokens
321
+ end
322
+
323
+ def payload_tokens(tokens)
324
+ return unless TOKEN_READERS.values.all? { |reader| tokens.respond_to?(reader) }
325
+
326
+ TOKEN_READERS.to_h { |key, reader| [ key, tokens.public_send(reader).to_i ] }
327
+ end
328
+
329
+ def message_tokens(payload)
284
330
  initial_count = Array(payload[:input_messages]).size
285
331
  new_messages = Array(payload[:messages_after])[initial_count..] || []
286
332
  assistant_messages = new_messages.select { |message| message.respond_to?(:role) && message.role.to_s == "assistant" }
287
333
 
288
- tokens = {
289
- "input" => sum_tokens(assistant_messages, :input_tokens),
290
- "output" => sum_tokens(assistant_messages, :output_tokens),
291
- "thinking" => sum_tokens(assistant_messages, :thinking_tokens)
292
- }
293
- tokens["total"] = tokens.values.sum
294
- tokens
334
+ TOKEN_READERS.to_h { |key, reader| [ key, assistant_messages.sum { |message| message_token_count(message, key, reader) } ] }
295
335
  end
296
336
 
297
- def sum_tokens(messages, method_name)
298
- messages.sum { |message| message.respond_to?(method_name) ? message.public_send(method_name).to_i : 0 }
337
+ # A 1.x message answers +input_tokens+; a 2.x message answers
338
+ # +tokens.input+.
339
+ def message_token_count(message, key, reader)
340
+ legacy_reader = :"#{key}_tokens"
341
+ return message.public_send(legacy_reader).to_i if message.respond_to?(legacy_reader)
342
+
343
+ tokens = message.tokens if message.respond_to?(:tokens)
344
+ tokens.respond_to?(reader) ? tokens.public_send(reader).to_i : 0
299
345
  end
300
346
  end
301
347
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activeagents-telemetry-ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - ActiveAgents
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activeagents-telemetry
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.1'
19
+ version: '0.3'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.1'
26
+ version: '0.3'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activesupport
29
29
  requirement: !ruby/object:Gem::Requirement