activeagents-telemetry 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: 6c38e176c17f5a182fcab038fd6d83d9c4892323e25396233b05c8ba77c4b5fe
4
+ data.tar.gz: f884e7ee222f1e58d6d839c03d36e8dd8cff4f364850eff485d7f8b99e840899
5
+ SHA512:
6
+ metadata.gz: c455c628880fa3394dd19cdcb9e332f6a66624c1c85ca786d034ee29b4b20d8c3606c9a328f25a979ca29ed35a93aebe42155bc27666f4cab54feeb4502b5a5d
7
+ data.tar.gz: c6d2eb0384422271e510528fa363fc5b961b9aceb393e2fe575985fa9326433765e014eda642acd891e75b038f1faf25a1101d74416ef325278e6a458948d020
data/CHANGELOG.md ADDED
@@ -0,0 +1,40 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-08-10
6
+
7
+ First public release of both gems: `activeagents-telemetry` (the shared
8
+ core) and `activeagents-telemetry-ruby_llm` (the RubyLLM adapter).
9
+
10
+ ### Added
11
+
12
+ - Feature parity with the ActiveAgent framework's built-in telemetry, so the
13
+ framework can adopt this gem as a dependency: an `enabled` kill-switch,
14
+ `load_from_hash` for YAML-driven config, child spans (`Span#add_span`,
15
+ flattened by `Trace#to_h`), `Span#set_status`/`status_message`/`measure`,
16
+ `BatchingReporter` (buffered delivery with `batch_size`/`flush_interval`),
17
+ and a pluggable `local_store` for in-process persistence without HTTP.
18
+ - Attribute redaction is now actually applied at delivery: span attribute
19
+ keys matching `redact_attributes` (by dot-separated segment) are scrubbed.
20
+ The framework's previous implementation declared the option but never
21
+ enforced it.
22
+
23
+ - `activeagents-telemetry` — shared core extracted from the ActiveAgent gem's
24
+ telemetry and the RubyLLM adapter: `Configuration`, `Span`, `Trace`, and
25
+ `Reporter`, which together own the `POST /v1/traces` wire format.
26
+ - `activeagents-telemetry-ruby_llm` — the RubyLLM adapter, moved out of the
27
+ activeagents monorepo (`ruby_llm_telemetry/`) and ported onto the shared core.
28
+
29
+ ### Changed
30
+
31
+ - The adapter's namespace moved from `ActiveAgents::RubyLLMTelemetry` to
32
+ `ActiveAgents::Telemetry::RubyLLM`, and the gem name from
33
+ `active_agents-ruby_llm_telemetry` to `activeagents-telemetry-ruby_llm`. The
34
+ predecessor was never published to RubyGems, so there is no upgrade path to
35
+ maintain.
36
+ - `subscribe!` now inherits its destination from
37
+ `ActiveAgents::Telemetry.configuration` when arguments are omitted, so an app
38
+ configures the endpoint once for every adapter.
39
+ - Errors are reported through `Span#record_error`, which truncates the message
40
+ and never transmits a backtrace.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ActiveAgents
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # activeagents-telemetry
2
+
3
+ LLM tracing for Ruby apps. Reports what your app asked the model, how long it
4
+ took, what it cost in tokens, and which tools ran — to the
5
+ [ActiveAgents](https://activeagents.ai) platform or any self-hosted ActiveAgent
6
+ dashboard.
7
+
8
+ **[Configuration guide → the wiki](https://github.com/activeagents/activeagents-telemetry/wiki)**
9
+
10
+ ## Gems in this repo
11
+
12
+ | Gem | Use it when |
13
+ |-----|-------------|
14
+ | `activeagents-telemetry` | Shared core — wire format, configuration, delivery. Every adapter depends on it. |
15
+ | `activeagents-telemetry-ruby_llm` | Your app calls models through [RubyLLM](https://github.com/crmne/ruby_llm). |
16
+
17
+ Apps built on the [ActiveAgent](https://github.com/activeagents/activeagent)
18
+ framework don't need an adapter — the framework reports telemetry on its own.
19
+
20
+ ## Quick start
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem "activeagents-telemetry-ruby_llm"
25
+ ```
26
+
27
+ ```ruby
28
+ # config/initializers/telemetry.rb
29
+ RubyLLM.configure do |config|
30
+ config.instrumenter = ActiveSupport::Notifications # RubyLLM 1.x; 2.x wires this up in Rails
31
+ end
32
+
33
+ ActiveAgents::Telemetry.configure do |config|
34
+ config.api_key = ENV["ACTIVEAGENTS_API_KEY"]
35
+ config.service_name = "my-app"
36
+ end
37
+
38
+ ActiveAgents::Telemetry::RubyLLM.subscribe!
39
+ ```
40
+
41
+ That's it. Every chat turn now reports as a trace.
42
+
43
+ ## What a trace looks like
44
+
45
+ One trace per conversation turn:
46
+
47
+ ```
48
+ root SupportBot.respond 1,240ms OK
49
+ └─ llm llm.generate gpt-4o 2 rounds 1,180ms 42 tokens
50
+ └─ tool tool.search_docs 310ms OK
51
+ ```
52
+
53
+ Tool arguments and results are never sent. Error messages are truncated and
54
+ backtraces are never transmitted.
55
+
56
+ ## Self-hosting
57
+
58
+ Point the endpoint at your mounted dashboard — same gems, same wire format:
59
+
60
+ ```ruby
61
+ config.endpoint = "https://your-app.example.com/active_agent/api/traces"
62
+ ```
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ bundle install
68
+ bundle exec rake test_all # core + every adapter
69
+ bundle exec rake build_all # build all gems into pkg/
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT — see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgents
4
+ module Telemetry
5
+ # A Reporter that buffers traces and delivers them in batches — fewer
6
+ # HTTP requests under sustained traffic, at the cost of traces arriving
7
+ # up to flush_interval seconds late.
8
+ #
9
+ # A full buffer (configuration.batch_size) flushes immediately; a
10
+ # background thread flushes whatever accumulated every
11
+ # configuration.flush_interval seconds. Call #shutdown before process
12
+ # exit or the tail of the buffer is lost.
13
+ #
14
+ # Sampling happens on enqueue, so a dropped trace never occupies buffer
15
+ # space; enabled?/configured? are also checked on enqueue AND inherited
16
+ # from Reporter#report at delivery time.
17
+ class BatchingReporter < Reporter
18
+ def initialize(configuration, **options)
19
+ super
20
+ @buffer = []
21
+ @mutex = Mutex.new
22
+ @flusher = nil
23
+ @send_threads = []
24
+ @shutdown = false
25
+ end
26
+
27
+ # Enqueues a trace, flushing if the batch is full.
28
+ def report(traces)
29
+ return if @shutdown
30
+
31
+ accepted = normalize(traces).select { sample_trace? }
32
+ return if accepted.empty?
33
+ return unless configuration.enabled? && configuration.configured?
34
+
35
+ batch = nil
36
+ @mutex.synchronize do
37
+ @buffer.concat(accepted)
38
+ batch = @buffer.slice!(0..) if @buffer.size >= configuration.batch_size
39
+ start_flusher
40
+ end
41
+ deliver_batch(batch) if batch
42
+ nil
43
+ end
44
+
45
+ # Delivers everything buffered, blocking until done.
46
+ def flush
47
+ batch = @mutex.synchronize { @buffer.slice!(0..) }
48
+ deliver_batch(batch, blocking: true) unless batch.empty?
49
+ nil
50
+ end
51
+
52
+ # Flushes, waits out in-flight sends, and stops the background thread —
53
+ # traces reported just before process exit are delivered, not dropped.
54
+ # Idempotent.
55
+ def shutdown
56
+ @shutdown = true
57
+ flush
58
+ @mutex.synchronize { @send_threads.dup }.each { |thread| thread.join(configuration.timeout) }
59
+ @flusher&.kill
60
+ @flusher = nil
61
+ end
62
+
63
+ private
64
+
65
+ def deliver_batch(batch, blocking: false)
66
+ body = payload_for(batch)
67
+ return deliver(body) if blocking || !configuration.async?
68
+
69
+ thread = Thread.new { deliver(body) }
70
+ @mutex.synchronize do
71
+ @send_threads.select!(&:alive?)
72
+ @send_threads << thread
73
+ end
74
+ thread
75
+ rescue StandardError => e
76
+ log("failed to build trace payload: #{e.class}: #{e.message}")
77
+ end
78
+
79
+ # Lazily started so a reporter constructed at boot in a process that
80
+ # never traces (a console, a one-off rake task) spawns no thread.
81
+ def start_flusher
82
+ return if @flusher&.alive? || @shutdown
83
+
84
+ @flusher = Thread.new do
85
+ Thread.current.name = "activeagents-telemetry-flusher"
86
+ until @shutdown
87
+ sleep(configuration.flush_interval)
88
+ flush
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgents
4
+ module Telemetry
5
+ # Where traces go and how they get there.
6
+ #
7
+ # Every adapter takes one of these, so an app configures the destination
8
+ # once and each adapter inherits it:
9
+ #
10
+ # ActiveAgents::Telemetry.configure do |config|
11
+ # config.api_key = ENV["ACTIVEAGENTS_API_KEY"]
12
+ # config.service_name = "my-app"
13
+ # config.environment = Rails.env
14
+ # end
15
+ #
16
+ # A self-hosted ActiveAgent dashboard is the same thing with a different
17
+ # endpoint — point it at "https://your-app.example.com/active_agent/api/traces".
18
+ class Configuration
19
+ # The hosted platform. Self-hosters override `endpoint`.
20
+ DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/traces"
21
+
22
+ # Path a mounted ActiveAgent::Dashboard::Engine serves traces on.
23
+ LOCAL_ENDPOINT_PATH = "/active_agent/api/traces"
24
+
25
+ # Attribute keys scrubbed from spans before delivery. Name-based — a
26
+ # secret inside free text is not caught; see the wiki's privacy page.
27
+ DEFAULT_REDACT_ATTRIBUTES = %w[password secret token key credential api_key].freeze
28
+
29
+ attr_accessor :endpoint, :api_key, :service_name, :environment,
30
+ :resource_attributes, :logger, :timeout, :open_timeout,
31
+ :async, :sample_rate, :error_message_limit,
32
+ :enabled, :batch_size, :flush_interval,
33
+ :capture_bodies, :redact_attributes, :local_store
34
+
35
+ def initialize
36
+ @enabled = true
37
+ @endpoint = DEFAULT_ENDPOINT
38
+ @api_key = nil
39
+ @service_name = nil
40
+ @environment = nil
41
+ @resource_attributes = {}
42
+ @logger = nil
43
+ @timeout = 10
44
+ @open_timeout = 10
45
+ @async = true
46
+ @sample_rate = 1.0
47
+ @error_message_limit = 200
48
+ @batch_size = 100
49
+ @flush_interval = 5
50
+ @capture_bodies = false
51
+ @redact_attributes = DEFAULT_REDACT_ATTRIBUTES.dup
52
+ @local_store = nil
53
+ end
54
+
55
+ # Traces are only sent when there is somewhere to send them: an endpoint
56
+ # plus something to authenticate with, or a local store that bypasses
57
+ # HTTP entirely.
58
+ def configured?
59
+ local_store? || (!endpoint.to_s.empty? && !api_key.to_s.empty?)
60
+ end
61
+
62
+ # An explicit kill-switch over and above configured?. Defaults to true —
63
+ # for adapters, the presence of an api_key remains the effective switch;
64
+ # frameworks that want opt-in default this to false.
65
+ def enabled?
66
+ @enabled == true
67
+ end
68
+
69
+ # A callable (trace_hash, sdk_hash) that persists traces in-process —
70
+ # the ActiveAgent dashboard wires this to its TelemetryTrace model.
71
+ def local_store?
72
+ !@local_store.nil?
73
+ end
74
+
75
+ def capture_bodies?
76
+ @capture_bodies == true
77
+ end
78
+
79
+ def async?
80
+ @async == true
81
+ end
82
+
83
+ # Head-based sampling: decided once per trace, at report time.
84
+ def sample?
85
+ return true if sample_rate >= 1.0
86
+ return false if sample_rate <= 0.0
87
+
88
+ rand < sample_rate
89
+ end
90
+ alias should_sample? sample?
91
+
92
+ FLOAT_SETTINGS = %w[sample_rate].freeze
93
+ INTEGER_SETTINGS = %w[batch_size flush_interval timeout open_timeout error_message_limit].freeze
94
+
95
+ # Loads settings from a hash — how config/*.yml files reach here.
96
+ # Unknown keys are ignored, so a newer config file works on an older
97
+ # gem; numeric settings are coerced because ERB-interpolated YAML
98
+ # values arrive as strings.
99
+ def load_from_hash(hash)
100
+ (hash || {}).each do |key, value|
101
+ writer = "#{key}="
102
+ next unless respond_to?(writer)
103
+
104
+ value = value.to_f if FLOAT_SETTINGS.include?(key.to_s)
105
+ value = value.to_i if INTEGER_SETTINGS.include?(key.to_s)
106
+ public_send(writer, value)
107
+ end
108
+ self
109
+ end
110
+
111
+ # Falls back to the Rails application name, then to a generic label, so
112
+ # traces are attributable even when the app never sets one.
113
+ def resolved_service_name
114
+ @service_name || rails_app_name || "ruby"
115
+ end
116
+
117
+ def resolved_environment
118
+ @environment || rails_env || ENV.fetch("RACK_ENV", "production")
119
+ end
120
+
121
+ def resolved_logger
122
+ @logger || rails_logger || Logger.new(File::NULL)
123
+ end
124
+
125
+ def to_h
126
+ {
127
+ enabled: enabled,
128
+ endpoint: endpoint,
129
+ api_key: api_key ? "[REDACTED]" : nil,
130
+ service_name: resolved_service_name,
131
+ environment: resolved_environment,
132
+ sample_rate: sample_rate,
133
+ async: async,
134
+ batch_size: batch_size,
135
+ flush_interval: flush_interval,
136
+ capture_bodies: capture_bodies,
137
+ local_store: local_store? ? "[CALLABLE]" : nil
138
+ }
139
+ end
140
+
141
+ private
142
+
143
+ def rails_app_name
144
+ return nil unless defined?(Rails) && Rails.application
145
+
146
+ Rails.application.class.module_parent_name.underscore
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ def rails_env
152
+ defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.to_s : nil
153
+ end
154
+
155
+ def rails_logger
156
+ defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module ActiveAgents
8
+ module Telemetry
9
+ # Delivers traces to the configured endpoint.
10
+ #
11
+ # Telemetry is never worth breaking an app over, so every failure path here
12
+ # ends in a log line: a bad endpoint, a down collector, a serialization bug
13
+ # in an adapter, and a nil api_key all degrade to "no traces" rather than an
14
+ # exception in the caller's request cycle.
15
+ #
16
+ # Delivery is fire-and-forget on a thread by default. Pass `async: false`
17
+ # for tests, or for scripts short enough that the process may exit before a
18
+ # background thread flushes.
19
+ class Reporter
20
+ SDK_NAME = "activeagents-telemetry"
21
+
22
+ attr_reader :configuration
23
+
24
+ # @param sdk_name [String] overridden by adapters so ingest can tell
25
+ # which integration produced a trace.
26
+ # @param sample [Boolean] pass false when the caller already applied
27
+ # head-based sampling at trace creation — sampling twice compounds
28
+ # the rate to rate².
29
+ def initialize(configuration, sdk_name: SDK_NAME, sdk_version: VERSION, sample: true)
30
+ @configuration = configuration
31
+ @sdk_name = sdk_name
32
+ @sdk_version = sdk_version
33
+ @sample = sample
34
+ end
35
+
36
+ # @param traces [Trace, Hash, Array<Trace, Hash>] traces to deliver —
37
+ # Trace objects or already-serialized trace hashes
38
+ # @return [void]
39
+ def report(traces)
40
+ traces = normalize(traces)
41
+ return if traces.empty?
42
+ return unless configuration.enabled? && configuration.configured?
43
+ return unless sample_trace?
44
+
45
+ body = payload_for(traces)
46
+ configuration.async? ? Thread.new { deliver(body) } : deliver(body)
47
+ nil
48
+ rescue StandardError => e
49
+ log("failed to build trace payload: #{e.class}: #{e.message}")
50
+ nil
51
+ end
52
+
53
+ # Blocking delivery, for tests and for at-exit flushes.
54
+ def report_now(traces)
55
+ traces = normalize(traces)
56
+ return if traces.empty?
57
+ return unless configuration.enabled? && configuration.configured?
58
+
59
+ deliver(payload_for(traces))
60
+ end
61
+
62
+ private
63
+
64
+ # Array() is wrong here: it would explode a raw trace hash into
65
+ # key/value pairs. Traces arrive as Trace objects or serialized hashes,
66
+ # singly or in arrays.
67
+ def normalize(traces)
68
+ (traces.is_a?(Array) ? traces : [ traces ]).reject { |trace| trace.nil? || (trace.respond_to?(:empty?) && trace.empty?) }
69
+ end
70
+
71
+ # Trace#to_h is already string-keyed; raw hashes may arrive symbol-keyed
72
+ # and both local stores and ingest read string keys.
73
+ def serialize(trace)
74
+ trace.is_a?(Trace) ? trace.to_h : deep_stringify(trace)
75
+ end
76
+
77
+ def deep_stringify(value)
78
+ case value
79
+ when Hash then value.each_with_object({}) { |(key, inner), out| out[key.to_s] = deep_stringify(inner) }
80
+ when Array then value.map { |inner| deep_stringify(inner) }
81
+ else value
82
+ end
83
+ end
84
+
85
+ def sample_trace?
86
+ !@sample || configuration.sample?
87
+ end
88
+
89
+ def payload_for(traces)
90
+ {
91
+ "traces" => traces.map { |trace| redact(serialize(trace)) },
92
+ "sdk" => {
93
+ "name" => @sdk_name,
94
+ "version" => @sdk_version,
95
+ "language" => "ruby",
96
+ "runtime_version" => RUBY_VERSION
97
+ }
98
+ }
99
+ end
100
+
101
+ # Scrubs span attributes whose key matches the configured redaction
102
+ # list. Matching is by attribute key segment ("api_key", "http.api_key"),
103
+ # not by value — a secret inside free text is not caught here.
104
+ def redact(trace_hash)
105
+ redacted = Array(configuration.redact_attributes).map(&:to_s)
106
+ return trace_hash if redacted.empty?
107
+
108
+ spans = trace_hash["spans"] || trace_hash[:spans] || []
109
+ spans.each do |span|
110
+ attributes = span["attributes"] || span[:attributes]
111
+ next unless attributes.is_a?(Hash)
112
+
113
+ attributes.each_key do |key|
114
+ segments = key.to_s.downcase.split(".")
115
+ attributes[key] = "[REDACTED]" if segments.any? { |segment| redacted.include?(segment) }
116
+ end
117
+ end
118
+ trace_hash
119
+ end
120
+
121
+ def deliver(body)
122
+ return deliver_locally(body) if configuration.local_store?
123
+
124
+ uri = URI.parse(configuration.endpoint)
125
+
126
+ http = Net::HTTP.new(uri.host, uri.port)
127
+ http.use_ssl = uri.scheme == "https"
128
+ http.open_timeout = configuration.open_timeout
129
+ http.read_timeout = configuration.timeout
130
+
131
+ request = Net::HTTP::Post.new(uri.request_uri)
132
+ request["Content-Type"] = "application/json"
133
+ request["Authorization"] = "Bearer #{configuration.api_key}"
134
+ request["User-Agent"] = "#{@sdk_name}/#{@sdk_version} Ruby/#{RUBY_VERSION}"
135
+ request["X-Service-Name"] = configuration.resolved_service_name.to_s
136
+ request["X-Environment"] = configuration.resolved_environment.to_s
137
+ request.body = JSON.generate(body)
138
+
139
+ response = http.request(request)
140
+ log("ingest rejected traces: #{response.code} #{response.message}") unless response.is_a?(Net::HTTPSuccess)
141
+ response
142
+ rescue StandardError => e
143
+ log("#{e.class}: #{e.message}")
144
+ nil
145
+ end
146
+
147
+ # Hands each trace to the configured local store instead of HTTP —
148
+ # nothing leaves the process. Per-trace rescue: one bad row must not
149
+ # drop its batch-mates.
150
+ def deliver_locally(body)
151
+ body["traces"].each do |trace|
152
+ configuration.local_store.call(trace, body["sdk"])
153
+ rescue StandardError => e
154
+ log("local store failed: #{e.class}: #{e.message}")
155
+ end
156
+ nil
157
+ end
158
+
159
+ def log(message)
160
+ configuration.resolved_logger.error("[ActiveAgents::Telemetry] #{message}")
161
+ rescue StandardError
162
+ warn "[ActiveAgents::Telemetry] #{message}"
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module ActiveAgents
6
+ module Telemetry
7
+ # A single operation inside a trace, serialized to the `/v1/traces` wire
8
+ # format. Adapters build these rather than hand-rolling hashes, so a span
9
+ # from the RubyLLM adapter and a span from any future adapter are shaped
10
+ # identically on the wire.
11
+ class Span
12
+ TYPES = %w[root prompt llm tool thinking embedding error].freeze
13
+
14
+ OK = "OK"
15
+ ERROR = "ERROR"
16
+ UNSET = "UNSET"
17
+
18
+ ZERO_TOKENS = { "input" => 0, "output" => 0, "thinking" => 0, "total" => 0 }.freeze
19
+
20
+ attr_reader :span_id, :name, :type, :attributes, :events, :children, :status_message
21
+ attr_accessor :parent_span_id, :start_time, :end_time, :status
22
+
23
+ def initialize(name, type:, trace_id: nil, parent_span_id: nil, span_id: nil, attributes: {}, start_time: nil)
24
+ @span_id = span_id || SecureRandom.hex(8)
25
+ @trace_id = trace_id
26
+ @parent_span_id = parent_span_id
27
+ @name = name.to_s
28
+ @type = type.to_s
29
+ @attributes = stringify(attributes)
30
+ @start_time = start_time || Time.now
31
+ @end_time = nil
32
+ @status = UNSET
33
+ @status_message = nil
34
+ @tokens = ZERO_TOKENS.dup
35
+ @events = []
36
+ @children = []
37
+ end
38
+
39
+ attr_reader :trace_id
40
+
41
+ # Stamping a trace_id propagates to children, so a subtree built before
42
+ # its trace exists still lands in one piece.
43
+ def trace_id=(value)
44
+ @trace_id = value
45
+ @children.each { |child| child.trace_id = value }
46
+ end
47
+
48
+ # Builds a child span sharing this span's trace. Instrumentation that
49
+ # holds only a span — not the enclosing trace — nests through this;
50
+ # Trace#to_h flattens the tree back out for the wire.
51
+ def add_span(name, type: "root", **options)
52
+ child = Span.new(name, type: type, trace_id: trace_id, parent_span_id: span_id, **options)
53
+ @children << child
54
+ child
55
+ end
56
+
57
+ def set_attribute(key, value)
58
+ @attributes[key.to_s] = value
59
+ self
60
+ end
61
+
62
+ def set_attributes(attrs)
63
+ @attributes.merge!(stringify(attrs))
64
+ self
65
+ end
66
+
67
+ def set_tokens(input: 0, output: 0, thinking: 0)
68
+ @tokens = {
69
+ "input" => input.to_i,
70
+ "output" => output.to_i,
71
+ "thinking" => thinking.to_i,
72
+ "total" => input.to_i + output.to_i + thinking.to_i
73
+ }
74
+ self
75
+ end
76
+
77
+ def tokens
78
+ @tokens.dup
79
+ end
80
+
81
+ # Adds another span's token counts to this one — how a turn-level llm
82
+ # span accumulates the rounds it covers.
83
+ def add_tokens(other)
84
+ counts = other.respond_to?(:tokens) ? other.tokens : other
85
+ @tokens = @tokens.merge(stringify(counts)) { |_key, carried, added| carried.to_i + added.to_i }
86
+ self
87
+ end
88
+
89
+ def add_event(name, attributes = {})
90
+ @events << { "name" => name.to_s, "timestamp" => iso8601(Time.now), "attributes" => stringify(attributes) }
91
+ self
92
+ end
93
+
94
+ # @param code [Symbol, String] :ok, :error, or :unset
95
+ def set_status(code, message = nil)
96
+ @status = { ok: OK, error: ERROR, unset: UNSET }.fetch(code.to_s.downcase.to_sym, UNSET)
97
+ @status_message = message
98
+ self
99
+ end
100
+
101
+ # Records an error without ever putting a backtrace or an untruncated
102
+ # message on the wire — telemetry payloads leave the app's trust boundary.
103
+ def record_error(error, message_limit: 200)
104
+ @status = ERROR
105
+ @status_message = truncate(error.message.to_s, message_limit)
106
+ set_attribute("error.type", error.class.name)
107
+ set_attribute("error.message", truncate(error.message.to_s, message_limit))
108
+ self
109
+ end
110
+
111
+ def finish(at: nil)
112
+ @end_time = at || Time.now
113
+ @status = OK if @status == UNSET
114
+ self
115
+ end
116
+
117
+ def finished?
118
+ !@end_time.nil?
119
+ end
120
+
121
+ def duration_ms
122
+ return nil unless finished?
123
+
124
+ ((@end_time - @start_time) * 1000).round(2)
125
+ end
126
+
127
+ def to_h
128
+ {
129
+ "span_id" => span_id,
130
+ "trace_id" => trace_id,
131
+ "parent_span_id" => parent_span_id,
132
+ "name" => name,
133
+ "type" => type,
134
+ "start_time" => iso8601(start_time),
135
+ "end_time" => end_time ? iso8601(end_time) : nil,
136
+ "duration_ms" => duration_ms,
137
+ "status" => status,
138
+ "status_message" => status_message,
139
+ "attributes" => attributes,
140
+ "tokens" => tokens,
141
+ "events" => events
142
+ }
143
+ end
144
+
145
+ # Runs the block, recording its outcome and timing on the span.
146
+ def measure
147
+ result = yield(self)
148
+ set_status(:ok) if @status == UNSET
149
+ result
150
+ rescue StandardError => e
151
+ record_error(e)
152
+ raise
153
+ ensure
154
+ finish
155
+ end
156
+
157
+ private
158
+
159
+ def stringify(hash)
160
+ (hash || {}).each_with_object({}) { |(key, value), out| out[key.to_s] = value }
161
+ end
162
+
163
+ def truncate(string, limit)
164
+ string.length > limit ? "#{string[0, limit - 1]}…" : string
165
+ end
166
+
167
+ def iso8601(time)
168
+ time.utc.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z")
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module ActiveAgents
6
+ module Telemetry
7
+ # A collection of spans sharing a trace_id, plus the service metadata the
8
+ # ingest endpoint keys on. Adapters build one per unit of work — for chat
9
+ # adapters, one per conversation turn.
10
+ class Trace
11
+ attr_reader :trace_id, :spans
12
+ attr_accessor :service_name, :environment, :resource_attributes
13
+
14
+ def initialize(trace_id: nil, service_name: nil, environment: nil, resource_attributes: {})
15
+ @trace_id = trace_id || SecureRandom.hex(16)
16
+ @service_name = service_name
17
+ @environment = environment
18
+ @resource_attributes = resource_attributes || {}
19
+ @spans = []
20
+ end
21
+
22
+ # Adopts the span into this trace, stamping the trace_id so adapters
23
+ # cannot emit a span that belongs to no trace.
24
+ def add_span(span)
25
+ span.trace_id = trace_id
26
+ @spans << span
27
+ span
28
+ end
29
+
30
+ # Builds and adopts a span in one step.
31
+ def span(name, type:, parent: nil, **options)
32
+ add_span(Span.new(name, type: type, parent_span_id: parent&.span_id, **options))
33
+ end
34
+
35
+ def root
36
+ @spans.find { |span| span.type == "root" }
37
+ end
38
+
39
+ def empty?
40
+ @spans.empty?
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ "trace_id" => trace_id,
46
+ "service_name" => service_name,
47
+ "environment" => environment,
48
+ "timestamp" => timestamp,
49
+ "resource_attributes" => resource_attributes,
50
+ "spans" => spans.flat_map { |span| flatten(span) }.map(&:to_h)
51
+ }
52
+ end
53
+
54
+ private
55
+
56
+ # Spans built through Span#add_span nest as children; the wire format is
57
+ # flat, with parentage carried by parent_span_id.
58
+ def flatten(span)
59
+ [ span ] + span.children.flat_map { |child| flatten(child) }
60
+ end
61
+
62
+ # The trace's own end time, so out-of-order delivery still lands the
63
+ # trace at the moment it actually finished.
64
+ def timestamp
65
+ finished = spans.map(&:end_time).compact.max || Time.now
66
+ finished.utc.strftime("%Y-%m-%dT%H:%M:%S.%6N%:z")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgents
4
+ module Telemetry
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "telemetry/version"
6
+ require_relative "telemetry/configuration"
7
+ require_relative "telemetry/span"
8
+ require_relative "telemetry/trace"
9
+ require_relative "telemetry/reporter"
10
+ require_relative "telemetry/batching_reporter"
11
+
12
+ # Shared core for reporting LLM traces to ActiveAgents.
13
+ #
14
+ # This gem carries no integration of its own — it owns the wire format, the
15
+ # configuration, and delivery. Install an adapter for whatever your app calls
16
+ # the model through:
17
+ #
18
+ # gem "activeagents-telemetry-ruby_llm" # apps built on RubyLLM
19
+ #
20
+ # Configure the destination once and every adapter inherits it:
21
+ #
22
+ # ActiveAgents::Telemetry.configure do |config|
23
+ # config.api_key = ENV["ACTIVEAGENTS_API_KEY"]
24
+ # config.service_name = "my-app"
25
+ # end
26
+ module ActiveAgents
27
+ module Telemetry
28
+ class << self
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ def configure
34
+ yield(configuration) if block_given?
35
+ configuration
36
+ end
37
+
38
+ # Replaces configuration wholesale; mostly useful in tests.
39
+ attr_writer :configuration
40
+
41
+ def reset!
42
+ @configuration = Configuration.new
43
+ @reporter = nil
44
+ end
45
+
46
+ def reporter
47
+ @reporter ||= Reporter.new(configuration)
48
+ end
49
+
50
+ attr_writer :reporter
51
+
52
+ def enabled?
53
+ configuration.configured?
54
+ end
55
+ end
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activeagents-telemetry
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
+ description: |
14
+ The wire format, configuration, and delivery layer behind ActiveAgents
15
+ telemetry. Install an adapter for whatever your app calls the model
16
+ through — activeagents-telemetry-ruby_llm for apps built on RubyLLM —
17
+ and traces land in the ActiveAgents platform or any self-hosted
18
+ ActiveAgent dashboard (POST /v1/traces). Nothing beyond stdlib.
19
+ email:
20
+ - hello@activeagents.ai
21
+ executables: []
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE.txt
27
+ - README.md
28
+ - lib/activeagents/telemetry.rb
29
+ - lib/activeagents/telemetry/batching_reporter.rb
30
+ - lib/activeagents/telemetry/configuration.rb
31
+ - lib/activeagents/telemetry/reporter.rb
32
+ - lib/activeagents/telemetry/span.rb
33
+ - lib/activeagents/telemetry/trace.rb
34
+ - lib/activeagents/telemetry/version.rb
35
+ homepage: https://github.com/activeagents/activeagents-telemetry
36
+ licenses:
37
+ - MIT
38
+ metadata:
39
+ homepage_uri: https://github.com/activeagents/activeagents-telemetry
40
+ source_code_uri: https://github.com/activeagents/activeagents-telemetry
41
+ documentation_uri: https://github.com/activeagents/activeagents-telemetry/wiki
42
+ changelog_uri: https://github.com/activeagents/activeagents-telemetry/blob/main/CHANGELOG.md
43
+ rubygems_mfa_required: 'true'
44
+ post_install_message:
45
+ rdoc_options: []
46
+ require_paths:
47
+ - lib
48
+ required_ruby_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 3.2.0
53
+ required_rubygems_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '0'
58
+ requirements: []
59
+ rubygems_version: 3.5.22
60
+ signing_key:
61
+ specification_version: 4
62
+ summary: Shared core for reporting LLM traces to ActiveAgents
63
+ test_files: []