activeagent 1.0.3 → 1.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +26 -0
  4. data/lib/active_agent/base.rb +4 -0
  5. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +27 -6
  6. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +11 -1
  7. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +15 -12
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +27 -7
  9. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +9 -0
  10. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +18 -2
  11. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +15 -3
  12. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +3 -1
  13. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +4 -4
  14. data/lib/active_agent/dashboard/config/routes.rb +5 -64
  15. data/lib/active_agent/dashboard/engine.rb +19 -15
  16. data/lib/active_agent/dashboard.rb +13 -3
  17. data/lib/active_agent/model_capabilities.rb +89 -0
  18. data/lib/active_agent/providers/_base_provider.rb +23 -2
  19. data/lib/active_agent/providers/concerns/exception_handler.rb +12 -1
  20. data/lib/active_agent/providers/errors.rb +140 -0
  21. data/lib/active_agent/providers/ollama/chat/transforms.rb +9 -3
  22. data/lib/active_agent/railtie.rb +5 -0
  23. data/lib/active_agent/telemetry/configuration.rb +112 -172
  24. data/lib/active_agent/telemetry/instrumentation.rb +137 -14
  25. data/lib/active_agent/telemetry/reporter.rb +12 -165
  26. data/lib/active_agent/telemetry/span.rb +18 -245
  27. data/lib/active_agent/telemetry/tracer.rb +67 -70
  28. data/lib/active_agent/telemetry.rb +1 -2
  29. data/lib/active_agent/version.rb +1 -1
  30. data/lib/active_agent.rb +5 -0
  31. data/lib/generators/active_agent/dashboard/install_generator.rb +30 -2
  32. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +41 -4
  33. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +20 -4
  34. metadata +17 -11
  35. data/lib/generators/active_agent/dashboard/install/install_generator.rb +0 -96
  36. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +0 -89
  37. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +0 -42
  38. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +0 -38
  39. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +0 -22
  40. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +0 -53
  41. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +0 -28
  42. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +0 -43
  43. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +0 -44
  44. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +0 -56
@@ -23,11 +23,6 @@ module ActiveAgent
23
23
  module Instrumentation
24
24
  extend ActiveSupport::Concern
25
25
 
26
- included do
27
- # Hook into generation lifecycle
28
- around_generate :trace_generation if respond_to?(:around_generate)
29
- end
30
-
31
26
  class_methods do
32
27
  # Installs instrumentation on the agent class.
33
28
  #
@@ -46,21 +41,82 @@ module ActiveAgent
46
41
  def process_prompt
47
42
  return super unless Telemetry.enabled?
48
43
 
49
- Telemetry.trace("#{self.class.name}.#{action_name}", span_type: :root) do |span|
44
+ # Reuse (or mint) the generation's trace id so the telemetry trace
45
+ # shares one id with everything else that reads
46
+ # prompt_options[:trace_id] — the generation request parameters and
47
+ # e.g. solid_agent's persisted generation records. Without this the
48
+ # tracer generates its own id and traces can't be correlated with
49
+ # the records they describe.
50
+ trace_id = nil
51
+ if respond_to?(:prompt_options) && prompt_options.is_a?(Hash)
52
+ trace_id = (prompt_options[:trace_id] ||= SecureRandom.uuid)
53
+ end
54
+
55
+ Telemetry.trace("#{self.class.name}.#{action_name}", span_type: :root, **{ trace_id: trace_id }.compact) do |span|
50
56
  span.set_attribute("agent.class", self.class.name)
51
57
  span.set_attribute("agent.action", action_name.to_s)
52
- span.set_attribute("agent.provider", provider_name) if respond_to?(:provider_name)
53
- span.set_attribute("agent.model", model_name) if respond_to?(:model_name)
58
+ span.set_attribute("agent.provider", provider_name)
59
+ span.set_attribute("agent.model", model_name)
54
60
 
