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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Dashboard
5
+ # Manages sandbox execution sessions for agent runs.
6
+ #
7
+ # Sandbox sessions provide isolated execution environments for running
8
+ # agents with tools like browser automation, file system access, etc.
9
+ #
10
+ # Supports both local (Docker/Incus) and cloud (Cloud Run) sandbox providers.
11
+ #
12
+ # @example Creating a sandbox session
13
+ # session = ActiveAgent::Dashboard::SandboxSession.create!(
14
+ # sandbox_type: "playwright_mcp"
15
+ # )
16
+ # session.provision!
17
+ #
18
+ class SandboxSession < ApplicationRecord
19
+ # Owner associations - optional to support anonymous users
20
+ belongs_to :user, class_name: ActiveAgent::Dashboard.user_class, optional: true if ActiveAgent::Dashboard.user_class
21
+ belongs_to :account, class_name: ActiveAgent::Dashboard.account_class, optional: true if ActiveAgent::Dashboard.multi_tenant?
22
+ belongs_to :agent_template, class_name: "ActiveAgent::Dashboard::AgentTemplate", optional: true
23
+
24
+ has_many :sandbox_runs, class_name: "ActiveAgent::Dashboard::SandboxRun", dependent: :destroy
25
+ has_one :session_recording, class_name: "ActiveAgent::Dashboard::SessionRecording", dependent: :nullify
26
+
27
+ # Session statuses
28
+ enum :status, {
29
+ pending: 0,
30
+ provisioning: 1,
31
+ ready: 2,
32
+ running: 3,
33
+ completed: 4,
34
+ expired: 5,
35
+ failed: 6
36
+ }
37
+
38
+ # Sandbox types
39
+ SANDBOX_TYPES = %w[playwright_mcp terminal research].freeze
40
+
41
+ # Default limits (can be overridden by platform tier limits)
42
+ DEFAULT_LIMITS = {
43
+ max_runs: 10,
44
+ timeout_seconds: 300,
45
+ max_tokens: 50_000,
46
+ session_duration_minutes: 15
47
+ }.freeze
48
+
49
+ # Validations
50
+ validates :session_id, presence: true, uniqueness: true
51
+ validates :sandbox_type, inclusion: { in: SANDBOX_TYPES }
52
+
53
+ # Callbacks
54
+ before_validation :generate_session_id, on: :create
55
+ before_create :set_defaults
56
+
57
+ # Scopes
58
+ scope :active, -> { where(status: [ :pending, :provisioning, :ready, :running ]) }
59
+ scope :expired_sessions, -> { where("expires_at < ?", Time.current) }
60
+ scope :by_type, ->(type) { where(sandbox_type: type) }
61
+ scope :anonymous, -> { where(user_id: nil) }
62
+ scope :recent, -> { order(created_at: :desc) }
63
+
64
+ # Check if session is still valid
65
+ def active?
66
+ !expired? && !failed? && !completed? && expires_at > Time.current
67
+ end
68
+
69
+ # Check if can run more tasks
70
+ def can_run?
71
+ active? && runs_count < max_runs
72
+ end
73
+
74
+ # Record a new run (thread-safe for parallel execution)
75
+ def record_run!(task:, result:, duration_ms:, tokens:, screenshots: [], provider: nil)
76
+ run = {
77
+ id: SecureRandom.uuid,
78
+ task: task,
79
+ result: result,
80
+ duration_ms: duration_ms,
81
+ tokens: tokens,
82
+ screenshots: screenshots,
83
+ provider: provider,
84
+ status: "completed",
85
+ created_at: Time.current.iso8601
86
+ }
87
+
88
+ with_lock do
89
+ reload
90
+ self.runs = runs + [ run ]
91
+ self.runs_count = runs.size
92
+ self.total_tokens += tokens
93
+ self.total_duration_ms += duration_ms
94
+ self.last_activity_at = Time.current
95
+ save!
96
+ end
97
+
98
+ run
99
+ end
100
+
101
+ # Provision the sandbox
102
+ def provision!
103
+ return if provisioning? || ready?
104
+
105
+ update!(status: :provisioning)
106
+
107
+ # Use configured sandbox service
108
+ if Rails.env.development? || Rails.env.test?
109
+ ActiveAgent::Dashboard::SandboxProvisionJob.perform_now(id)
110
+ else
111
+ ActiveAgent::Dashboard::SandboxProvisionJob.perform_later(id)
112
+ end
113
+ end
114
+
115
+ # Mark as ready with sandbox URL
116
+ def mark_ready!(sandbox_url:, sandbox_job_id: nil)
117
+ update!(
118
+ status: :ready,
119
+ cloud_run_url: sandbox_url,
120
+ cloud_run_job_id: sandbox_job_id
121
+ )
122
+ end
123
+
124
+ # Expire the session
125
+ def expire!
126
+ update!(status: :expired)
127
+ ActiveAgent::Dashboard::SandboxCleanupJob.perform_later(id) if cloud_run_job_id.present?
128
+ end
129
+
130
+ # Summary for API responses
131
+ def summary
132
+ {
133
+ id: id,
134
+ session_id: session_id,
135
+ sandbox_type: sandbox_type,
136
+ status: status,
137
+ runs_count: runs_count,
138
+ max_runs: max_runs,
139
+ total_tokens: total_tokens,
140
+ expires_at: expires_at&.iso8601,
141
+ created_at: created_at.iso8601,
142
+ cloud_run_url: cloud_run_url
143
+ }
144
+ end
145
+
146
+ # Detailed info including runs
147
+ def details
148
+ summary.merge(
149
+ runs: runs,
150
+ total_duration_ms: total_duration_ms,
151
+ last_activity_at: last_activity_at&.iso8601
152
+ )
153
+ end
154
+
155
+ private
156
+
157
+ def generate_session_id
158
+ self.session_id ||= SecureRandom.uuid
159
+ end
160
+
161
+ def set_defaults
162
+ limits = ActiveAgent::Dashboard.sandbox_limits || DEFAULT_LIMITS
163
+ self.expires_at ||= limits[:session_duration_minutes].minutes.from_now
164
+ self.max_runs ||= limits[:max_runs]
165
+ self.timeout_seconds ||= limits[:timeout_seconds]
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Dashboard
5
+ # Records browser sessions for playback and analysis.
6
+ #
7
+ # Session recordings capture the sequence of actions taken during
8
+ # an agent run or sandbox session, including screenshots, DOM snapshots,
9
+ # and timing information.
10
+ #
11
+ # @example Starting a recording
12
+ # recording = ActiveAgent::Dashboard::SessionRecording.start!(
13
+ # agent_run: run,
14
+ # name: "checkout_flow"
15
+ # )
16
+ #
17
+ # @example Recording an action
18
+ # recording.record_action!(
19
+ # action_type: "click",
20
+ # selector: "button.submit",
21
+ # screenshot: screenshot_data
22
+ # )
23
+ #
24
+ class SessionRecording < ApplicationRecord
25
+ belongs_to :agent_run, class_name: "ActiveAgent::Dashboard::AgentRun", optional: true
26
+ belongs_to :sandbox_session, class_name: "ActiveAgent::Dashboard::SandboxSession", optional: true
27
+
28
+ has_many :recording_actions, class_name: "ActiveAgent::Dashboard::RecordingAction", dependent: :destroy
29
+ has_many :recording_snapshots, class_name: "ActiveAgent::Dashboard::RecordingSnapshot", dependent: :destroy
30
+
31
+ enum :status, { recording: 0, completed: 1, failed: 2 }
32
+
33
+ validates :status, presence: true
34
+ validate :must_have_parent, unless: -> { demo_recording? || user_session? }
35
+
36
+ scope :recent, -> { order(created_at: :desc) }
37
+ scope :for_agent, ->(agent_id) { joins(:agent_run).where(agent_runs: { agent_id: agent_id }) }
38
+ scope :demo, -> { where(name: "lander_demo") }
39
+ scope :user_sessions, -> { where("name LIKE ?", "user_takeover_%") }
40
+
41
+ # Check if this is a demo recording (doesn't require parent)
42
+ def demo_recording?
43
+ name&.start_with?("lander_") || name == "demo"
44
+ end
45
+
46
+ # Check if this is a user takeover session (doesn't require parent)
47
+ def user_session?
48
+ name&.start_with?("user_takeover_")
49
+ end
50
+
51
+ # Start a new recording session
52
+ def self.start!(agent_run: nil, sandbox_session: nil, name: nil)
53
+ create!(
54
+ agent_run: agent_run,
55
+ sandbox_session: sandbox_session,
56
+ name: name || generate_name(agent_run, sandbox_session),
57
+ status: :recording,
58
+ metadata: { started_at: Time.current.iso8601 }
59
+ )
60
+ end
61
+
62
+ # Start a user takeover session (for lander demo analytics)
63
+ def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil)
64
+ create!(
65
+ name: "user_takeover_#{Time.current.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}",
66
+ status: :recording,
67
+ metadata: {
68
+ started_at: Time.current.iso8601,
69
+ session_type: "user_takeover",
70
+ visitor_id: visitor_id,
71
+ parent_demo_id: parent_demo_id,
72
+ page_url: page_url,
73
+ user_agent: nil
74
+ }
75
+ )
76
+ end
77
+
78
+ # Record a browser action
79
+ def record_action!(action_type:, selector: nil, value: nil, screenshot: nil, dom_snapshot: nil, metadata: {})
80
+ raise "Recording already completed" unless recording?
81
+
82
+ action = recording_actions.create!(
83
+ action_type: action_type,
84
+ sequence: next_sequence,
85
+ timestamp_ms: elapsed_ms,
86
+ selector: selector,
87
+ value: value,
88
+ metadata: metadata
89
+ )
90
+
91
+ if screenshot.present?
92
+ snapshot = store_snapshot(screenshot, :screenshot, action)
93
+ action.update!(screenshot_key: snapshot.storage_key)
94
+ end
95
+
96
+ if dom_snapshot.present?
97
+ snapshot = store_snapshot(dom_snapshot, :dom, action)
98
+ action.update!(dom_snapshot_key: snapshot.storage_key)
99
+ end
100
+
101
+ increment!(:action_count)
102
+ action
103
+ end
104
+
105
+ # Complete the recording
106
+ def complete!
107
+ return unless recording?
108
+
109
+ update!(
110
+ status: :completed,
111
+ duration_ms: elapsed_ms,
112
+ metadata: metadata.merge(completed_at: Time.current.iso8601)
113
+ )
114
+ end
115
+
116
+ # Mark recording as failed
117
+ def fail!(error_message = nil)
118
+ return unless recording?
119
+
120
+ update!(
121
+ status: :failed,
122
+ duration_ms: elapsed_ms,
123
+ metadata: metadata.merge(
124
+ failed_at: Time.current.iso8601,
125
+ error: error_message
126
+ )
127
+ )
128
+ end
129
+
130
+ # Get timeline data for playback
131
+ def timeline
132
+ recording_actions.order(:sequence).map do |action|
133
+ {
134
+ id: action.id,
135
+ type: action.action_type,
136
+ sequence: action.sequence,
137
+ timestamp_ms: action.timestamp_ms,
138
+ selector: action.selector,
139
+ value: action.value,
140
+ screenshot_key: action.screenshot_key,
141
+ metadata: action.metadata
142
+ }
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ def must_have_parent
149
+ return if agent_run.present? || sandbox_session.present?
150
+
151
+ errors.add(:base, "must belong to an agent_run or sandbox_session")
152
+ end
153
+
154
+ def self.generate_name(agent_run, sandbox_session)
155
+ prefix = if agent_run&.agent
156
+ agent_run.agent.name.parameterize
157
+ elsif sandbox_session&.agent_template
158
+ sandbox_session.agent_template.name.parameterize
159
+ else
160
+ "session"
161
+ end
162
+
163
+ "#{prefix}_#{Time.current.strftime('%Y%m%d_%H%M%S')}"
164
+ end
165
+
166
+ def next_sequence
167
+ (recording_actions.maximum(:sequence) || 0) + 1
168
+ end
169
+
170
+ def elapsed_ms
171
+ ((Time.current - created_at) * 1000).to_i
172
+ end
173
+
174
+ def store_snapshot(data, snapshot_type, action = nil)
175
+ storage_key = generate_storage_key(snapshot_type, action&.sequence)
176
+
177
+ recording_snapshots.create!(
178
+ recording_action: action,
179
+ storage_key: storage_key,
180
+ snapshot_type: snapshot_type,
181
+ file_size_bytes: data.bytesize
182
+ )
183
+ end
184
+
185
+ def generate_storage_key(snapshot_type, sequence = nil)
186
+ parts = [ "recordings", id, snapshot_type.to_s ]
187
+ parts << sequence.to_s if sequence
188
+ parts << SecureRandom.hex(8)
189
+ parts.join("/")
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ # Stores telemetry traces from ActiveAgent clients.
5
+ #
6
+ # Each trace represents a complete generation lifecycle, including prompt
7
+ # preparation, LLM calls, tool invocations, and error handling.
8
+ #
9
+ # This model supports two modes:
10
+ # - Local mode: No account association (single-tenant, self-hosted)
11
+ # - Multi-tenant mode: With account association (for activeagents.ai platform)
12
+ #
13
+ # @example Creating a trace from ingested data (local mode)
14
+ # ActiveAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info)
15
+ #
16
+ # @example Creating a trace with account (multi-tenant mode)
17
+ # ActiveAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info, account: account)
18
+ #
19
+ class TelemetryTrace < ::ActiveRecord::Base
20
+ self.table_name = "active_agent_telemetry_traces"
21
+
22
+ # Optional account association for multi-tenant mode
23
+ # The host app can add: belongs_to :account if needed
24
+ if ActiveAgent::Dashboard.multi_tenant?
25
+ belongs_to :account, class_name: ActiveAgent::Dashboard.account_class
26
+ end
27
+
28
+ # Status values for traces
29
+ STATUS_OK = "OK"
30
+ STATUS_ERROR = "ERROR"
31
+ STATUS_UNSET = "UNSET"
32
+
33
+ validates :trace_id, presence: true
34
+
35
+ # Scopes
36
+ scope :recent, -> { order(timestamp: :desc) }
37
+ scope :with_errors, -> { where(status: STATUS_ERROR) }
38
+ scope :for_service, ->(name) { where(service_name: name) }
39
+ scope :for_environment, ->(env) { where(environment: env) }
40
+ scope :for_agent, ->(agent_class) { where(agent_class: agent_class) }
41
+ scope :for_date_range, ->(start_date, end_date) { where(timestamp: start_date..end_date) }
42
+ scope :for_account, ->(account) { where(account: account) if ActiveAgent::Dashboard.multi_tenant? }
43
+
44
+ # Creates a TelemetryTrace from an ingested trace payload.
45
+ #
46
+ # Extracts relevant data from the trace payload and stores it in a
47
+ # normalized format for querying and analysis.
48
+ #
49
+ # @param trace [Hash] The trace payload from ActiveAgent::Telemetry
50
+ # @param sdk_info [Hash] SDK metadata
51
+ # @param account [Object, nil] Optional account for multi-tenant mode
52
+ # @return [TelemetryTrace] The created trace
53
+ def self.create_from_payload(trace, sdk_info = {}, account: nil)
54
+ spans = trace["spans"] || []
55
+ root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
56
+
57
+ # Calculate totals from all spans
58
+ total_duration = root_span["duration_ms"]
59
+ total_input = 0
60
+ total_output = 0
61
+ total_thinking = 0
62
+
63
+ spans.each do |span|
64
+ tokens = span["tokens"] || {}
65
+ total_input += (tokens["input"] || 0)
66
+ total_output += (tokens["output"] || 0)
67
+ total_thinking += (tokens["thinking"] || 0)
68
+ end
69
+
70
+ # Extract agent info from root span attributes
71
+ attributes = root_span["attributes"] || {}
72
+ agent_class = attributes["agent.class"]
73
+ agent_action = attributes["agent.action"]
74
+
75
+ # Find any error message
76
+ error_span = spans.find { |s| s["status"] == STATUS_ERROR }
77
+ error_message = error_span&.dig("attributes", "error.message")
78
+
79
+ attrs = {
80
+ trace_id: trace["trace_id"],
81
+ service_name: trace["service_name"],
82
+ environment: trace["environment"],
83
+ timestamp: Time.parse(trace["timestamp"]),
84
+ spans: spans,
85
+ resource_attributes: trace["resource_attributes"],
86
+ sdk_info: sdk_info,
87
+ total_duration_ms: total_duration,
88
+ total_input_tokens: total_input,
89
+ total_output_tokens: total_output,
90
+ total_thinking_tokens: total_thinking,
91
+ status: root_span["status"] || STATUS_UNSET,
92
+ agent_class: agent_class,
93
+ agent_action: agent_action,
94
+ error_message: error_message
95
+ }
96
+
97
+ # Add account if in multi-tenant mode
98
+ attrs[:account] = account if ActiveAgent::Dashboard.multi_tenant? && account
99
+
100
+ create!(attrs)
101
+ end
102
+
103
+ # Returns the root span of this trace.
104
+ #
105
+ # @return [Hash, nil] The root span or nil
106
+ def root_span
107
+ spans&.find { |s| s["parent_span_id"].nil? }
108
+ end
109
+
110
+ # Returns all LLM spans in this trace.
111
+ #
112
+ # @return [Array<Hash>] LLM spans
113
+ def llm_spans
114
+ spans&.select { |s| s["type"] == "llm" } || []
115
+ end
116
+
117
+ # Returns all tool call spans in this trace.
118
+ #
119
+ # @return [Array<Hash>] Tool spans
120
+ def tool_spans
121
+ spans&.select { |s| s["type"] == "tool" } || []
122
+ end
123
+
124
+ # Returns total token count.
125
+ #
126
+ # @return [Integer] Total tokens used
127
+ def total_tokens
128
+ (total_input_tokens || 0) + (total_output_tokens || 0) + (total_thinking_tokens || 0)
129
+ end
130
+
131
+ # Returns whether this trace had an error.
132
+ #
133
+ # @return [Boolean]
134
+ def error?
135
+ status == STATUS_ERROR
136
+ end
137
+
138
+ # Returns display name for the trace.
139
+ #
140
+ # @return [String] Display name (e.g., "WeatherAgent.forecast")
141
+ def display_name
142
+ if agent_class && agent_action
143
+ "#{agent_class}.#{agent_action}"
144
+ elsif agent_class
145
+ agent_class
146
+ else
147
+ trace_id&.first(8)
148
+ end
149
+ end
150
+
151
+ # Returns formatted duration.
152
+ #
153
+ # @return [String] Duration in ms or s
154
+ def formatted_duration
155
+ return "—" unless total_duration_ms
156
+
157
+ if total_duration_ms >= 1000
158
+ "#{(total_duration_ms / 1000.0).round(2)}s"
159
+ else
160
+ "#{total_duration_ms.round(0)}ms"
161
+ end
162
+ end
163
+
164
+ # Returns formatted token count.
165
+ #
166
+ # @return [String] Token count with K suffix for large numbers
167
+ def formatted_tokens
168
+ count = total_tokens
169
+ return "0" if count.zero?
170
+
171
+ if count >= 1000
172
+ "#{(count / 1000.0).round(1)}K"
173
+ else
174
+ count.to_s
175
+ end
176
+ end
177
+
178
+ # Returns the provider used (from LLM spans).
179
+ #
180
+ # @return [String, nil] Provider name
181
+ def provider
182
+ llm_span = llm_spans.first
183
+ return nil unless llm_span
184
+
185
+ llm_span.dig("attributes", "llm.provider")
186
+ end
187
+
188
+ # Returns the model used (from LLM spans).
189
+ #
190
+ # @return [String, nil] Model name
191
+ def model
192
+ llm_span = llm_spans.first
193
+ return nil unless llm_span
194
+
195
+ llm_span.dig("attributes", "llm.model")
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,105 @@
1
+ <div class="space-y-4">
2
+ <!-- Trace Info -->
3
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
4
+ <div>
5
+ <span class="text-gray-500 dark:text-gray-400">Trace ID</span>
6
+ <div class="font-mono text-gray-900 dark:text-white"><%= trace.trace_id %></div>
7
+ </div>
8
+ <div>
9
+ <span class="text-gray-500 dark:text-gray-400">Service</span>
10
+ <div class="text-gray-900 dark:text-white"><%= trace.service_name || "—" %></div>
11
+ </div>
12
+ <div>
13
+ <span class="text-gray-500 dark:text-gray-400">Provider</span>
14
+ <div class="text-gray-900 dark:text-white"><%= trace.provider || "—" %></div>
15
+ </div>
16
+ <div>
17
+ <span class="text-gray-500 dark:text-gray-400">Model</span>
18
+ <div class="text-gray-900 dark:text-white"><%= trace.model || "—" %></div>
19
+ </div>
20
+ </div>
21
+
22
+ <% if trace.error? && trace.error_message.present? %>
23
+ <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
24
+ <div class="flex">
25
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
26
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
27
+ </svg>
28
+ <div class="ml-3">
29
+ <h3 class="text-sm font-medium text-red-800 dark:text-red-200">Error</h3>
30
+ <p class="mt-1 text-sm text-red-700 dark:text-red-300"><%= trace.error_message %></p>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ <% end %>
35
+
36
+ <!-- Token Breakdown -->
37
+ <div class="flex gap-6 text-sm">
38
+ <div class="flex items-center gap-2">
39
+ <div class="w-3 h-3 rounded-full bg-blue-500"></div>
40
+ <span class="text-gray-500">Input:</span>
41
+ <span class="font-medium text-gray-900 dark:text-white"><%= number_with_delimiter(trace.total_input_tokens || 0) %></span>
42
+ </div>
43
+ <div class="flex items-center gap-2">
44
+ <div class="w-3 h-3 rounded-full bg-green-500"></div>
45
+ <span class="text-gray-500">Output:</span>
46
+ <span class="font-medium text-gray-900 dark:text-white"><%= number_with_delimiter(trace.total_output_tokens || 0) %></span>
47
+ </div>
48
+ <% if trace.total_thinking_tokens.to_i > 0 %>
49
+ <div class="flex items-center gap-2">
50
+ <div class="w-3 h-3 rounded-full bg-purple-500"></div>
51
+ <span class="text-gray-500">Thinking:</span>
52
+ <span class="font-medium text-purple-600 dark:text-purple-400"><%= number_with_delimiter(trace.total_thinking_tokens) %></span>
53
+ </div>
54
+ <% end %>
55
+ </div>
56
+
57
+ <!-- Spans Timeline -->
58
+ <% if trace.spans.present? %>
59
+ <div class="mt-4">
60
+ <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Span Timeline</h4>
61
+ <div class="relative bg-gray-100 dark:bg-gray-800 rounded-lg p-4">
62
+ <% total_duration = trace.total_duration_ms || 1 %>
63
+ <% trace.spans.each_with_index do |span, index| %>
64
+ <%
65
+ start_pct = ((span["start_time_relative_ms"] || 0) / total_duration * 100).clamp(0, 100)
66
+ width_pct = ((span["duration_ms"] || 0) / total_duration * 100).clamp(0.5, 100 - start_pct)
67
+ span_type = span["type"] || "unknown"
68
+ colors = {
69
+ "root" => "bg-gray-400",
70
+ "prompt" => "bg-blue-400",
71
+ "llm" => "bg-indigo-500",
72
+ "generate" => "bg-indigo-400",
73
+ "tool" => "bg-amber-500",
74
+ "thinking" => "bg-purple-500",
75
+ "response" => "bg-green-400"
76
+ }
77
+ bg_color = colors[span_type] || "bg-gray-400"
78
+ %>
79
+ <div class="flex items-center gap-2 mb-2">
80
+ <div class="w-24 text-xs text-gray-500 dark:text-gray-400 truncate" title="<%= span["name"] %>">
81
+ <%= span["name"]&.split(".")&.last || span_type %>
82
+ </div>
83
+ <div class="flex-1 h-6 bg-gray-200 dark:bg-gray-700 rounded relative overflow-hidden">
84
+ <div class="span-bar absolute h-full <%= bg_color %> rounded <%= span["status"] == "ERROR" ? 'opacity-50 bg-red-500' : '' %>"
85
+ style="left: <%= start_pct %>%; width: <%= width_pct %>%;"
86
+ title="<%= span["name"] %>: <%= span["duration_ms"]&.round(1) %>ms">
87
+ </div>
88
+ </div>
89
+ <div class="w-16 text-xs text-gray-500 dark:text-gray-400 text-right">
90
+ <%= span["duration_ms"] ? "#{span["duration_ms"].round(1)}ms" : "—" %>
91
+ </div>
92
+ </div>
93
+ <% end %>
94
+ </div>
95
+ </div>
96
+ <% end %>
97
+
98
+ <!-- Raw Data (collapsed) -->
99
+ <details class="mt-4">
100
+ <summary class="text-sm text-gray-500 dark:text-gray-400 cursor-pointer hover:text-gray-700">
101
+ View raw trace data
102
+ </summary>
103
+ <pre class="mt-2 p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-x-auto"><%= JSON.pretty_generate(trace.as_json(except: [:id, :created_at, :updated_at])) %></pre>
104
+ </details>
105
+ </div>