activeagents-telemetry-ruby_llm 0.1.0 → 0.3.0

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: 74f798054c3b023ac181d541e9ce36d907733511e4abfd1a43d1565cbff09c3e
4
- data.tar.gz: c651f96ca0fafc447d5e49090da2f84907fb4a04d33256ad1b87fee1d0e6d0d7
3
+ metadata.gz: 5abf7d85a5deec9d008170f9c9a3e50d619024b7bad8c1f01eea3eda3d3befc6
4
+ data.tar.gz: ea10cb9f3cb45700fe8ad0642c72eb3c6e01576c1e4353d274998ead5f3f7243
5
5
  SHA512:
6
- metadata.gz: 066163ce9683532ee6f2f3220592ede5a85c8a58208bdac182e169c32ccbe5a8c00682e1ac79c936da353399d6aeb024d132253b329d6dcf6e7b2227a0421577
7
- data.tar.gz: d1ee5d365abac91ad386ff734095b21aa1b77ef1b0722c4a9d37e2742ab91cc9c9b5898db709f82cda59e7fd1663e224c19fa1691d34c095e6987f4e3d6783b3
6
+ metadata.gz: 67e5b7d06a3de8510dead200b6ec9a4da44172d24b46c3ea02646cfd4fad1fc3c5acb960798e17a5957f61d8dcd4bd7efc392ae08f8e46ef19b084014e357073
7
+ data.tar.gz: 16dde44b30870de36415b0958b85eabff49caf639238baf140107e6b2a986effe21bf0eea529c78b5810d90816098cac8c35f09800304c3c5cddd3c7ab6b3c22
data/README.md CHANGED
@@ -48,7 +48,9 @@ enclosing event, 2.x drives a flat `step until complete?` loop whose rounds
48
48
  are siblings with tool calls between them. Rounds are accumulated and flushed
49
49
  on the round that ends the turn, so both produce the same trace.
50
50
 
51
- Tool arguments and results are never sent; error messages are truncated.
51
+ Prompts, completions, and tool arguments/results are sent only when the
52
+ configuration's `capture_bodies` is enabled (off by default, truncated to
53
+ 4,000 characters); error messages are truncated.
52
54
 
53
55
  ## Naming the traffic
54
56
 