55
- # Add prompt span
61
+ # Add prompt span, carrying the prompt contents (instructions +
62
+ # outbound messages) so dashboards can show what was sent.
56
63
  prompt_span = span.add_span("agent.prompt", span_type: :prompt)
57
- prompt_span.set_attribute("messages.count", messages.size) if respond_to?(:messages)
64
+ if (message_stack = prompt_options[:messages]).respond_to?(:size)
65
+ prompt_span.set_attribute("messages.count", message_stack.size)
66
+ end
67
+ # Attribute order is display order in dashboards: the system
68
+ # message first, then the tool roster, then the (often long)
69
+ # message history.
70
+ if prompt_options.is_a?(Hash)
71
+ rendered_instructions = begin
72
+ prompt_view_instructions(prompt_options[:instructions]) if respond_to?(:prompt_view_instructions)
73
+ rescue StandardError
74
+ prompt_options[:instructions].is_a?(String) ? prompt_options[:instructions] : nil
75
+ end
76
+ if rendered_instructions.present?
77
+ prompt_span.set_attribute("prompt.input.instructions", telemetry_truncate(Array(rendered_instructions).join("\n\n")))
78
+ end
79
+
80
+ if (tools = prompt_options[:tools]).present?
81
+ roster = Array(tools).filter_map { |tool|
82
+ next unless tool.is_a?(Hash)
83
+
84
+ name = tool[:name] || tool["name"]
85
+ description = tool[:description] || tool["description"]
86
+ parameters = tool[:parameters] || tool["parameters"] || tool[:input_schema] || tool["input_schema"]
87
+ properties = parameters.is_a?(Hash) ? (parameters[:properties] || parameters["properties"]) : nil
88
+ {
89
+ name: name,
90
+ description: telemetry_truncate(description),
91
+ parameters: properties.is_a?(Hash) ? properties.keys : []
92
+ }.compact
93
+ }
94
+ prompt_span.set_attribute("prompt.input.tools", JSON.generate(roster)) if roster.any?
95
+ end
96
+
97
+ if (outbound = prompt_options[:messages]).present?
98
+ serialized = Array(outbound).map { |message|
99
+ if message.is_a?(Hash)
100
+ role = message[:role] || message["role"] || "user"
101
+ content = message[:content] || message["content"]
102
+ { role: role.to_s, content: telemetry_truncate(content) }
103
+ else
104
+ { role: "user", content: telemetry_truncate(message) }
105
+ end
106
+ }
107
+ prompt_span.set_attribute("prompt.input.messages", JSON.generate(serialized))
108
+ end
109
+ end
58
110
  prompt_span.finish
59
111
 
60
112
  # Execute generation with LLM span
61
113
  llm_span = span.add_span("llm.generate", span_type: :llm)
62
- llm_span.set_attribute("llm.provider", provider_name) if respond_to?(:provider_name)
63
- llm_span.set_attribute("llm.model", model_name) if respond_to?(:model_name)
114
+ llm_span.set_attribute("llm.provider", provider_name)
115
+ llm_span.set_attribute("llm.model", model_name)
116
+
117
+ # Providers run tools through tools_function mid-generation; the
118
+ # wrapped proc (see below) hangs timed tool spans off this span.
119
+ @_telemetry_llm_span = llm_span
64
120
 
65
121
  begin
66
122
  result = super
@@ -95,6 +151,23 @@ module ActiveAgent
95
151
  end
96
152
  end
97
153
 
154
+ # The span was tagged with the configured model (often unset →
155
+ # "unknown"); the provider's raw response knows what actually
156
+ # served the request.
157
+ if result.respond_to?(:raw_response) && result.raw_response.is_a?(Hash)
158
+ served_model = result.raw_response["model"] || result.raw_response[:model]
159
+ llm_span.set_attribute("llm.model", served_model.to_s) if served_model.present?
160
+ end
161
+
162
+ # Carry the generation contents so dashboards can show what
163
+ # came back, not just how many tokens it cost.
164
+ if result.respond_to?(:message) && result.message.respond_to?(:content) && result.message.content.present?
165
+ llm_span.set_attribute("llm.output.message", telemetry_truncate(result.message.content))
166
+ end
167
+ if result.respond_to?(:finish_reason) && result.finish_reason.present?
168
+ llm_span.set_attribute("llm.finish_reason", result.finish_reason.to_s)
169
+ end
170
+
98
171
  llm_span.set_status(:ok)
