actionagent 0.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +21 -0
- data/README.md +103 -0
- data/app/assets/builds/action_agent.css +2 -0
- data/app/assets/builds/action_agent.js +163 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +128 -0
- data/app/controllers/action_agent/api/agents_controller.rb +411 -0
- data/app/controllers/action_agent/api/analytics_controller.rb +94 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +45 -0
- data/app/controllers/action_agent/api/base_controller.rb +112 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +148 -0
- data/app/controllers/action_agent/api/instance_tiers_controller.rb +106 -0
- data/app/controllers/action_agent/api/interactions_controller.rb +197 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +218 -0
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +156 -0
- data/app/controllers/action_agent/api/metrics_controller.rb +178 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +52 -0
- data/app/controllers/action_agent/api/provider_models_controller.rb +119 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +224 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +372 -0
- data/app/controllers/action_agent/api/templates_controller.rb +94 -0
- data/app/controllers/action_agent/api/tools_controller.rb +58 -0
- data/app/controllers/action_agent/api/trace_reports_controller.rb +68 -0
- data/app/controllers/action_agent/api/traces_controller.rb +136 -0
- data/app/controllers/action_agent/application_controller.rb +105 -0
- data/app/controllers/action_agent/dashboard_controller.rb +104 -0
- data/app/controllers/action_agent/traces_controller.rb +121 -0
- data/app/jobs/action_agent/agent_execution_job.rb +52 -0
- data/app/jobs/action_agent/application_job.rb +12 -0
- data/app/jobs/action_agent/process_telemetry_traces_job.rb +86 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +42 -0
- data/app/jobs/action_agent/sandbox_provision_job.rb +56 -0
- data/app/jobs/action_agent/sandbox_run_job.rb +285 -0
- data/app/jobs/action_agent/trace_retention_job.rb +57 -0
- data/app/models/action_agent/agent.rb +343 -0
- data/app/models/action_agent/agent_context.rb +129 -0
- data/app/models/action_agent/agent_generation.rb +48 -0
- data/app/models/action_agent/agent_memory.rb +50 -0
- data/app/models/action_agent/agent_memory_entry.rb +14 -0
- data/app/models/action_agent/agent_message.rb +43 -0
- data/app/models/action_agent/agent_run.rb +151 -0
- data/app/models/action_agent/agent_template.rb +182 -0
- data/app/models/action_agent/agent_version.rb +48 -0
- data/app/models/action_agent/api_key.rb +53 -0
- data/app/models/action_agent/application_record.rb +27 -0
- data/app/models/action_agent/evaluation.rb +80 -0
- data/app/models/action_agent/evaluation_run.rb +20 -0
- data/app/models/action_agent/model_pricing.rb +80 -0
- data/app/models/action_agent/provider_key.rb +60 -0
- data/app/models/action_agent/recording_action.rb +119 -0
- data/app/models/action_agent/recording_snapshot.rb +88 -0
- data/app/models/action_agent/sandbox_instance_tier.rb +368 -0
- data/app/models/action_agent/sandbox_run.rb +45 -0
- data/app/models/action_agent/sandbox_session.rb +160 -0
- data/app/models/action_agent/session_recording.rb +178 -0
- data/app/models/action_agent/telemetry_trace.rb +357 -0
- data/app/models/concerns/action_agent/adapter_aware.rb +50 -0
- data/app/models/concerns/action_agent/ownable.rb +86 -0
- data/app/models/concerns/action_agent/session_recordable.rb +91 -0
- data/app/queries/action_agent/agent_executions.rb +201 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +23 -0
- data/app/serializers/action_agent/interaction_preview.rb +22 -0
- data/app/serializers/action_agent/telemetry_trace_serializer.rb +122 -0
- data/app/serializers/action_agent/trace_interaction_serializer.rb +246 -0
- data/app/services/action_agent/agent_execution_service.rb +572 -0
- data/app/services/action_agent/agent_registrar.rb +197 -0
- data/app/services/action_agent/agent_scorecard.rb +191 -0
- data/app/services/action_agent/agent_toolbox.rb +504 -0
- data/app/services/action_agent/evaluation_runner_service.rb +481 -0
- data/app/services/action_agent/mcp_catalog.rb +247 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +241 -0
- data/app/services/action_agent/mock_sandbox_backend.rb +52 -0
- data/app/services/action_agent/playwright_mcp_client.rb +148 -0
- data/app/services/action_agent/sandbox_orchestrator.rb +242 -0
- data/app/services/action_agent/session_recording_service.rb +228 -0
- data/app/services/action_agent/tool_discovery.rb +617 -0
- data/app/views/action_agent/dashboard/index.html.erb +5 -0
- data/app/views/action_agent/traces/_trace_detail.html.erb +117 -0
- data/app/views/action_agent/traces/index.html.erb +135 -0
- data/app/views/action_agent/traces/metrics.html.erb +145 -0
- data/app/views/action_agent/traces/show.html.erb +36 -0
- data/app/views/layouts/action_agent/application.html.erb +94 -0
- data/app/views/layouts/action_agent/react.html.erb +19 -0
- data/config/routes.rb +144 -0
- data/lib/action_agent/compatibility.rb +49 -0
- data/lib/action_agent/engine.rb +51 -0
- data/lib/action_agent/version.rb +5 -0
- data/lib/action_agent.rb +388 -0
- data/lib/actionagent.rb +6 -0
- data/lib/generators/action_agent/install_generator.rb +137 -0
- data/lib/generators/action_agent/templates/action_agent.rb.erb +82 -0
- data/lib/generators/action_agent/templates/add_agent_id_to_active_agent_telemetry_traces.rb.erb +24 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +319 -0
- data/lib/generators/action_agent/templates/create_active_agent_telemetry_traces.rb.erb +58 -0
- metadata +209 -12
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
class SandboxSession < ApplicationRecord
|
|
5
|
+
include Ownable
|
|
6
|
+
owned_by :user, :account
|
|
7
|
+
|
|
8
|
+
belongs_to :agent_template, optional: true
|
|
9
|
+
|
|
10
|
+
# Session statuses
|
|
11
|
+
enum :status, {
|
|
12
|
+
pending: 0,
|
|
13
|
+
provisioning: 1,
|
|
14
|
+
ready: 2,
|
|
15
|
+
running: 3,
|
|
16
|
+
completed: 4,
|
|
17
|
+
expired: 5,
|
|
18
|
+
failed: 6
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Sandbox types
|
|
22
|
+
SANDBOX_TYPES = %w[playwright_mcp terminal research].freeze
|
|
23
|
+
|
|
24
|
+
# Free tier limits
|
|
25
|
+
FREE_TIER_LIMITS = {
|
|
26
|
+
max_runs: 10,
|
|
27
|
+
timeout_seconds: 300,
|
|
28
|
+
max_tokens: 50_000,
|
|
29
|
+
session_duration_minutes: 15
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Validations
|
|
33
|
+
validates :session_id, presence: true, uniqueness: true
|
|
34
|
+
validates :sandbox_type, inclusion: { in: SANDBOX_TYPES }
|
|
35
|
+
|
|
36
|
+
# Callbacks
|
|
37
|
+
before_validation :generate_session_id, on: :create
|
|
38
|
+
before_create :set_expiration
|
|
39
|
+
|
|
40
|
+
# Scopes
|
|
41
|
+
scope :active, -> { where(status: [ :pending, :provisioning, :ready, :running ]) }
|
|
42
|
+
scope :expired_sessions, -> { where("expires_at < ?", Time.current) }
|
|
43
|
+
scope :by_type, ->(type) { where(sandbox_type: type) }
|
|
44
|
+
scope :anonymous, -> { where(user_id: nil) }
|
|
45
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
46
|
+
|
|
47
|
+
# Catalog entries for the MCP servers this session was started with.
|
|
48
|
+
# Unknown keys are dropped rather than raising — a session outlives a
|
|
49
|
+
# catalog edit.
|
|
50
|
+
def mcp_catalog_entries
|
|
51
|
+
Array(mcp_servers).filter_map { |key| McpCatalog.find(key) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check if session is still valid
|
|
55
|
+
def active?
|
|
56
|
+
!expired? && !failed? && !completed? && expires_at > Time.current
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if can run more tasks
|
|
60
|
+
def can_run?
|
|
61
|
+
active? && runs_count < max_runs
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Record a new run (thread-safe for parallel execution)
|
|
65
|
+
def record_run!(task:, result:, duration_ms:, tokens:, screenshots: [], provider: nil)
|
|
66
|
+
run = {
|
|
67
|
+
id: SecureRandom.uuid,
|
|
68
|
+
task: task,
|
|
69
|
+
result: result,
|
|
70
|
+
duration_ms: duration_ms,
|
|
71
|
+
tokens: tokens,
|
|
72
|
+
screenshots: screenshots,
|
|
73
|
+
provider: provider,
|
|
74
|
+
status: "completed",
|
|
75
|
+
created_at: Time.current.iso8601
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Use pessimistic locking to prevent race conditions when multiple providers run in parallel
|
|
79
|
+
with_lock do
|
|
80
|
+
reload # Reload to get the latest state
|
|
81
|
+
self.runs = runs + [ run ]
|
|
82
|
+
self.runs_count = runs.size
|
|
83
|
+
self.total_tokens += tokens
|
|
84
|
+
self.total_duration_ms += duration_ms
|
|
85
|
+
self.last_activity_at = Time.current
|
|
86
|
+
save!
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
run
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Provision the Cloud Run sandbox
|
|
93
|
+
def provision!
|
|
94
|
+
return if provisioning? || ready?
|
|
95
|
+
|
|
96
|
+
update!(status: :provisioning)
|
|
97
|
+
|
|
98
|
+
# In development, run synchronously for immediate feedback
|
|
99
|
+
if Rails.env.development? || Rails.env.test?
|
|
100
|
+
SandboxProvisionJob.perform_now(id)
|
|
101
|
+
else
|
|
102
|
+
SandboxProvisionJob.perform_later(id)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Mark as ready with Cloud Run URL
|
|
107
|
+
def mark_ready!(cloud_run_url:, cloud_run_job_id: nil)
|
|
108
|
+
update!(
|
|
109
|
+
status: :ready,
|
|
110
|
+
cloud_run_url: cloud_run_url,
|
|
111
|
+
cloud_run_job_id: cloud_run_job_id
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Expire the session
|
|
116
|
+
def expire!
|
|
117
|
+
update!(status: :expired)
|
|
118
|
+
# Cleanup Cloud Run resources
|
|
119
|
+
SandboxCleanupJob.perform_later(id) if cloud_run_job_id.present?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Summary for API responses
|
|
123
|
+
def summary
|
|
124
|
+
{
|
|
125
|
+
id: id,
|
|
126
|
+
session_id: session_id,
|
|
127
|
+
sandbox_type: sandbox_type,
|
|
128
|
+
status: status,
|
|
129
|
+
runs_count: runs_count,
|
|
130
|
+
max_runs: max_runs,
|
|
131
|
+
total_tokens: total_tokens,
|
|
132
|
+
expires_at: expires_at&.iso8601,
|
|
133
|
+
created_at: created_at.iso8601,
|
|
134
|
+
cloud_run_url: cloud_run_url,
|
|
135
|
+
mcp_servers: Array(mcp_servers)
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Detailed info including runs
|
|
140
|
+
def details
|
|
141
|
+
summary.merge(
|
|
142
|
+
runs: runs,
|
|
143
|
+
total_duration_ms: total_duration_ms,
|
|
144
|
+
last_activity_at: last_activity_at&.iso8601
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def generate_session_id
|
|
151
|
+
self.session_id ||= SecureRandom.uuid
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def set_expiration
|
|
155
|
+
self.expires_at ||= FREE_TIER_LIMITS[:session_duration_minutes].minutes.from_now
|
|
156
|
+
self.max_runs ||= FREE_TIER_LIMITS[:max_runs]
|
|
157
|
+
self.timeout_seconds ||= FREE_TIER_LIMITS[:timeout_seconds]
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
class SessionRecording < ApplicationRecord
|
|
5
|
+
include Ownable
|
|
6
|
+
owned_by :user, :account
|
|
7
|
+
|
|
8
|
+
belongs_to :agent_run, optional: true
|
|
9
|
+
belongs_to :sandbox_session, optional: true
|
|
10
|
+
|
|
11
|
+
has_many :recording_actions, dependent: :destroy
|
|
12
|
+
has_many :recording_snapshots, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
enum :status, { recording: 0, completed: 1, failed: 2 }
|
|
15
|
+
|
|
16
|
+
validates :status, presence: true
|
|
17
|
+
validate :must_have_parent, unless: -> { demo_recording? || user_session? }
|
|
18
|
+
|
|
19
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
20
|
+
scope :for_agent, ->(agent_id) { joins(:agent_run).where(agent_runs: { agent_id: agent_id }) }
|
|
21
|
+
scope :demo, -> { where(name: "lander_demo") }
|
|
22
|
+
scope :user_sessions, -> { where("name LIKE ?", "user_takeover_%") }
|
|
23
|
+
|
|
24
|
+
# Check if this is a demo recording (doesn't require parent)
|
|
25
|
+
def demo_recording?
|
|
26
|
+
name&.start_with?("lander_") || name == "demo"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check if this is a user takeover session (doesn't require parent)
|
|
30
|
+
def user_session?
|
|
31
|
+
name&.start_with?("user_takeover_")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Start a new recording session
|
|
35
|
+
def self.start!(agent_run: nil, sandbox_session: nil, name: nil)
|
|
36
|
+
create!(
|
|
37
|
+
agent_run: agent_run,
|
|
38
|
+
sandbox_session: sandbox_session,
|
|
39
|
+
name: name || generate_name(agent_run, sandbox_session),
|
|
40
|
+
status: :recording,
|
|
41
|
+
metadata: { started_at: Time.current.iso8601 }
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Start a user takeover session (for lander demo analytics)
|
|
46
|
+
def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil)
|
|
47
|
+
create!(
|
|
48
|
+
name: "user_takeover_#{Time.current.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}",
|
|
49
|
+
status: :recording,
|
|
50
|
+
metadata: {
|
|
51
|
+
started_at: Time.current.iso8601,
|
|
52
|
+
session_type: "user_takeover",
|
|
53
|
+
visitor_id: visitor_id,
|
|
54
|
+
parent_demo_id: parent_demo_id,
|
|
55
|
+
page_url: page_url,
|
|
56
|
+
user_agent: nil # Will be set from request
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Record a browser action
|
|
62
|
+
def record_action!(action_type:, selector: nil, value: nil, screenshot: nil, dom_snapshot: nil, metadata: {})
|
|
63
|
+
raise "Recording already completed" unless recording?
|
|
64
|
+
|
|
65
|
+
action = recording_actions.create!(
|
|
66
|
+
action_type: action_type,
|
|
67
|
+
sequence: next_sequence,
|
|
68
|
+
timestamp_ms: elapsed_ms,
|
|
69
|
+
selector: selector,
|
|
70
|
+
value: value,
|
|
71
|
+
metadata: metadata
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Handle screenshot attachment if provided
|
|
75
|
+
if screenshot.present?
|
|
76
|
+
snapshot = store_snapshot(screenshot, :screenshot, action)
|
|
77
|
+
action.update!(screenshot_key: snapshot.storage_key)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Handle DOM snapshot if provided
|
|
81
|
+
if dom_snapshot.present?
|
|
82
|
+
snapshot = store_snapshot(dom_snapshot, :dom, action)
|
|
83
|
+
action.update!(dom_snapshot_key: snapshot.storage_key)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
increment!(:action_count)
|
|
87
|
+
action
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Complete the recording
|
|
91
|
+
def complete!
|
|
92
|
+
return unless recording?
|
|
93
|
+
|
|
94
|
+
update!(
|
|
95
|
+
status: :completed,
|
|
96
|
+
duration_ms: elapsed_ms,
|
|
97
|
+
metadata: metadata.merge(completed_at: Time.current.iso8601)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Mark recording as failed
|
|
102
|
+
def fail!(error_message = nil)
|
|
103
|
+
return unless recording?
|
|
104
|
+
|
|
105
|
+
update!(
|
|
106
|
+
status: :failed,
|
|
107
|
+
duration_ms: elapsed_ms,
|
|
108
|
+
metadata: metadata.merge(
|
|
109
|
+
failed_at: Time.current.iso8601,
|
|
110
|
+
error: error_message
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get timeline data for playback
|
|
116
|
+
def timeline
|
|
117
|
+
recording_actions.order(:sequence).map do |action|
|
|
118
|
+
{
|
|
119
|
+
id: action.id,
|
|
120
|
+
type: action.action_type,
|
|
121
|
+
sequence: action.sequence,
|
|
122
|
+
timestamp_ms: action.timestamp_ms,
|
|
123
|
+
selector: action.selector,
|
|
124
|
+
value: action.value,
|
|
125
|
+
screenshot_key: action.screenshot_key,
|
|
126
|
+
metadata: action.metadata
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def must_have_parent
|
|
134
|
+
return if agent_run.present? || sandbox_session.present?
|
|
135
|
+
|
|
136
|
+
errors.add(:base, "must belong to an agent_run or sandbox_session")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.generate_name(agent_run, sandbox_session)
|
|
140
|
+
prefix = if agent_run&.agent
|
|
141
|
+
agent_run.agent.name.parameterize
|
|
142
|
+
elsif sandbox_session&.agent_template
|
|
143
|
+
sandbox_session.agent_template.name.parameterize
|
|
144
|
+
else
|
|
145
|
+
"session"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
"#{prefix}_#{Time.current.strftime('%Y%m%d_%H%M%S')}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def next_sequence
|
|
152
|
+
(recording_actions.maximum(:sequence) || 0) + 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def elapsed_ms
|
|
156
|
+
((Time.current - created_at) * 1000).to_i
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def store_snapshot(data, snapshot_type, action = nil)
|
|
160
|
+
storage_key = generate_storage_key(snapshot_type, action&.sequence)
|
|
161
|
+
|
|
162
|
+
# For now, store metadata - actual file upload handled by service
|
|
163
|
+
recording_snapshots.create!(
|
|
164
|
+
recording_action: action,
|
|
165
|
+
storage_key: storage_key,
|
|
166
|
+
snapshot_type: snapshot_type,
|
|
167
|
+
file_size_bytes: data.bytesize
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def generate_storage_key(snapshot_type, sequence = nil)
|
|
172
|
+
parts = [ "recordings", id, snapshot_type.to_s ]
|
|
173
|
+
parts << sequence.to_s if sequence
|
|
174
|
+
parts << SecureRandom.hex(8)
|
|
175
|
+
parts.join("/")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
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
|
+
# ActionAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info)
|
|
15
|
+
#
|
|
16
|
+
# @example Creating a trace with account (multi-tenant mode)
|
|
17
|
+
# ActionAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info, account: account)
|
|
18
|
+
#
|
|
19
|
+
class TelemetryTrace < ::ActiveRecord::Base
|
|
20
|
+
include ActionAgent::AdapterAware
|
|
21
|
+
|
|
22
|
+
self.table_name = "active_agent_telemetry_traces"
|
|
23
|
+
|
|
24
|
+
# Optional account association for multi-tenant mode
|
|
25
|
+
# The host app can add: belongs_to :account if needed
|
|
26
|
+
if ActionAgent.multi_tenant?
|
|
27
|
+
belongs_to :account, class_name: ActionAgent.account_class
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Status values for traces
|
|
31
|
+
STATUS_OK = "OK"
|
|
32
|
+
STATUS_ERROR = "ERROR"
|
|
33
|
+
STATUS_UNSET = "UNSET"
|
|
34
|
+
|
|
35
|
+
validates :trace_id, presence: true
|
|
36
|
+
|
|
37
|
+
# Scopes
|
|
38
|
+
scope :recent, -> { order(timestamp: :desc) }
|
|
39
|
+
scope :with_errors, -> { where(status: STATUS_ERROR) }
|
|
40
|
+
scope :for_service, ->(name) { where(service_name: name) }
|
|
41
|
+
scope :for_environment, ->(env) { where(environment: env) }
|
|
42
|
+
scope :for_agent, ->(agent_class) { where(agent_class: agent_class) }
|
|
43
|
+
scope :for_date_range, ->(start_date, end_date) { where(timestamp: start_date..end_date) }
|
|
44
|
+
# The dashboard agent this trace was attributed to on ingest, if any.
|
|
45
|
+
belongs_to :agent, class_name: "ActionAgent::Agent", optional: true
|
|
46
|
+
|
|
47
|
+
scope :for_account, ->(account) { where(account: account) if ActionAgent.multi_tenant? }
|
|
48
|
+
|
|
49
|
+
# Creates a TelemetryTrace from an ingested trace payload.
|
|
50
|
+
#
|
|
51
|
+
# Extracts relevant data from the trace payload and stores it in a
|
|
52
|
+
# normalized format for querying and analysis.
|
|
53
|
+
#
|
|
54
|
+
# @param trace [Hash] The trace payload from ActiveAgent::Telemetry
|
|
55
|
+
# @param sdk_info [Hash] SDK metadata
|
|
56
|
+
# @param account [Object, nil] Optional account for multi-tenant mode
|
|
57
|
+
# @return [TelemetryTrace] The created trace
|
|
58
|
+
# Plucks [llm_model, *columns] per trace, where llm_model comes from the
|
|
59
|
+
# first llm span. PostgreSQL digs into the spans jsonb in SQL so span
|
|
60
|
+
# payloads never reach Ruby; other adapters read the column back and dig
|
|
61
|
+
# in Ruby, which costs more but keeps the dashboard adapter-agnostic.
|
|
62
|
+
def self.pluck_with_llm_model(scope, *columns)
|
|
63
|
+
if postgres?
|
|
64
|
+
scope.pluck(
|
|
65
|
+
Arel.sql(
|
|
66
|
+
# spans is cast rather than assumed to be jsonb: the column is
|
|
67
|
+
# json on every install created before the migration template
|
|
68
|
+
# started picking jsonb per adapter, and jsonb_array_elements
|
|
69
|
+
# rejects a json argument outright.
|
|
70
|
+
"(SELECT s.value -> 'attributes' ->> 'llm.model' " \
|
|
71
|
+
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
72
|
+
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
73
|
+
),
|
|
74
|
+
*columns
|
|
75
|
+
)
|
|
76
|
+
else
|
|
77
|
+
scope.pluck(:spans, *columns).map do |spans, *rest|
|
|
78
|
+
llm = Array(spans).find { |span| span.is_a?(Hash) && span["type"].to_s == "llm" }
|
|
79
|
+
[ llm&.dig("attributes", "llm.model"), *rest ]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.create_from_payload(trace, sdk_info = {}, account: nil)
|
|
85
|
+
spans = trace["spans"] || []
|
|
86
|
+
root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
|
|
87
|
+
|
|
88
|
+
total_duration = root_span["duration_ms"]
|
|
89
|
+
|
|
90
|
+
# Instrumentation mirrors LLM token usage onto the root span for
|
|
91
|
+
# display, so summing every span double-counts. When child spans
|
|
92
|
+
# carry token data, they are the source of truth; the root span only
|
|
93
|
+
# counts for single-span traces.
|
|
94
|
+
counted_spans = spans.reject { |s| s["parent_span_id"].nil? }
|
|
95
|
+
counted_spans = spans if counted_spans.none? { |s| span_token_sum(s).positive? }
|
|
96
|
+
|
|
97
|
+
total_input = 0
|
|
98
|
+
total_output = 0
|
|
99
|
+
total_thinking = 0
|
|
100
|
+
|
|
101
|
+
counted_spans.each do |span|
|
|
102
|
+
tokens = span["tokens"] || {}
|
|
103
|
+
total_input += (tokens["input"] || 0)
|
|
104
|
+
total_output += (tokens["output"] || 0)
|
|
105
|
+
total_thinking += (tokens["thinking"] || 0)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Extract agent info from root span attributes
|
|
109
|
+
attributes = root_span["attributes"] || {}
|
|
110
|
+
agent_class = attributes["agent.class"]
|
|
111
|
+
agent_action = attributes["agent.action"]
|
|
112
|
+
|
|
113
|
+
# Find any error message
|
|
114
|
+
error_span = spans.find { |s| s["status"] == STATUS_ERROR }
|
|
115
|
+
error_message = error_span&.dig("attributes", "error.message")
|
|
116
|
+
|
|
117
|
+
attrs = {
|
|
118
|
+
trace_id: trace["trace_id"],
|
|
119
|
+
service_name: trace["service_name"],
|
|
120
|
+
environment: trace["environment"],
|
|
121
|
+
timestamp: Time.parse(trace["timestamp"]),
|
|
122
|
+
spans: spans,
|
|
123
|
+
resource_attributes: trace["resource_attributes"],
|
|
124
|
+
sdk_info: sdk_info,
|
|
125
|
+
total_duration_ms: total_duration,
|
|
126
|
+
total_input_tokens: total_input,
|
|
127
|
+
total_output_tokens: total_output,
|
|
128
|
+
total_thinking_tokens: total_thinking,
|
|
129
|
+
status: root_span["status"] || STATUS_UNSET,
|
|
130
|
+
agent_class: agent_class,
|
|
131
|
+
agent_action: agent_action,
|
|
132
|
+
error_message: error_message
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# Add account if in multi-tenant mode
|
|
136
|
+
attrs[:account] = account if ActionAgent.multi_tenant? && account
|
|
137
|
+
|
|
138
|
+
create!(attrs)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Sums a span's token counts (used to decide which spans carry the
|
|
142
|
+
# authoritative token data during ingestion).
|
|
143
|
+
#
|
|
144
|
+
# @api private
|
|
145
|
+
def self.span_token_sum(span)
|
|
146
|
+
tokens = span["tokens"] || {}
|
|
147
|
+
tokens.fetch("input", 0).to_i + tokens.fetch("output", 0).to_i + tokens.fetch("thinking", 0).to_i
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Returns the root span of this trace.
|
|
151
|
+
#
|
|
152
|
+
# @return [Hash, nil] The root span or nil
|
|
153
|
+
def root_span
|
|
154
|
+
spans&.find { |s| s["parent_span_id"].nil? }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns all LLM spans in this trace.
|
|
158
|
+
#
|
|
159
|
+
# @return [Array<Hash>] LLM spans
|
|
160
|
+
def llm_spans
|
|
161
|
+
spans&.select { |s| s["type"] == "llm" } || []
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Returns all tool call spans in this trace.
|
|
165
|
+
#
|
|
166
|
+
# @return [Array<Hash>] Tool spans
|
|
167
|
+
def tool_spans
|
|
168
|
+
spans&.select { |s| s["type"] == "tool" } || []
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Returns each tool call in this trace, normalized for display.
|
|
172
|
+
#
|
|
173
|
+
# Tool spans are tagged with their origin at instrumentation time
|
|
174
|
+
# (ActiveAgent::Telemetry::ToolOrigin), but traces ingested before that
|
|
175
|
+
# shipped — or sent by another SDK — only carry +tool.name+. Those are
|
|
176
|
+
# classified on read from the same naming convention, so a dashboard
|
|
177
|
+
# sees consistent attribution across old and new traces.
|
|
178
|
+
#
|
|
179
|
+
# @return [Array<Hash>] one entry per tool span with :name, :base_name,
|
|
180
|
+
# :origin, :mcp_server, :duration_ms, :status, :error, :arguments and
|
|
181
|
+
# :result
|
|
182
|
+
def tool_usage
|
|
183
|
+
tool_spans.map do |span|
|
|
184
|
+
attributes = span["attributes"] || {}
|
|
185
|
+
name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
|
|
186
|
+
classification = classify_tool(name, attributes)
|
|
187
|
+
|
|
188
|
+
{
|
|
189
|
+
name: name,
|
|
190
|
+
base_name: attributes["tool.base_name"] || classification[:tool],
|
|
191
|
+
origin: attributes["tool.origin"] || classification[:origin],
|
|
192
|
+
mcp_server: attributes["tool.mcp_server"] || classification[:server],
|
|
193
|
+
duration_ms: span["duration_ms"],
|
|
194
|
+
status: span["status"],
|
|
195
|
+
error: attributes["error.message"],
|
|
196
|
+
arguments: attributes["tool.input.args"],
|
|
197
|
+
result: attributes["tool.output.result"]
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Returns the tools this trace's generation request OFFERED the
|
|
203
|
+
# provider, whether or not the model went on to call any of them.
|
|
204
|
+
#
|
|
205
|
+
# Instrumentation records the roster on the prompt span as
|
|
206
|
+
# +prompt.input.tools+ (name, description, parameter keys), which is
|
|
207
|
+
# the agent's declared tool surface for that generation. Reading it
|
|
208
|
+
# here is what lets a dashboard show a tool that exists but has never
|
|
209
|
+
# been invoked — a state that tool spans alone can't express.
|
|
210
|
+
#
|
|
211
|
+
# @return [Array<Hash>] entries with :name, :description, :parameters,
|
|
212
|
+
# :origin and :mcp_server
|
|
213
|
+
def declared_tools
|
|
214
|
+
Array(tool_roster).filter_map do |tool|
|
|
215
|
+
next unless tool.is_a?(Hash)
|
|
216
|
+
|
|
217
|
+
name = (tool["name"] || tool[:name]).to_s
|
|
218
|
+
next if name.empty?
|
|
219
|
+
|
|
220
|
+
classification = ActiveAgent::Telemetry::ToolOrigin.classify(name)
|
|
221
|
+
{
|
|
222
|
+
name: name,
|
|
223
|
+
description: (tool["description"] || tool[:description]).presence,
|
|
224
|
+
parameters: normalize_parameters(tool["parameters"] || tool[:parameters]),
|
|
225
|
+
origin: classification[:origin],
|
|
226
|
+
mcp_server: classification[:server]
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Returns the distinct MCP servers this trace touched — both the ones
|
|
232
|
+
# it called and the ones it was merely offered.
|
|
233
|
+
#
|
|
234
|
+
# @return [Array<String>] server names, in first-seen order
|
|
235
|
+
def mcp_servers
|
|
236
|
+
(tool_usage.filter_map { |tool| tool[:mcp_server] } +
|
|
237
|
+
declared_tools.filter_map { |tool| tool[:mcp_server] }).uniq
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Returns total token count.
|
|
241
|
+
#
|
|
242
|
+
# @return [Integer] Total tokens used
|
|
243
|
+
def total_tokens
|
|
244
|
+
(total_input_tokens || 0) + (total_output_tokens || 0) + (total_thinking_tokens || 0)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Returns whether this trace had an error.
|
|
248
|
+
#
|
|
249
|
+
# @return [Boolean]
|
|
250
|
+
def error?
|
|
251
|
+
status == STATUS_ERROR
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Returns display name for the trace.
|
|
255
|
+
#
|
|
256
|
+
# @return [String] Display name (e.g., "WeatherAgent.forecast")
|
|
257
|
+
def display_name
|
|
258
|
+
if agent_class && agent_action
|
|
259
|
+
"#{agent_class}.#{agent_action}"
|
|
260
|
+
elsif agent_class
|
|
261
|
+
agent_class
|
|
262
|
+
else
|
|
263
|
+
trace_id&.first(8)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Returns formatted duration.
|
|
268
|
+
#
|
|
269
|
+
# @return [String] Duration in ms or s
|
|
270
|
+
def formatted_duration
|
|
271
|
+
return "—" unless total_duration_ms
|
|
272
|
+
|
|
273
|
+
if total_duration_ms >= 1000
|
|
274
|
+
"#{(total_duration_ms / 1000.0).round(2)}s"
|
|
275
|
+
else
|
|
276
|
+
"#{total_duration_ms.round(0)}ms"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Returns formatted token count.
|
|
281
|
+
#
|
|
282
|
+
# @return [String] Token count with K suffix for large numbers
|
|
283
|
+
def formatted_tokens
|
|
284
|
+
count = total_tokens
|
|
285
|
+
return "0" if count.zero?
|
|
286
|
+
|
|
287
|
+
if count >= 1000
|
|
288
|
+
"#{(count / 1000.0).round(1)}K"
|
|
289
|
+
else
|
|
290
|
+
count.to_s
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Returns the provider used (from LLM spans).
|
|
295
|
+
#
|
|
296
|
+
# @return [String, nil] Provider name
|
|
297
|
+
def provider
|
|
298
|
+
llm_span = llm_spans.first
|
|
299
|
+
return nil unless llm_span
|
|
300
|
+
|
|
301
|
+
llm_span.dig("attributes", "llm.provider")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Returns the model used (from LLM spans).
|
|
305
|
+
#
|
|
306
|
+
# @return [String, nil] Model name
|
|
307
|
+
def model
|
|
308
|
+
llm_span = llm_spans.first
|
|
309
|
+
return nil unless llm_span
|
|
310
|
+
|
|
311
|
+
llm_span.dig("attributes", "llm.model")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
private
|
|
315
|
+
|
|
316
|
+
# The offered roster, as stored. Instrumentation writes JSON onto the
|
|
317
|
+
# prompt span; +llm.tools+ is accepted as an alias because some SDK
|
|
318
|
+
# versions put the roster on the llm span instead, and a payload that
|
|
319
|
+
# arrived already decoded is passed straight through.
|
|
320
|
+
def tool_roster
|
|
321
|
+
raw = spans.to_a.filter_map do |span|
|
|
322
|
+
attributes = span["attributes"] || {}
|
|
323
|
+
attributes["prompt.input.tools"].presence || attributes["llm.tools"].presence
|
|
324
|
+
end.first
|
|
325
|
+
return nil if raw.blank?
|
|
326
|
+
return raw unless raw.is_a?(String)
|
|
327
|
+
|
|
328
|
+
JSON.parse(raw)
|
|
329
|
+
rescue JSON::ParserError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The roster records parameters as a name list, but a raw JSON Schema
|
|
334
|
+
# arrives instead when an SDK forwards tool definitions verbatim.
|
|
335
|
+
def normalize_parameters(parameters)
|
|
336
|
+
return [] if parameters.blank?
|
|
337
|
+
return parameters.map(&:to_s) if parameters.is_a?(Array)
|
|
338
|
+
|
|
339
|
+
if parameters.is_a?(Hash)
|
|
340
|
+
properties = parameters["properties"] || parameters[:properties]
|
|
341
|
+
return properties.keys.map(&:to_s) if properties.is_a?(Hash)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
[]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Recovers a tool's origin for traces that predate origin tagging.
|
|
348
|
+
# Prefers an explicit server attribute when the SDK sent one, then
|
|
349
|
+
# falls back to the shared name-convention classifier.
|
|
350
|
+
def classify_tool(name, attributes)
|
|
351
|
+
explicit = attributes["mcp.server"] || attributes["tool.server"]
|
|
352
|
+
return { origin: "mcp", server: explicit, tool: name } if explicit.present?
|
|
353
|
+
|
|
354
|
+
ActiveAgent::Telemetry::ToolOrigin.classify(name)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|