activeagents-telemetry-ruby_llm 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 74f798054c3b023ac181d541e9ce36d907733511e4abfd1a43d1565cbff09c3e
4
+ data.tar.gz: c651f96ca0fafc447d5e49090da2f84907fb4a04d33256ad1b87fee1d0e6d0d7
5
+ SHA512:
6
+ metadata.gz: 066163ce9683532ee6f2f3220592ede5a85c8a58208bdac182e169c32ccbe5a8c00682e1ac79c936da353399d6aeb024d132253b329d6dcf6e7b2227a0421577
7
+ data.tar.gz: d1ee5d365abac91ad386ff734095b21aa1b77ef1b0722c4a9d37e2742ab91cc9c9b5898db709f82cda59e7fd1663e224c19fa1691d34c095e6987f4e3d6783b3
data/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # activeagents-telemetry-ruby_llm
2
+
3
+ Reports [RubyLLM](https://github.com/crmne/ruby_llm) chats to an
4
+ ActiveAgents-compatible trace endpoint — the hosted platform, or any
5
+ self-hosted ActiveAgent dashboard.
6
+
7
+ For apps built directly on RubyLLM. Apps that can adopt `ActiveAgent::Base`
8
+ should use the framework's `ruby_llm` provider instead, which reports telemetry
9
+ on its own.
10
+
11
+ **[Full guide → the wiki](https://github.com/activeagents/activeagents-telemetry/wiki/RubyLLM)**
12
+
13
+ ## Install
14
+
15
+ ```ruby
16
+ gem "activeagents-telemetry-ruby_llm"
17
+ ```
18
+
19
+ ## Use
20
+
21
+ ```ruby
22
+ # config/initializers/telemetry.rb
23
+ RubyLLM.configure do |config|
24
+ config.instrumenter = ActiveSupport::Notifications # RubyLLM 1.x; 2.x wires this up in Rails
25
+ end
26
+
27
+ ActiveAgents::Telemetry.configure do |config|
28
+ config.api_key = ENV["ACTIVEAGENTS_API_KEY"]
29
+ config.service_name = "my-app"
30
+ end
31
+
32
+ ActiveAgents::Telemetry::RubyLLM.subscribe!
33
+ ```
34
+
35
+ The key is a platform API key (Settings → API Keys) or an account's legacy
36
+ `telemetry_api_key`. Delivery is fire-and-forget on a background thread, and
37
+ failures are logged and swallowed — telemetry never raises into the app.
38
+
39
+ ## What a turn looks like
40
+
41
+ One trace per conversation turn: a `root` span (`Agent.action`), one `llm`
42
+ span covering the whole provider loop with `llm.rounds` and token totals, and
43
+ a `tool` span per tool call with real timings.
44
+
45
+ RubyLLM emits a `chat.ruby_llm` event per provider round, and the two
46
+ generations arrange them differently — 1.x nests a tool round inside the
47
+ enclosing event, 2.x drives a flat `step until complete?` loop whose rounds
48
+ are siblings with tool calls between them. Rounds are accumulated and flushed
49
+ on the round that ends the turn, so both produce the same trace.
50
+
51
+ Tool arguments and results are never sent; error messages are truncated.
52
+
53
+ ## Naming the traffic
54
+
55
+ RubyLLM carries no application identity on the payload — neither a
56
+ `RubyLLM::Agent` class nor an `acts_as_chat` record reaches the instrumenter
57
+ — so unattributed traffic reports as `RubyLLM::Chat`:
58
+
59
+ ```ruby
60
+ # Per call site
61
+ ActiveAgents::Telemetry::RubyLLM.with_agent("SupportBot", action: "respond") { chat.ask(...) }
62
+
63
+ # Or from the initializer, derived from the event payload
64
+ ActiveAgents::Telemetry::RubyLLM.subscribe!(
65
+ agent_resolver: ->(payload) { { name: "SupportBot", action: payload[:tools].present? ? "respond" : "summarize" } }
66
+ )
67
+
68
+ # Or name every RubyLLM::Agent subclass by its class
69
+ module AgentTelemetryAttribution
70
+ def ask(...) = ActiveAgents::Telemetry::RubyLLM.with_agent(self.class.name) { super }
71
+ end
72
+ RubyLLM::Agent.prepend(AgentTelemetryAttribution)
73
+ ```
74
+
75
+ ## Scope
76
+
77
+ Chat completions and tool calls. RubyLLM's `embedding`, `image`,
78
+ `moderation`, `speech`, `transcription`, `request`, and `models.refresh`
79
+ events are not reported yet — they carry their own token counts and are a
80
+ natural extension of the same subscriber.
81
+
82
+ Concurrent tool execution runs tools off the instrumented thread and is not
83
+ captured; sequential execution (the default) is fully covered. A turn left
84
+ open by a halted tool call, or by an app driving 2.x's `step`/`run_tools` by
85
+ hand, is flushed when the next chat reports, after `MAX_TURN_SECONDS`, or on
86
+ an explicit `flush!`.
87
+
88
+ ## Tests
89
+
90
+ ```bash
91
+ bundle exec rake test
92
+ ```
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgents
4
+ module Telemetry
5
+ module RubyLLM
6
+ VERSION = "0.1.0"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/notifications"
5
+
6
+ require "activeagents/telemetry"
7
+
8
+ require_relative "ruby_llm/version"
9
+
10
+ module ActiveAgents
11
+ module Telemetry
12
+ # Reports RubyLLM chats to an ActiveAgents-compatible trace endpoint.
13
+ #
14
+ # Requires RubyLLM.config.instrumenter = ActiveSupport::Notifications
15
+ # (RubyLLM 1.x; 2.x wires this up under Rails).
16
+ #
17
+ # One trace per conversation turn: a root span, an llm span covering the
18
+ # whole provider loop, and a tool span per tool_call.ruby_llm event.
19
+ #
20
+ # RubyLLM emits a chat.ruby_llm event per provider round, and the two
21
+ # generations of the gem arrange those rounds differently: through 1.x a
22
+ # tool round recurses inside the enclosing event, while 2.x drives a flat
23
+ # `step until complete?` loop whose rounds are siblings with tool calls
24
+ # firing between them. Rounds are therefore accumulated and flushed on the
25
+ # round that ends the turn — the one that errors or answers without
26
+ # requesting tools — which yields the same trace under both arrangements.
27
+ # Tokens are summed per round from the assistant messages that round added,
28
+ # so a repeated event-level count is never double counted.
29
+ #
30
+ # Tool arguments and results are never sent; error messages are truncated.
31
+ module RubyLLM
32
+ AGENT_KEY = :activeagents_telemetry_ruby_llm_agent
33
+ STATE_KEY = :activeagents_telemetry_ruby_llm_state
34
+ TOOL_STARTED_AT_KEY = :_activeagents_telemetry_started_at
35
+ SDK_NAME = "activeagents-telemetry-ruby_llm"
36
+ # A turn that never reaches a final round (a halted tool call, or an app
37
+ # driving RubyLLM 2.x's `step` by hand) would otherwise accumulate forever.
38
+ MAX_TURN_SECONDS = 600
39
+
40
+ DEFAULT_AGENT = { name: "RubyLLM::Chat", action: "chat" }.freeze
41
+
42
+ State = Struct.new(:depth, :started_at, :tool_spans, :rounds, :tokens, :chat_key)
43
+
44
+ class << self
45
+ # Subscribes to RubyLLM's instrumentation.
46
+ #
47
+ # Destination settings fall back to ActiveAgents::Telemetry.configuration,
48
+ # so an app that already called `ActiveAgents::Telemetry.configure` can
49
+ # call this with no arguments at all.
50
+ #
51
+ # agent_resolver: optional callable receiving the chat event payload and
52
+ # returning { name:, action: }, so traffic can be attributed from an
53
+ # initializer alone; an enclosing with_agent block still wins. RubyLLM
54
+ # carries no application identity on the payload — neither RubyLLM::Agent
55
+ # nor an acts_as_chat record reaches the instrumenter — so unattributed
56
+ # traffic reports as RubyLLM::Chat.
57
+ def subscribe!(api_key: nil, endpoint: nil, service_name: nil, environment: nil,
58
+ agent_resolver: nil, async: nil, configuration: nil)
59
+ @configuration = configuration || Telemetry.configuration.dup
60
+ @configuration.api_key = api_key unless api_key.nil?
61
+ @configuration.endpoint = endpoint unless endpoint.nil?
62
+ @configuration.service_name = service_name unless service_name.nil?
63
+ @configuration.environment = environment unless environment.nil?
64
+ @configuration.async = async unless async.nil?
65
+
66
+ @agent_resolver = agent_resolver
67
+ @reporter = Reporter.new(@configuration, sdk_name: SDK_NAME, sdk_version: VERSION)
68
+
69
+ @subscriptions ||= [
70
+ ActiveSupport::Notifications.subscribe("chat.ruby_llm", ChatSubscriber.new),
71
+ ActiveSupport::Notifications.subscribe("tool_call.ruby_llm", ToolCallSubscriber.new)
72
+ ]
73
+ end
74
+
75
+ def unsubscribe!
76
+ Array(@subscriptions).each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
77
+ @subscriptions = nil
78
+ end
79
+
80
+ def configuration
81
+ @configuration ||= Telemetry.configuration
82
+ end
83
+
84
+ def reporter
85
+ @reporter ||= Reporter.new(configuration, sdk_name: SDK_NAME, sdk_version: VERSION)
86
+ end
87
+
88
+ attr_writer :reporter
89
+
90
+ # Attributes traces inside the block to a named agent/action.
91
+ def with_agent(name, action: "chat")
92
+ previous = Thread.current[AGENT_KEY]
93
+ Thread.current[AGENT_KEY] = { name: name, action: action }
94
+ yield
95
+ ensure
96
+ Thread.current[AGENT_KEY] = previous
97
+ end
98
+
99
+ def state
100
+ Thread.current[STATE_KEY] ||= State.new(0, nil, [], 0, Span::ZERO_TOKENS.dup, nil)
101
+ end
102
+
103
+ def clear_state
104
+ Thread.current[STATE_KEY] = nil
105
+ end
106
+
107
+ # Reports whatever the current turn has accumulated. Apps that drive
108
+ # RubyLLM 2.x's `step`/`run_tools` themselves can call this to close a
109
+ # turn that ends while tool calls are still pending.
110
+ def flush!(payload = {})
111
+ turn = Thread.current[STATE_KEY]
112
+ return if turn.nil? || turn.rounds.zero?
113
+
114
+ clear_state
115
+ report_turn(payload, turn)
116
+ end
117
+
118
+ def begin_round(payload)
119
+ turn = state
120
+ if turn.depth.zero?
121
+ chat_key = payload[:chat].object_id
122
+ flush! if turn.rounds.positive? && (turn.chat_key != chat_key || turn_expired?(turn))
123
+ turn = state
124
+ turn.chat_key = chat_key
125
+ turn.started_at ||= Time.now
126
+ end
127
+ turn.depth += 1
128
+ end
129
+
130
+ def finish_round(payload)
131
+ turn = state
132
+ turn.depth -= 1
133
+ return unless turn.depth.zero?
134
+
135
+ turn.rounds += 1
136
+ accumulate_tokens(turn, payload)
137
+ flush!(payload) if payload[:exception_object] || !payload[:tool_call]
138
+ end
139
+
140
+ def build_tool_span(payload, started_at, finished_at)
141
+ error = payload[:exception_object]
142
+ span = Span.new(
143
+ "tool.#{payload[:tool_name]}",
144
+ type: "tool",
145
+ start_time: started_at,
146
+ attributes: { "tool.name" => payload[:tool_name].to_s, "tool.call_id" => payload[:tool_call_id].to_s }
147
+ )
148
+ span.record_error(error, message_limit: configuration.error_message_limit) if error
149
+ span.finish(at: finished_at)
150
+ end
151
+
152
+ private
153
+
154
+ def report_turn(payload, turn)
155
+ agent = Thread.current[AGENT_KEY] || resolve_agent(payload) || DEFAULT_AGENT
156
+ started_at = turn.started_at || Time.now
157
+ finished_at = Time.now
158
+ error = payload[:exception_object]
159
+
160
+ trace = Trace.new(
161
+ service_name: configuration.resolved_service_name,
162
+ environment: configuration.resolved_environment,
163
+ resource_attributes: configuration.resource_attributes
164
+ )
165
+
166
+ root = trace.span(
167
+ "#{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
+ }
174
+ )
175
+
176
+ llm = trace.span(
177
+ "llm.generate", type: "llm", parent: root, start_time: started_at,
178
+ attributes: {
179
+ "llm.provider" => payload[:provider].to_s,
180
+ "llm.model" => payload[:model].to_s,
181
+ "llm.rounds" => turn.rounds,
182
+ "llm.streaming" => payload[:streaming] || false
183
+ }
184
+ )
185
+ llm.add_tokens(turn.tokens)
186
+
187
+ [ root, llm ].each do |span|
188
+ span.record_error(error, message_limit: configuration.error_message_limit) if error
189
+ span.finish(at: finished_at)
190
+ end
191
+
192
+ turn.tool_spans.each do |tool_span|
193
+ tool_span.parent_span_id = llm.span_id
194
+ trace.add_span(tool_span)
195
+ end
196
+
197
+ reporter.report(trace)
198
+ end
199
+
200
+ def turn_expired?(turn)
201
+ turn.started_at.nil? || (Time.now - turn.started_at) > MAX_TURN_SECONDS
202
+ end
203
+
204
+ def accumulate_tokens(turn, payload)
205
+ turn.tokens = turn.tokens.merge(token_totals(payload)) { |_key, carried, added| carried + added }
206
+ end
207
+
208
+ def resolve_agent(payload)
209
+ agent = @agent_resolver&.call(payload)
210
+ return unless agent.is_a?(Hash) && !agent[:name].to_s.empty?
211
+
212
+ { name: agent[:name], action: agent[:action] || "chat" }
213
+ rescue StandardError => e
214
+ warn "[#{SDK_NAME}] agent_resolver failed: #{e.class}: #{e.message}"
215
+ nil
216
+ end
217
+
218
+ def token_totals(payload)
219
+ initial_count = Array(payload[:input_messages]).size
220
+ new_messages = Array(payload[:messages_after])[initial_count..] || []
221
+ assistant_messages = new_messages.select { |message| message.respond_to?(:role) && message.role.to_s == "assistant" }
222
+
223
+ tokens = {
224
+ "input" => sum_tokens(assistant_messages, :input_tokens),
225
+ "output" => sum_tokens(assistant_messages, :output_tokens),
226
+ "thinking" => sum_tokens(assistant_messages, :thinking_tokens)
227
+ }
228
+ tokens["total"] = tokens.values.sum
229
+ tokens
230
+ end
231
+
232
+ def sum_tokens(messages, method_name)
233
+ messages.sum { |message| message.respond_to?(method_name) ? message.public_send(method_name).to_i : 0 }
234
+ end
235
+ end
236
+
237
+ # Evented ActiveSupport::Notifications subscriber; finish fires even when
238
+ # the instrumented block raises, with the exception on the payload.
239
+ class ChatSubscriber
240
+ def start(_name, _id, payload)
241
+ RubyLLM.begin_round(payload)
242
+ rescue StandardError => e
243
+ warn "[#{SDK_NAME}] #{e.class}: #{e.message}"
244
+ end
245
+
246
+ def finish(_name, _id, payload)
247
+ RubyLLM.finish_round(payload)
248
+ rescue StandardError => e
249
+ warn "[#{SDK_NAME}] #{e.class}: #{e.message}"
250
+ RubyLLM.clear_state
251
+ end
252
+ end
253
+
254
+ class ToolCallSubscriber
255
+ def start(_name, _id, payload)
256
+ payload[TOOL_STARTED_AT_KEY] = Time.now
257
+ end
258
+
259
+ def finish(_name, _id, payload)
260
+ started_at = payload.delete(TOOL_STARTED_AT_KEY) || Time.now
261
+ RubyLLM.state.tool_spans << RubyLLM.build_tool_span(payload, started_at, Time.now)
262
+ rescue StandardError => e
263
+ warn "[#{SDK_NAME}] #{e.class}: #{e.message}"
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activeagents-telemetry-ruby_llm
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ActiveAgents
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activeagents-telemetry
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ description: |
42
+ Subscribes to RubyLLM's instrumentation events and reports each chat turn
43
+ as a trace — a root span, an llm span, and a span per tool call — to the
44
+ ActiveAgents platform or any self-hosted ActiveAgent dashboard. Works with
45
+ apps built directly on RubyLLM: no ActiveAgent framework dependency.
46
+ email:
47
+ - hello@activeagents.ai
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files: []
51
+ files:
52
+ - README.md
53
+ - lib/activeagents/telemetry/ruby_llm.rb
54
+ - lib/activeagents/telemetry/ruby_llm/version.rb
55
+ homepage: https://github.com/activeagents/activeagents-telemetry
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://github.com/activeagents/activeagents-telemetry
60
+ source_code_uri: https://github.com/activeagents/activeagents-telemetry/tree/main/adapters/ruby_llm
61
+ documentation_uri: https://github.com/activeagents/activeagents-telemetry/wiki/RubyLLM
62
+ changelog_uri: https://github.com/activeagents/activeagents-telemetry/blob/main/CHANGELOG.md
63
+ rubygems_mfa_required: 'true'
64
+ post_install_message:
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.2.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 3.5.22
80
+ signing_key:
81
+ specification_version: 4
82
+ summary: Report RubyLLM chats to an ActiveAgents trace endpoint
83
+ test_files: []