99
172
  llm_span.finish
100
173
  span.set_status(:ok)
@@ -105,6 +178,44 @@ module ActiveAgent
105
178
  llm_span.finish
106
179
  span.record_error(e)
107
180
  raise
181
+ ensure
182
+ @_telemetry_llm_span = nil
183
+ end
184
+ end
185
+ end
186
+
187
+ # Providers invoke this proc for every tool call during generation.
188
+ # Wrapping it is what makes tool telemetry real: each call gets a
189
+ # timed span with its arguments and result — the post-hoc
190
+ # result.tool_calls path below never fires on 1.x responses, which
191
+ # don't expose tool calls.
192
+ def tools_function
193
+ base = super
194
+ return base unless Telemetry.enabled?
195
+
196
+ agent = self
197
+ proc do |tool_name, *args, **kwargs|
198
+ parent = agent.instance_variable_get(:@_telemetry_llm_span)
199
+ unless parent
200
+ next base.call(tool_name, *args, **kwargs)
201
+ end
202
+
203
+ tool_span = parent.add_span("tool.#{tool_name}", span_type: :tool)
204
+ tool_span.set_attribute("tool.name", tool_name.to_s)
205
+ arguments = kwargs.presence || (args.length == 1 ? args.first : args.presence)
206
+ if arguments.present?
207
+ tool_span.set_attribute("tool.input.args", agent.send(:telemetry_truncate, JSON.generate(arguments)))
208
+ end
209
+ begin
210
+ result = base.call(tool_name, *args, **kwargs)
211
+ tool_span.set_attribute("tool.output.result", agent.send(:telemetry_truncate, result))
212
+ tool_span.set_status(:ok)
213
+ result
214
+ rescue StandardError => e
215
+ tool_span.record_error(e)
216
+ raise
217
+ ensure
218
+ tool_span.finish
108
219
  end
109
220
  end
110
221
  end
@@ -116,7 +227,7 @@ module ActiveAgent
116
227
  Telemetry.trace("#{self.class.name}.embed", span_type: :embedding) do |span|
117
228
  span.set_attribute("agent.class", self.class.name)
118
229
  span.set_attribute("agent.action", "embed")
119
- span.set_attribute("agent.provider", provider_name) if respond_to?(:provider_name)
230
+ span.set_attribute("agent.provider", provider_name)
120
231
 
121
232
  begin
122
233
  result = super
@@ -138,8 +249,20 @@ module ActiveAgent
138
249
 
139
250
  private
140
251
 
252
+ # Content attributes are capped so a large prompt (e.g. a 100k-token
253
+ # tool loop) can't bloat the trace payload.
254
+ TELEMETRY_ATTRIBUTE_MAX_CHARS = 4_000
255
+
256
+ def telemetry_truncate(value)
257
+ text = value.to_s
258
+ return text if text.length <= TELEMETRY_ATTRIBUTE_MAX_CHARS
259
+
260
+ "#{text[0, TELEMETRY_ATTRIBUTE_MAX_CHARS]}… (truncated, #{text.length} chars total)"
261
+ end
262
+
141
263
  def provider_name
142
- self.class.generation_provider&.to_s || "unknown"
264
+ klass = prompt_provider_klass
265
+ klass.respond_to?(:tag_name) ? klass.tag_name : "unknown"
143
266
  rescue StandardError
144
267
  "unknown"
145
268
  end
@@ -1,175 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "json"
5
- require "uri"
6
-
7
3
  module ActiveAgent
