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.
- checksums.yaml +4 -4
- data/README.md +10 -4
- data/lib/active_agent/base.rb +3 -2
- data/lib/active_agent/concerns/provider.rb +6 -2
- data/lib/active_agent/concerns/rescue.rb +39 -0
- data/lib/active_agent/concerns/streaming.rb +2 -1
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/api/traces_controller.rb +117 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/application_controller.rb +54 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/dashboard_controller.rb +126 -0
- data/lib/active_agent/dashboard/app/controllers/active_agent/dashboard/traces_controller.rb +103 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/agent_execution_job.rb +56 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/application_job.rb +14 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_cleanup_job.rb +49 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/dashboard/sandbox_provision_job.rb +65 -0
- data/lib/active_agent/dashboard/app/jobs/active_agent/process_telemetry_traces_job.rb +77 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent.rb +256 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_run.rb +113 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_template.rb +208 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/agent_version.rb +60 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/application_record.rb +46 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_action.rb +125 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/recording_snapshot.rb +83 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_run.rb +52 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/sandbox_session.rb +169 -0
- data/lib/active_agent/dashboard/app/models/active_agent/dashboard/session_recording.rb +193 -0
- data/lib/active_agent/dashboard/app/models/active_agent/telemetry_trace.rb +198 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb +105 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/index.html.erb +135 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/metrics.html.erb +143 -0
- data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/show.html.erb +36 -0
- data/lib/active_agent/dashboard/app/views/layouts/active_agent/dashboard/application.html.erb +94 -0
- data/lib/active_agent/dashboard/config/routes.rb +78 -0
- data/lib/active_agent/dashboard/engine.rb +39 -0
- data/lib/active_agent/dashboard.rb +151 -0
- data/lib/active_agent/providers/_base_provider.rb +2 -1
- data/lib/active_agent/providers/anthropic_provider.rb +14 -4
- data/lib/active_agent/providers/azure/_types.rb +5 -0
- data/lib/active_agent/providers/azure/options.rb +111 -0
- data/lib/active_agent/providers/azure_open_ai_provider.rb +2 -0
- data/lib/active_agent/providers/azure_openai_provider.rb +2 -0
- data/lib/active_agent/providers/azure_provider.rb +133 -0
- data/lib/active_agent/providers/azureopenai_provider.rb +2 -0
- data/lib/active_agent/providers/bedrock/_types.rb +8 -0
- data/lib/active_agent/providers/bedrock/bearer_client.rb +109 -0
- data/lib/active_agent/providers/bedrock/options.rb +77 -0
- data/lib/active_agent/providers/bedrock_provider.rb +84 -0
- data/lib/active_agent/providers/common/messages/_types.rb +6 -2
- data/lib/active_agent/providers/concerns/exception_handler.rb +1 -0
- data/lib/active_agent/providers/gemini/_types.rb +19 -0
- data/lib/active_agent/providers/gemini/options.rb +41 -0
- data/lib/active_agent/providers/gemini_provider.rb +94 -0
- data/lib/active_agent/providers/open_ai/chat/transforms.rb +37 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +2 -0
- data/lib/active_agent/providers/ruby_llm/_types.rb +77 -0
- data/lib/active_agent/providers/ruby_llm/embedding_request.rb +16 -0
- data/lib/active_agent/providers/ruby_llm/messages/_types.rb +109 -0
- data/lib/active_agent/providers/ruby_llm/messages/assistant.rb +27 -0
- data/lib/active_agent/providers/ruby_llm/messages/base.rb +48 -0
- data/lib/active_agent/providers/ruby_llm/messages/system.rb +18 -0
- data/lib/active_agent/providers/ruby_llm/messages/tool.rb +24 -0
- data/lib/active_agent/providers/ruby_llm/messages/user.rb +18 -0
- data/lib/active_agent/providers/ruby_llm/options.rb +28 -0
- data/lib/active_agent/providers/ruby_llm/request.rb +30 -0
- data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +45 -0
- data/lib/active_agent/providers/ruby_llm_provider.rb +407 -0
- data/lib/active_agent/railtie.rb +32 -1
- data/lib/active_agent/telemetry/configuration.rb +213 -0
- data/lib/active_agent/telemetry/instrumentation.rb +155 -0
- data/lib/active_agent/telemetry/reporter.rb +176 -0
- data/lib/active_agent/telemetry/span.rb +267 -0
- data/lib/active_agent/telemetry/tracer.rb +184 -0
- data/lib/active_agent/telemetry.rb +162 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/active_agent.rb +2 -0
- data/lib/generators/active_agent/dashboard/install/install_generator.rb +96 -0
- data/lib/generators/active_agent/dashboard/install/templates/initializer.rb +89 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_runs.rb +42 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_templates.rb +38 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agent_versions.rb +22 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_agents.rb +53 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_runs.rb +28 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_sandbox_sessions.rb +43 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_session_recordings.rb +44 -0
- data/lib/generators/active_agent/dashboard/install/templates/migrations/create_active_agent_telemetry_traces.rb +56 -0
- data/lib/generators/active_agent/dashboard/install_generator.rb +64 -0
- data/lib/generators/active_agent/dashboard/templates/active_agent_dashboard.rb.erb +30 -0
- data/lib/generators/active_agent/dashboard/templates/create_active_agent_telemetry_traces.rb.erb +30 -0
- 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
|
data/lib/active_agent/dashboard/app/views/active_agent/dashboard/traces/_trace_detail.html.erb
ADDED
|
@@ -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>
|