activeagent 1.0.1 → 1.0.2

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -4
  3. data/lib/active_agent/base.rb +3 -2
  4. data/lib/active_agent/concerns/provider.rb +6 -2
  5. data/lib/active_agent/concerns/rescue.rb +39 -0
  6. data/lib/active_agent/concerns/streaming.rb +2 -1
  7. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +117 -0
  8. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +54 -0
  9. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +126 -0
  10. data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +103 -0
  11. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +56 -0
  12. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +14 -0
  13. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +49 -0
  14. data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +65 -0
  15. data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +77 -0
  16. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +256 -0
  17. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +113 -0
  18. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +208 -0
  19. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +60 -0
  20. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +46 -0
  21. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +125 -0
  22. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +83 -0
  23. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +52 -0
  24. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +169 -0
  25. data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +193 -0
  26. data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +198 -0
  27. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +105 -0
  28. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +135 -0
  29. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +143 -0
  30. data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +36 -0
  31. data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +94 -0
  32. data/lib/active_agent/dashboard/config/routes.rb +78 -0
  33. data/lib/active_agent/dashboard/engine.rb +39 -0
  34. data/lib/active_agent/dashboard.rb +151 -0
  35. data/lib/active_agent/providers/_base_provider.rb +2 -1
  36. data/lib/active_agent/providers/anthropic_provider.rb +14 -4
  37. data/lib/active_agent/providers/azure/_types.rb +5 -0
  38. data/lib/active_agent/providers/azure/options.rb +111 -0
  39. data/lib/active_agent/providers/azure_open_ai_provider.rb +2 -0
  40. data/lib/active_agent/providers/azure_openai_provider.rb +2 -0
  41. data/lib/active_agent/providers/azure_provider.rb +133 -0
  42. data/lib/active_agent/providers/azureopenai_provider.rb +2 -0
  43. data/lib/active_agent/providers/bedrock/_types.rb +8 -0
  44. data/lib/active_agent/providers/bedrock/bearer_client.rb +109 -0
  45. data/lib/active_agent/providers/bedrock/options.rb +77 -0
  46. data/lib/active_agent/providers/bedrock_provider.rb +84 -0
  47. data/lib/active_agent/providers/common/messages/_types.rb +6 -2
  48. data/lib/active_agent/providers/concerns/exception_handler.rb +1 -0
  49. data/lib/active_agent/providers/gemini/_types.rb +19 -0
  50. data/lib/active_agent/providers/gemini/options.rb +41 -0
  51. data/lib/active_agent/providers/gemini_provider.rb +94 -0
  52. data/lib/active_agent/providers/open_ai/chat/transforms.rb +37 -1
  53. data/lib/active_agent/providers/open_ai/chat_provider.rb +2 -0
  54. data/lib/active_agent/providers/ruby_llm/_types.rb +77 -0
  55. data/lib/active_agent/providers/ruby_llm/embedding_request.rb +16 -0
  56. data/lib/active_agent/providers/ruby_llm/messages/_types.rb +109 -0
  57. data/lib/active_agent/providers/ruby_llm/messages/assistant.rb +27 -0
  58. data/lib/active_agent/providers/ruby_llm/messages/base.rb +48 -0
  59. data/lib/active_agent/providers/ruby_llm/messages/system.rb +18 -0
  60. data/lib/active_agent/providers/ruby_llm/messages/tool.rb +24 -0
  61. data/lib/active_agent/providers/ruby_llm/messages/user.rb +18 -0
  62. data/lib/active_agent/providers/ruby_llm/options.rb +28 -0
  63. data/lib/active_agent/providers/ruby_llm/request.rb +30 -0
  64. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +45 -0
  65. data/lib/active_agent/providers/ruby_llm_provider.rb +407 -0
  66. data/lib/active_agent/railtie.rb +32 -1
  67. data/lib/active_agent/telemetry/configuration.rb +213 -0
  68. data/lib/active_agent/telemetry/instrumentation.rb +155 -0
  69. data/lib/active_agent/telemetry/reporter.rb +176 -0
  70. data/lib/active_agent/telemetry/span.rb +267 -0
  71. data/lib/active_agent/telemetry/tracer.rb +184 -0
  72. data/lib/active_agent/telemetry.rb +162 -0
  73. data/lib/active_agent/version.rb +1 -1
  74. data/lib/active_agent.rb +2 -0
  75. data/lib/generators/active_agent/dashboard/install/install_generator.rb +96 -0
  76. data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +89 -0
  77. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +42 -0
  78. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +38 -0
  79. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +22 -0
  80. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +53 -0
  81. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +28 -0
  82. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +43 -0
  83. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +44 -0
  84. data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +56 -0
  85. data/lib/generators/active_agent/dashboard/install_generator.rb +64 -0
  86. data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +30 -0
  87. data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +30 -0
  88. metadata +99 -13
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Telemetry
5
+ # Auto-instrumentation for ActiveAgent generation lifecycle.
6
+ #
7
+ # When included in ActiveAgent::Base, automatically traces:
8
+ # - Agent generation (prompt_now, generate_now)
9
+ # - Tool calls
10
+ # - Streaming events
11
+ # - Errors
12
+ #
13
+ # @example Enabling instrumentation
14
+ # # In config/initializers/activeagent.rb
15
+ # ActiveAgent::Telemetry.configure do |config|
16
+ # config.enabled = true
17
+ # config.endpoint = "https://api.activeagents.ai/v1/traces"
18
+ # config.api_key = Rails.application.credentials.activeagents_api_key
19
+ # end
20
+ #
21
+ # # Instrumentation is automatically applied when telemetry is enabled
22
+ #
23
+ module Instrumentation
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ # Hook into generation lifecycle
28
+ around_generate :trace_generation if respond_to?(:around_generate)
29
+ end
30
+
31
+ class_methods do
32
+ # Installs instrumentation on the agent class.
33
+ #
34
+ # Called automatically when telemetry is enabled.
35
+ def instrument_telemetry!
36
+ return if @telemetry_instrumented
37
+
38
+ prepend GenerationInstrumentation
39
+ @telemetry_instrumented = true
40
+ end
41
+ end
42
+
43
+ # Module prepended to intercept generation methods.
44
+ module GenerationInstrumentation
45
+ # Wraps process_prompt with telemetry tracing.
46
+ def process_prompt
47
+ return super unless Telemetry.enabled?
48
+
49
+ Telemetry.trace("#{self.class.name}.#{action_name}", span_type: :root) do |span|
50
+ span.set_attribute("agent.class", self.class.name)
51
+ 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)
54
+
55
+ # Add prompt span
56
+ prompt_span = span.add_span("agent.prompt", span_type: :prompt)
57
+ prompt_span.set_attribute("messages.count", messages.size) if respond_to?(:messages)
58
+ prompt_span.finish
59
+
60
+ # Execute generation with LLM span
61
+ 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)
64
+
65
+ begin
66
+ result = super
67
+
68
+ # Record token usage from response
69
+ if result.respond_to?(:usage) && result.usage.present?
70
+ usage = result.usage
71
+ # Usage model uses methods, not hash access
72
+ input_tokens = (usage.input_tokens rescue 0) || 0
73
+ output_tokens = (usage.output_tokens rescue 0) || 0
74
+ reasoning_tokens = (usage.reasoning_tokens rescue 0) || 0
75
+
76
+ llm_span.set_tokens(
77
+ input: input_tokens.to_i,
78
+ output: output_tokens.to_i,
79
+ thinking: reasoning_tokens.to_i
80
+ )
81
+ span.set_tokens(
82
+ input: input_tokens.to_i,
83
+ output: output_tokens.to_i,
84
+ thinking: reasoning_tokens.to_i
85
+ )
86
+ end
87
+
88
+ # Record tool calls if present
89
+ if result.respond_to?(:tool_calls) && result.tool_calls.present?
90
+ result.tool_calls.each do |tool_call|
91
+ tool_span = span.add_span("tool.#{tool_call[:name]}", span_type: :tool)
92
+ tool_span.set_attribute("tool.name", tool_call[:name])
93
+ tool_span.set_attribute("tool.id", tool_call[:id]) if tool_call[:id]
94
+ tool_span.finish
95
+ end
96
+ end
97
+
98
+ llm_span.set_status(:ok)
99
+ llm_span.finish
100
+ span.set_status(:ok)
101
+
102
+ result
103
+ rescue StandardError => e
104
+ llm_span.record_error(e)
105
+ llm_span.finish
106
+ span.record_error(e)
107
+ raise
108
+ end
109
+ end
110
+ end
111
+
112
+ # Wraps process_embed with telemetry tracing.
113
+ def process_embed
114
+ return super unless Telemetry.enabled?
115
+
116
+ Telemetry.trace("#{self.class.name}.embed", span_type: :embedding) do |span|
117
+ span.set_attribute("agent.class", self.class.name)
118
+ span.set_attribute("agent.action", "embed")
119
+ span.set_attribute("agent.provider", provider_name) if respond_to?(:provider_name)
120
+
121
+ begin
122
+ result = super
123
+
124
+ if result.respond_to?(:usage) && result.usage.present?
125
+ usage = result.usage
126
+ input_tokens = (usage.input_tokens rescue 0) || 0
127
+ span.set_tokens(input: input_tokens.to_i)
128
+ end
129
+
130
+ span.set_status(:ok)
131
+ result
132
+ rescue StandardError => e
133
+ span.record_error(e)
134
+ raise
135
+ end
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def provider_name
142
+ self.class.generation_provider&.to_s || "unknown"
143
+ rescue StandardError
144
+ "unknown"
145
+ end
146
+
147
+ def model_name
148
+ prompt_options[:model] || "unknown"
149
+ rescue StandardError
150
+ "unknown"
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module ActiveAgent
8
+ 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.
13
+ #
14
+ # @example
15
+ # reporter = Reporter.new(configuration)
16
+ # reporter.report(trace_payload)
17
+ # reporter.flush # Send immediately
18
+ # reporter.shutdown # Clean shutdown
19
+ #
20
+ class Reporter
21
+ # @return [Configuration] Telemetry configuration
22
+ attr_reader :configuration
23
+
24
+ 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}")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Telemetry
5
+ # Represents a single span in a trace.
6
+ #
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.
10
+ #
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
80
+ 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 }
94
+ end
95
+
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
102
+ def add_span(name, span_type: :root, **attributes)
103
+ child = Span.new(
104
+ name,
105
+ trace_id: trace_id,
106
+ parent_span_id: span_id,
107
+ span_type: span_type,
108
+ **attributes
109
+ )
110
+ @children << child
111
+ child
112
+ 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
+ end
266
+ end
267
+ end