8
4
  module Telemetry
9
- # Asynchronously reports traces to the telemetry endpoint.
10
- #
11
- # Buffers traces and sends them in batches to reduce network overhead.
12
- # Uses a background thread for non-blocking transmission.
5
+ # The framework's reporter, now provided by the activeagents-telemetry
6
+ # gem's BatchingReporter: buffered delivery on batch_size/flush_interval,
7
+ # a blocking flush, and a shutdown that waits out in-flight sends so
8
+ # traces reported near process exit are delivered rather than dropped.
13
9
  #
14
- # @example
15
- # reporter = Reporter.new(configuration)
16
- # reporter.report(trace_payload)
17
- # reporter.flush # Send immediately
18
- # reporter.shutdown # Clean shutdown
10
+ # Local storage is handled by Configuration#local_store, which routes
11
+ # traces through the dashboard's trace model instead of HTTP.
19
12
  #
20
- class Reporter
21
- # @return [Configuration] Telemetry configuration
22
- attr_reader :configuration
23
-
13
+ # @see ActiveAgents::Telemetry::BatchingReporter
14
+ class Reporter < ActiveAgents::Telemetry::BatchingReporter
24
15
  def initialize(configuration)
25
- @configuration = configuration
26
- @buffer = []
27
- @mutex = Mutex.new
28
- @running = false
29
- @thread = nil
30
- @shutdown = false
31
-
32
- start_flush_thread if configuration.enabled?
33
- end
34
-
35
- # Adds a trace to the buffer for transmission.
36
- #
37
- # @param trace [Hash] Trace payload
38
- # @return [void]
39
- def report(trace)
40
- return unless configuration.enabled?
41
-
42
- @mutex.synchronize do
43
- @buffer << trace
44
-
45
- # Flush immediately if buffer is full
46
- if @buffer.size >= configuration.batch_size
47
- flush_buffer
48
- end
49
- end
50
- end
51
-
52
- # Flushes all buffered traces immediately.
53
- #
54
- # @return [void]
55
- def flush
56
- @mutex.synchronize do
57
- flush_buffer
58
- end
59
- end
60
-
61
- # Shuts down the reporter, flushing remaining traces.
62
- #
63
- # @return [void]
64
- def shutdown
65
- @shutdown = true
66
- flush
67
- @thread&.join(5) # Wait up to 5 seconds for thread to finish
68
- end
69
-
70
- private
71
-
72
- # Starts the background flush thread.
73
- def start_flush_thread
74
- @running = true
75
- @thread = Thread.new do
76
- Thread.current.name = "activeagent-telemetry-reporter"
77
-
78
- while @running && !@shutdown
79
- sleep(configuration.flush_interval)
80
-
81
- @mutex.synchronize do
82
- flush_buffer if @buffer.any?
83
- end
84
- end
85
- end
86
- end
87
-
88
- # Flushes the buffer by sending traces to the endpoint.
89
- #
90
- # Must be called within @mutex synchronization.
91
- def flush_buffer
92
- return if @buffer.empty?
93
-
94
- traces = @buffer.dup
95
- @buffer.clear
96
-
97
- Thread.new { send_traces(traces) }
98
- end
99
-
100
- # Sends traces to the configured endpoint.
101
- #
102
- # @param traces [Array<Hash>] Traces to send
103
- def send_traces(traces)
104
- # Use direct database storage for local mode
105
- if configuration.local_storage?
106
- store_traces_locally(traces)
107
- return
108
- end
109
-
110
- uri = URI.parse(configuration.endpoint)
111
-
112
- http = Net::HTTP.new(uri.host, uri.port)
113
- http.use_ssl = uri.scheme == "https"
114
- http.open_timeout = configuration.timeout
115
- http.read_timeout = configuration.timeout
116
-
117
- request = Net::HTTP::Post.new(uri.request_uri)
118
- request["Content-Type"] = "application/json"
119
- request["Authorization"] = "Bearer #{configuration.api_key}"
120
- request["User-Agent"] = "ActiveAgent/#{ActiveAgent::VERSION} Ruby/#{RUBY_VERSION}"
121
- request["X-Service-Name"] = configuration.resolved_service_name
122
- request["X-Environment"] = configuration.environment
123
-
124
- payload = {
125
- traces: traces,
126
- sdk: {
127
- name: "activeagent",
128
- version: ActiveAgent::VERSION,
129
- language: "ruby",
130
- runtime_version: RUBY_VERSION
131
- }
132
- }
133
-
134
- request.body = JSON.generate(payload)
135
-
136
- response = http.request(request)
137
-
138
- unless response.is_a?(Net::HTTPSuccess)
139
- log_error("Failed to send traces: #{response.code} #{response.message}")
140
- end
141
- rescue StandardError => e
142
- log_error("Error sending traces: #{e.class} - #{e.message}")
143
- end
144
-
145
- # Stores traces directly in the local database.
146
- #
147
- # @param traces [Array<Hash>] Traces to store
148
- def store_traces_locally(traces)
149
- sdk_info = {
150
- name: "activeagent",
151
- version: ActiveAgent::VERSION,
152
- language: "ruby",
153
- runtime_version: RUBY_VERSION
154
- }
155
-
156
- traces.each do |trace|
157
- # Skip if trace already exists (idempotency)
158
- next if ActiveAgent::TelemetryTrace.exists?(trace_id: trace["trace_id"])
159
-
160
- ActiveAgent::TelemetryTrace.create_from_payload(trace, sdk_info)
161
- rescue StandardError => e
162
- log_error("Failed to store trace locally: #{e.class} - #{e.message}")
163
- end
164
- rescue StandardError => e
165
- log_error("Error storing traces locally: #{e.class} - #{e.message}")
166
- end
167
-
168
- # Logs an error message.
169
- #
170
- # @param message [String] Error message
171
- def log_error(message)
172
- configuration.resolved_logger.error("[ActiveAgent::Telemetry] #{message}")
16
+ # sample: false — the Tracer applies head-based sampling at trace
17
+ # creation, before any span is built; sampling again here would
18
+ # compound the rate to rate².
19
+ super(configuration, sdk_name: "activeagent", sdk_version: ActiveAgent::VERSION, sample: false)
173
20
  end