@@ -90,3 +92,44 @@ an explicit `flush!`.
90
92
  ```bash
91
93
  bundle exec rake test
92
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.1.0"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
9
9
  end
@@ -27,7 +27,9 @@ module ActiveAgents
27
27
  # Tokens are summed per round from the assistant messages that round added,
28
28
  # so a repeated event-level count is never double counted.
29
29
  #
30
- # Tool arguments and results are never sent; error messages are truncated.
30
+ # Prompts, completions, and tool arguments/results are not sent unless the
31
+ # configuration's capture_bodies is enabled (they may contain sensitive
32
+ # data); error messages are truncated.
31
33
  module RubyLLM
32
34
  AGENT_KEY = :activeagents_telemetry_ruby_llm_agent
33
35
  STATE_KEY = :activeagents_telemetry_ruby_llm_state
@@ -36,10 +38,12 @@ module ActiveAgents
36
38
  # A turn that never reaches a final round (a halted tool call, or an app
37
39
  # driving RubyLLM 2.x's `step` by hand) would otherwise accumulate forever.
38
40
  MAX_TURN_SECONDS = 600
41
+ # Captured prompt/completion/tool content is truncated to this many characters.
42
+ CONTENT_LIMIT = 4_000
39
43
 
40
44
  DEFAULT_AGENT = { name: "RubyLLM::Chat", action: "chat" }.freeze
41
45
 
42
- State = Struct.new(:depth, :started_at, :tool_spans, :rounds, :tokens, :chat_key)
46
+ State = Struct.new(:depth, :started_at, :tool_spans, :rounds, :tokens, :chat_key, :agent)
43
47
 
44
48
  class << self
45
49
  # Subscribes to RubyLLM's instrumentation.
@@ -87,17 +91,30 @@ module ActiveAgents
87
91
 
88
92
  attr_writer :reporter
89
93
 
90
- # Attributes traces inside the block to a named agent/action.
91
- def with_agent(name, action: "chat")
94
+ # Attributes traces inside the block to a named agent/action. Correlation
95
+ # attributes and the callback apply to this scope only. Short-lived
96
+ # evaluation commands can deliver synchronously without changing the
97
+ # application's shared reporter configuration.
98
+ #
99
+ # A turn keeps the scope it started under, so a turn left open by a
100
+ # pending tool call and closed later by `flush!` still reports as this
101
+ # agent, and a turn that started outside any scope never adopts one.
102
+ # `on_trace` runs only for a trace the reporter accepted, which means
103
+ # it passed the enabled, configured and sampling checks: a delivery
104
+ # that then fails is logged by the reporter, not announced here.
105
+ def with_agent(name, action: "chat", attributes: {}, on_trace: nil, synchronous: false)
92
106
  previous = Thread.current[AGENT_KEY]
93
- Thread.current[AGENT_KEY] = { name: name, action: action }
107
+ Thread.current[AGENT_KEY] = {
108
+ name: name, action: action, attributes: attributes.to_h.transform_keys(&:to_s),
109
+ on_trace: on_trace, synchronous: synchronous
110
+ }
94
111
  yield
95
112
  ensure
96
113
  Thread.current[AGENT_KEY] = previous
97
114
  end
98
115
 
99
116
  def state
100
- Thread.current[STATE_KEY] ||= State.new(0, nil, [], 0, Span::ZERO_TOKENS.dup, nil)
117
+ Thread.current[STATE_KEY] ||= State.new(0, nil, [], 0, Span::ZERO_TOKENS.dup, nil, nil)
101
118
  end
102
119
 
103
120
  def clear_state
@@ -122,7 +139,13 @@ module ActiveAgents
122
139
  flush! if turn.rounds.positive? && (turn.chat_key != chat_key || turn_expired?(turn))
123
140
  turn = state
124
141
  turn.chat_key = chat_key
125
- turn.started_at ||= Time.now
142
+ if turn.started_at.nil?
143
+ turn.started_at = Time.now
144
+ # Captured once, on the turn's first round, whether or not a scope
145
+ # is active: a turn that started unscoped stays unscoped even when
146
+ # a later scope's chat is what flushes it.
147
+ turn.agent = Thread.current[AGENT_KEY]
148
+ end
126
149
  end
127
150
  turn.depth += 1
128
151
  end
@@ -139,11 +162,16 @@ module ActiveAgents
139
162
 
140
163
  def build_tool_span(payload, started_at, finished_at)
141
164
  error = payload[:exception_object]
165
+ attributes = { "tool.name" => payload[:tool_name].to_s, "tool.call_id" => payload[:tool_call_id].to_s }
166
+ if configuration.capture_bodies?
167
+ attributes["tool.arguments"] = tool_io_json(payload[:tool_arguments])
168
+ attributes["tool.result"] = tool_io_json(payload[:result_content]) unless error
169
+ end
142
170
  span = Span.new(
143
171
  "tool.#{payload[:tool_name]}",
144
172
  type: "tool",
145
173
  start_time: started_at,
146
- attributes: { "tool.name" => payload[:tool_name].to_s, "tool.call_id" => payload[:tool_call_id].to_s }
174
+ attributes: attributes
147
175
  )
148
176
  span.record_error(error, message_limit: configuration.error_message_limit) if error
149
177
  span.finish(at: finished_at)
@@ -152,7 +180,7 @@ module ActiveAgents
152
180
  private
153
181
 
154
182
  def report_turn(payload, turn)
155
- agent = Thread.current[AGENT_KEY] || resolve_agent(payload) || DEFAULT_AGENT
183
+ agent = turn.agent || resolve_agent(payload) || DEFAULT_AGENT
156
184
  started_at = turn.started_at || Time.now
157
185
  finished_at = Time.now
158
186
  error = payload[:exception_object]
@@ -163,14 +191,17 @@ module ActiveAgents
163
191
  resource_attributes: configuration.resource_attributes
164
192
  )
165
193
 
194
+ root_attributes = (agent[:attributes] || {}).merge(
195
+ "agent.class" => agent[:name],
196
+ "agent.action" => agent[:action],
197
+ "agent.provider" => payload[:provider].to_s,
198
+ "agent.model" => payload[:model].to_s
199
+ )
200
+ root_attributes.merge!(conversation_attributes(payload)) if configuration.capture_bodies?
201
+
166
202
  root = trace.span(
167
203
  "#{agent[:name]}.#{agent[:action]}", type: "root", start_time: started_at,
168
- attributes: {
169
- "agent.class" => agent[:name],
170
- "agent.action" => agent[:action],
171
- "agent.provider" => payload[:provider].to_s,
172
- "agent.model" => payload[:model].to_s
173
- }
204
+ attributes: root_attributes
174
205
  )
175
206
 
176
207
  llm = trace.span(
@@ -194,7 +225,14 @@ module ActiveAgents
194
225
  trace.add_span(tool_span)
195
226
  end
196
227
 
197
- reporter.report(trace)
228
+ accepted = agent[:synchronous] ? reporter.report(trace, sync: true) : reporter.report(trace)
229
+ notify_trace(agent[:on_trace], trace) if accepted
230
+ end
231
+
232
+ def notify_trace(callback, trace)
233
+ callback&.call(trace)
234
+ rescue StandardError => e
235
+ warn "[#{SDK_NAME}] on_trace failed: #{e.class}"
198
236
  end
199
237
 
200
238
  def turn_expired?(turn)
@@ -215,6 +253,59 @@ module ActiveAgents
215
253
  nil
216
254
  end
217
255
 
256
+ # The prompt that opened the turn and the answer that closed it — the two
257
+ # ends a trace is otherwise missing. System instructions are included:
258
+ # they are the most common cause of a surprising answer.
259
+ def conversation_attributes(payload)
260
+ input = Array(payload[:input_messages])
261
+ new_messages = Array(payload[:messages_after])[input.size..] || []
262
+
263
+ attributes = {}
264
+ if (prompt = last_message_text(input, "user"))
265
+ attributes["llm.prompt"] = truncate_captured(prompt)
266
+ end
267
+ if (instructions = system_instructions(input))
268
+ attributes["llm.instructions"] = truncate_captured(instructions)
269
+ end
270
+ if (completion = last_message_text(new_messages, "assistant"))
271
+ attributes["llm.completion"] = truncate_captured(completion)
272
+ end
273
+ attributes
274
+ end
275
+
276
+ # RubyLLM's `with_instructions` appends by default, so a chat can carry
277
+ # several system messages and the model sees all of them. Join rather
278
+ # than taking the last, or an app that layers a base prompt with a
279
+ # per-request one would report only the fragment.
280
+ def system_instructions(messages)
281
+ texts = messages.select do |candidate|
282
+ candidate.respond_to?(:role) && candidate.role.to_s == "system" &&
283
+ candidate.respond_to?(:content) && !candidate.content.to_s.empty?
284
+ end.map { |message| message.content.to_s }
285
+
286
+ texts.empty? ? nil : texts.join("\n\n")
287
+ end
288
+
289
+ def last_message_text(messages, role)
290
+ message = messages.reverse.find do |candidate|
291
+ candidate.respond_to?(:role) && candidate.role.to_s == role &&
292
+ candidate.respond_to?(:content) && !candidate.content.to_s.empty?
293
+ end
294
+ text = message&.content.to_s
295
+ text.empty? ? nil : text
296
+ end
297
+
298
+ def tool_io_json(value)
299
+ json = value.is_a?(String) ? value : JSON.generate(value)
300
+ truncate_captured(json)
301
+ rescue StandardError
302
+ value.inspect[0, CONTENT_LIMIT]
303
+ end
304
+
305
+ def truncate_captured(text)
306
+ text.to_s[0, CONTENT_LIMIT]
307
+ end
308
+
218
309
  def token_totals(payload)
219
310
  initial_count = Array(payload[:input_messages]).size
220
311
  new_messages = Array(payload[:messages_after])[initial_count..] || []
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.1.0
4
+ version: 0.3.0
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-10 00:00:00.000000000 Z
11
+ date: 2026-09-13 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