174
21
  end
175
22
  end
@@ -2,103 +2,28 @@
2
2
 
3
3
  module ActiveAgent
4
4
  module Telemetry
5
- # Represents a single span in a trace.
5
+ # The framework's span, now provided by the activeagents-telemetry gem.
6
+ # This subclass translates the framework's historical constructor
7
+ # (span_type: keyword, attributes as a keyword splat) onto the shared
8
+ # class; everything else — set_attribute, set_tokens, set_status,
9
+ # record_error, add_span, measure, finish — is inherited.
6
10
  #
7
- # Spans capture discrete operations within a trace, such as LLM calls,
8
- # tool invocations, or prompt rendering. Each span has timing, attributes,
9
- # and can have child spans.
11
+ # One deliberate change rides along from the shared core: record_error
12
+ # truncates the message and no longer puts a backtrace on the wire.
10
13
  #
11
- # @example Creating a span
12
- # span = Span.new("llm.generate", trace_id: trace.trace_id)
13
- # span.set_attribute("provider", "anthropic")
14
- # span.set_attribute("model", "claude-3-5-sonnet")
15
- # span.set_tokens(input: 100, output: 50)
16
- # span.finish
17
- #
18
- class Span
19
- # Span types for categorization
20
- TYPES = {
21
- root: "root", # Root span for entire generation
22
- prompt: "prompt", # Prompt preparation/rendering
23
- llm: "llm", # LLM API call
24
- tool: "tool", # Tool invocation
25
- thinking: "thinking", # Extended thinking (Anthropic)
26
- embedding: "embedding", # Embedding generation
27
- error: "error" # Error handling
28
- }.freeze
29
-
30
- # Span status codes
31
- STATUS = {
32
- unset: "UNSET",
33
- ok: "OK",
34
- error: "ERROR"
35
- }.freeze
36
-
37
- # @return [String] Unique identifier for this span
38
- attr_reader :span_id
39
-
40
- # @return [String] Trace ID this span belongs to
41
- attr_reader :trace_id
42
-
43
- # @return [String, nil] Parent span ID
44
- attr_reader :parent_span_id
45
-
46
- # @return [String] Span name (e.g., "llm.generate", "tool.get_weather")
47
- attr_reader :name
48
-
49
- # @return [String] Span type from TYPES
50
- attr_reader :span_type
51
-
52
- # @return [Time] When the span started
53
- attr_reader :start_time
54
-
55
- # @return [Time, nil] When the span ended
56
- attr_reader :end_time
57
-
58
- # @return [Hash] Span attributes
59
- attr_reader :attributes
60
-
61
- # @return [Array<Span>] Child spans
62
- attr_reader :children
63
-
64
- # @return [String] Status code from STATUS
65
- attr_reader :status
66
-
67
- # @return [String, nil] Status message
68
- attr_reader :status_message
69
-
70
- # @return [Array<Hash>] Events recorded during the span
71
- attr_reader :events
72
-
73
- # Creates a new span.
74
- #
75
- # @param name [String] Span name
76
- # @param trace_id [String] Parent trace ID
77
- # @param parent_span_id [String, nil] Parent span ID
78
- # @param span_type [Symbol] Type of span
79
- # @param attributes [Hash] Initial attributes
14
+ # @see ActiveAgents::Telemetry::Span
15
+ class Span < ActiveAgents::Telemetry::Span
80
16
  def initialize(name, trace_id:, parent_span_id: nil, span_type: :root, **attributes)
81
- @span_id = SecureRandom.hex(8)
82
- @trace_id = trace_id
83
- @parent_span_id = parent_span_id
84
- @name = name
85
- @span_type = TYPES[span_type] || span_type.to_s
86
- @start_time = Time.current
87
- @end_time = nil
88
- @attributes = attributes.transform_keys(&:to_s)
89
- @children = []
90
- @status = STATUS[:unset]
91
- @status_message = nil
92
- @events = []
93
- @tokens = { input: 0, output: 0, thinking: 0, total: 0 }
17
+ super(
18
+ name,
19
+ type: span_type,
20
+ trace_id: trace_id,
21
+ parent_span_id: parent_span_id,
22
+ attributes: attributes
23
+ )
94
24
  end
95
25
 
96
- # Creates a child span.
97
- #
98
- # @param name [String] Child span name
99
- # @param span_type [Symbol] Type of span
100
- # @param attributes [Hash] Span attributes
101
- # @return [Span] The child span
26
+ # Creates a child span, keeping the framework's keyword shape.
102
27
  def add_span(name, span_type: :root, **attributes)
103
28
  child = Span.new(
104
29
  name,
@@ -107,161 +32,9 @@ module ActiveAgent
107
32
  span_type: span_type,
108
33
  **attributes
109
34
  )
110
- @children << child
35
+ children << child
111
36
  child
112
37
  end
113
-
114
- # Sets a single attribute.
115
- #
116
- # @param key [String, Symbol] Attribute key
117
- # @param value [Object] Attribute value
118
- # @return [self]
119
- def set_attribute(key, value)
120
- @attributes[key.to_s] = value
121
- self
122
- end
123
-
124
- # Sets multiple attributes at once.
125
- #
126
- # @param attrs [Hash] Attributes to set
127
- # @return [self]
128
- def set_attributes(attrs)
129
- attrs.each { |k, v| set_attribute(k, v) }
130
- self
131
- end
132
-
133
- # Sets token usage for LLM spans.
134
- #
135
- # @param input [Integer] Input token count
136
- # @param output [Integer] Output token count
137
- # @param thinking [Integer] Thinking token count (Anthropic extended thinking)
138
- # @return [self]
139
- def set_tokens(input: 0, output: 0, thinking: 0)
140
- @tokens = {
141
- input: input,
142
- output: output,
143
- thinking: thinking,
144
- total: input + output + thinking
145
- }
146
- set_attribute("tokens.input", input)
147
- set_attribute("tokens.output", output)
148
- set_attribute("tokens.thinking", thinking) if thinking > 0
149
- set_attribute("tokens.total", @tokens[:total])
150
- self
151
- end
152
-
153
- # Returns token usage.
154
- #
155
- # @return [Hash] Token counts
156
- def tokens
157
- @tokens.dup
158
- end
159
-
160
- # Sets the span status.
161
- #
162
- # @param code [Symbol] Status code (:ok, :error, :unset)
163
- # @param message [String, nil] Optional status message
164
- # @return [self]
165
- def set_status(code, message = nil)
166
- @status = STATUS[code] || STATUS[:unset]
167
- @status_message = message
168
- self
169
- end
170
-
171
- # Records an error on the span.
172
- #
173
- # @param error [Exception] The error to record
174
- # @return [self]
175
- def record_error(error)
176
- set_status(:error, error.message)
177
- set_attribute("error.type", error.class.name)
178
- set_attribute("error.message", error.message)
179
- set_attribute("error.backtrace", error.backtrace&.first(10)&.join("\n"))
180
-
181
- add_event("exception", {
182
- "exception.type" => error.class.name,
183
- "exception.message" => error.message,
184
- "exception.stacktrace" => error.backtrace&.join("\n")
185
- })
186
-
187
- self
188
- end
189
-
190
- # Adds an event to the span.
191
- #
192
- # @param name [String] Event name
193
- # @param attributes [Hash] Event attributes
194
- # @return [self]
195
- def add_event(name, attributes = {})
196
- @events << {
197
- name: name,
198
- timestamp: Time.current.iso8601(6),
199
- attributes: attributes.transform_keys(&:to_s)
200
- }
201
- self
202
- end
203
-
204
- # Marks the span as finished.
205
- #
206
- # @return [self]
207
- def finish
208
- @end_time = Time.current
209
- set_status(:ok) if @status == STATUS[:unset]
210
- self
211
- end
212
-
213
- # Returns whether the span is finished.
214
- #
215
- # @return [Boolean]
216
- def finished?
217
- !@end_time.nil?
218
- end
219
-
220
- # Returns the duration in milliseconds.
221
- #
222
- # @return [Float, nil] Duration or nil if not finished
223
- def duration_ms
224
- return nil unless finished?
225
-
226
- ((@end_time - @start_time) * 1000).round(2)
227
- end
228
-
229
- # Serializes the span for transmission.
230
- #
231
- # @return [Hash] Serialized span data
232
- def to_h
233
- {
234
- span_id: span_id,
235
- trace_id: trace_id,
236
- parent_span_id: parent_span_id,
237
- name: name,
238
- type: span_type,
239
- start_time: start_time.iso8601(6),
240
- end_time: end_time&.iso8601(6),
241
- duration_ms: duration_ms,
242
- status: status,
243
- status_message: status_message,
244
- attributes: attributes,
245
- tokens: tokens,
246
- events: events,
247
- children: children.map(&:to_h)
248
- }
249
- end
250
-
251
- # Executes a block and records timing/errors.
252
- #
253
- # @yield Block to execute within the span
254
- # @return [Object] Result of the block
255
- def measure
256
- result = yield
257
- set_status(:ok)
258
- result
259
- rescue StandardError => e
260
- record_error(e)
261
- raise
262
- ensure
263
- finish
264
- end
265
38
  end
266
39
  end
267
40
  end