actionagent 1.2.2 → 1.3.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/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +48 -44
- data/app/controllers/action_agent/api/agent_runs_controller.rb +25 -7
- data/app/controllers/action_agent/api/agents_controller.rb +76 -48
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +237 -7
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +2 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +14 -5
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +182 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +66 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +110 -2
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/services/action_agent/agent_toolbox.rb +4 -4
- data/app/services/action_agent/evaluation_tool_resolver.rb +154 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/playwright_mcp_client.rb +6 -6
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +226 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +25 -2
- data/lib/action_agent/engine.rb +101 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +72 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +19 -6
|
@@ -81,6 +81,109 @@ module ActionAgent
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# The trace columns the Metrics report reads, in the order
|
|
85
|
+
# pluck_metrics_rows returns them (before the span-derived fields).
|
|
86
|
+
METRICS_COLUMNS = %i[
|
|
87
|
+
timestamp agent_class agent_action total_duration_ms status error_message
|
|
88
|
+
total_input_tokens total_output_tokens
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
# Plucks one row per trace with everything the Metrics report needs, so
|
|
92
|
+
# a window is read once:
|
|
93
|
+
#
|
|
94
|
+
# [timestamp, agent_class, agent_action, total_duration_ms, status,
|
|
95
|
+
# error_message, total_input_tokens, total_output_tokens,
|
|
96
|
+
# llm_model, llm_provider, tool_calls, tool_errors]
|
|
97
|
+
#
|
|
98
|
+
# llm_model and llm_provider come from the first llm span; tool_calls
|
|
99
|
+
# and tool_errors count the trace's tool spans and those whose status is
|
|
100
|
+
# ERROR. Same split as pluck_with_llm_model: PostgreSQL digs into the
|
|
101
|
+
# spans jsonb in SQL, other adapters read the column back and count in
|
|
102
|
+
# Ruby.
|
|
103
|
+
def self.pluck_metrics_rows(scope)
|
|
104
|
+
if postgres?
|
|
105
|
+
scope.pluck(
|
|
106
|
+
*METRICS_COLUMNS,
|
|
107
|
+
# spans is cast for the same reason as in pluck_with_llm_model:
|
|
108
|
+
# the column is json on older installs.
|
|
109
|
+
Arel.sql(
|
|
110
|
+
"(SELECT s.value -> 'attributes' ->> 'llm.model' " \
|
|
111
|
+
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
112
|
+
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
113
|
+
),
|
|
114
|
+
Arel.sql(
|
|
115
|
+
"(SELECT s.value -> 'attributes' ->> 'llm.provider' " \
|
|
116
|
+
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
117
|
+
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
118
|
+
),
|
|
119
|
+
Arel.sql(
|
|
120
|
+
"(SELECT COUNT(*) FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
121
|
+
"WHERE s.value ->> 'type' = 'tool')"
|
|
122
|
+
),
|
|
123
|
+
Arel.sql(
|
|
124
|
+
"(SELECT COUNT(*) FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
125
|
+
"WHERE s.value ->> 'type' = 'tool' AND s.value ->> 'status' = '#{STATUS_ERROR}')"
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
else
|
|
129
|
+
scope.pluck(:spans, *METRICS_COLUMNS).map do |spans, *rest|
|
|
130
|
+
spans = Array(spans).grep(Hash)
|
|
131
|
+
llm = spans.find { |span| span["type"].to_s == "llm" }
|
|
132
|
+
tools = spans.select { |span| span["type"].to_s == "tool" }
|
|
133
|
+
[
|
|
134
|
+
*rest,
|
|
135
|
+
llm&.dig("attributes", "llm.model"),
|
|
136
|
+
llm&.dig("attributes", "llm.provider"),
|
|
137
|
+
tools.size,
|
|
138
|
+
tools.count { |span| span["status"].to_s == STATUS_ERROR }
|
|
139
|
+
]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Per-tool call count, error count and summed duration over the tool
|
|
145
|
+
# spans of every trace in +scope+:
|
|
146
|
+
#
|
|
147
|
+
# { "web_search" => { calls: 12, errors: 1, total_ms: 7680.0 }, ... }
|
|
148
|
+
#
|
|
149
|
+
# A tool is named by its span's tool.name attribute, or by the span name
|
|
150
|
+
# minus its "tool." prefix — the same rule as #tool_usage. PostgreSQL
|
|
151
|
+
# groups in SQL over the unnested spans jsonb; other adapters read the
|
|
152
|
+
# spans back and tally in Ruby.
|
|
153
|
+
def self.tool_span_stats(scope)
|
|
154
|
+
if postgres?
|
|
155
|
+
name_sql = "COALESCE(s.value -> 'attributes' ->> 'tool.name', " \
|
|
156
|
+
"regexp_replace(COALESCE(s.value ->> 'name', ''), '^tool\\.', ''))"
|
|
157
|
+
|
|
158
|
+
scope
|
|
159
|
+
.joins("CROSS JOIN LATERAL jsonb_array_elements(#{table_name}.spans::jsonb) AS s")
|
|
160
|
+
.where("s.value ->> 'type' = 'tool'")
|
|
161
|
+
.group(Arel.sql(name_sql))
|
|
162
|
+
.pluck(
|
|
163
|
+
Arel.sql(name_sql),
|
|
164
|
+
Arel.sql("COUNT(*)"),
|
|
165
|
+
Arel.sql("SUM(CASE WHEN s.value ->> 'status' = '#{STATUS_ERROR}' THEN 1 ELSE 0 END)"),
|
|
166
|
+
Arel.sql("SUM(COALESCE((s.value ->> 'duration_ms')::float, 0))")
|
|
167
|
+
)
|
|
168
|
+
.to_h do |name, calls, errors, total_ms|
|
|
169
|
+
[ name, { calls: calls.to_i, errors: errors.to_i, total_ms: total_ms.to_f } ]
|
|
170
|
+
end
|
|
171
|
+
else
|
|
172
|
+
scope.pluck(:spans).each_with_object({}) do |spans, stats|
|
|
173
|
+
Array(spans).each do |span|
|
|
174
|
+
next unless span.is_a?(Hash) && span["type"].to_s == "tool"
|
|
175
|
+
|
|
176
|
+
attributes = span["attributes"] || {}
|
|
177
|
+
name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
|
|
178
|
+
entry = (stats[name] ||= { calls: 0, errors: 0, total_ms: 0.0 })
|
|
179
|
+
entry[:calls] += 1
|
|
180
|
+
entry[:errors] += 1 if span["status"].to_s == STATUS_ERROR
|
|
181
|
+
entry[:total_ms] += span["duration_ms"].to_f
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
84
187
|
def self.create_from_payload(trace, sdk_info = {}, account: nil)
|
|
85
188
|
spans = trace["spans"] || []
|
|
86
189
|
root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
|
|
@@ -193,8 +296,13 @@ module ActionAgent
|
|
|
193
296
|
duration_ms: span["duration_ms"],
|
|
194
297
|
status: span["status"],
|
|
195
298
|
error: attributes["error.message"],
|
|
196
|
-
|
|
197
|
-
|
|
299
|
+
# Two key families: the framework's instrumentation records
|
|
300
|
+
# tool.input.args / tool.output.result, the activeagents-telemetry
|
|
301
|
+
# ruby_llm adapter records tool.arguments / tool.result. The
|
|
302
|
+
# Interactions serializer already reads both; the Tools view's
|
|
303
|
+
# sample arguments come from here and were blank for adapter traffic.
|
|
304
|
+
arguments: attributes["tool.input.args"] || attributes["tool.arguments"],
|
|
305
|
+
result: attributes["tool.output.result"] || attributes["tool.result"]
|
|
198
306
|
}
|
|
199
307
|
end
|
|
200
308
|
end
|
|
@@ -45,6 +45,25 @@ module ActionAgent
|
|
|
45
45
|
|
|
46
46
|
Time.find_zone("UTC").parse(value.to_s).to_i
|
|
47
47
|
end
|
|
48
|
+
|
|
49
|
+
# Epoch second of the +seconds+-wide bucket holding +value+. Buckets
|
|
50
|
+
# are aligned to the Unix epoch, so the same timestamp lands in the
|
|
51
|
+
# same bucket whatever form an adapter produced it in: a Time
|
|
52
|
+
# (PostgreSQL, or any adapter's type-cast pluck), the UTC string
|
|
53
|
+
# SQLite and MySQL return from raw SQL, or an epoch number.
|
|
54
|
+
def bucket_epoch(value, seconds)
|
|
55
|
+
epoch = value.is_a?(Numeric) ? value.to_i : hour_bucket_epoch(value)
|
|
56
|
+
epoch - (epoch % seconds.to_i)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Start epochs of the +count+ consecutive +seconds+-wide buckets that
|
|
60
|
+
# end with the one containing +now+, oldest first. The last bucket is
|
|
61
|
+
# the live one, so the window these describe is
|
|
62
|
+
# [starts.first, starts.last + seconds) — it runs a little past now.
|
|
63
|
+
def bucket_starts(now, seconds, count)
|
|
64
|
+
last = bucket_epoch(now, seconds)
|
|
65
|
+
Array.new(count) { |index| last - ((count - 1 - index) * seconds) }
|
|
66
|
+
end
|
|
48
67
|
end
|
|
49
68
|
end
|
|
50
69
|
end
|
|
@@ -71,16 +71,29 @@ module ActionAgent
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
# The record's owner under the current configuration, or nil.
|
|
74
|
+
#
|
|
75
|
+
# The belongs_to is declared when the class loads, from the
|
|
76
|
+
# configuration at that moment; an owner model configured afterwards
|
|
77
|
+
# (a test, or an initializer that ran late) has the column but not the
|
|
78
|
+
# association, so the foreign key is read directly in that case.
|
|
74
79
|
def owner
|
|
75
80
|
association = self.class.owner_association
|
|
76
|
-
|
|
81
|
+
return nil unless association
|
|
82
|
+
return public_send(association) if respond_to?(association)
|
|
83
|
+
|
|
84
|
+
owner_class = ActionAgent.public_send(CLASS_FOR.fetch(association)).safe_constantize
|
|
85
|
+
owner_id = self[:"#{association}_id"]
|
|
86
|
+
owner_class.find_by(id: owner_id) if owner_class && owner_id
|
|
77
87
|
end
|
|
78
88
|
|
|
79
89
|
# Assigns +owner+ to whichever association this install uses. A no-op
|
|
80
90
|
# when the host app configured no owner model.
|
|
81
91
|
def owner=(record)
|
|
82
92
|
association = self.class.owner_association
|
|
83
|
-
|
|
93
|
+
return unless association
|
|
94
|
+
return public_send(:"#{association}=", record) if respond_to?(:"#{association}=")
|
|
95
|
+
|
|
96
|
+
self[:"#{association}_id"] = record&.id
|
|
84
97
|
end
|
|
85
98
|
end
|
|
86
99
|
end
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# The Metrics view's service overview: one window of traces bucketed into
|
|
5
|
+
# a time series, plus the totals, previous-period deltas, top lists and
|
|
6
|
+
# chart markers the golden-signal tiles, chart panels and right rail draw.
|
|
7
|
+
#
|
|
8
|
+
# The window is read once (TelemetryTrace.pluck_metrics_rows) and then
|
|
9
|
+
# bucketed, classified and percentiled in Ruby, so PostgreSQL and SQLite
|
|
10
|
+
# produce the same numbers. The previous period is read the same way for
|
|
11
|
+
# the deltas, and the tools rail is one grouped read over tool spans.
|
|
12
|
+
# Latency is the trace's total_duration_ms (nearest-rank percentiles);
|
|
13
|
+
# cost is ModelPricing's estimate per trace from the first llm span's
|
|
14
|
+
# model.
|
|
15
|
+
class MetricsReport
|
|
16
|
+
# range => [bucket seconds, bucket count]
|
|
17
|
+
RANGES = {
|
|
18
|
+
"1h" => [ 60, 60 ],
|
|
19
|
+
"24h" => [ 900, 96 ],
|
|
20
|
+
"7d" => [ 7200, 84 ]
|
|
21
|
+
}.freeze
|
|
22
|
+
DEFAULT_RANGE = "24h"
|
|
23
|
+
CUSTOM_RANGE = "custom"
|
|
24
|
+
MAX_WINDOW_HOURS = 24 * 30
|
|
25
|
+
|
|
26
|
+
# A bare `hours` window is cut into about this many buckets, using the
|
|
27
|
+
# smallest of these sizes that gets there.
|
|
28
|
+
TARGET_BUCKETS = 96
|
|
29
|
+
BUCKET_SIZES = [ 60, 120, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400 ].freeze
|
|
30
|
+
|
|
31
|
+
# Error classes in display order. classify_error decides in this order
|
|
32
|
+
# too, except that "tool error" is read off span status rather than the
|
|
33
|
+
# message and so is checked first.
|
|
34
|
+
ERROR_TYPES = [ "429 rate limit", "timeout", "tool error", "provider 5xx", "other" ].freeze
|
|
35
|
+
ERROR_PATTERNS = {
|
|
36
|
+
"429 rate limit" => /\b429\b|rate.?limit|too many requests|quota/i,
|
|
37
|
+
"timeout" => /timed?\s?out|timeout|deadline/i,
|
|
38
|
+
"provider 5xx" => /\b5\d\d\b|overloaded|internal server error|bad gateway|service unavailable|upstream/i
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
TOP_MODELS = 8
|
|
42
|
+
TOP_ACTIONS = 5
|
|
43
|
+
TOP_TOOLS = 5
|
|
44
|
+
|
|
45
|
+
# A bucket is the window's incident when it has at least this many
|
|
46
|
+
# errors and an error rate at least this many times the window's.
|
|
47
|
+
INCIDENT_MIN_ERRORS = 5
|
|
48
|
+
INCIDENT_RATE_FACTOR = 2
|
|
49
|
+
|
|
50
|
+
# One trace, as TelemetryTrace.pluck_metrics_rows returns it.
|
|
51
|
+
Row = Struct.new(
|
|
52
|
+
:timestamp, :agent_class, :agent_action, :duration_ms, :status, :error_message,
|
|
53
|
+
:input_tokens, :output_tokens, :model, :provider, :tool_calls, :tool_errors
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Running totals for one slice of the window — the whole window, a
|
|
57
|
+
# bucket, an agent, a model or an action. Every slice keeps the same
|
|
58
|
+
# counters so one pass over the rows can feed all of them.
|
|
59
|
+
class Tally
|
|
60
|
+
attr_reader :requests, :errors, :errors_by_type, :errors_by_agent, :requests_by_agent,
|
|
61
|
+
:tokens_in, :tokens_out, :cost, :tool_calls, :tool_errors
|
|
62
|
+
|
|
63
|
+
def initialize
|
|
64
|
+
@requests = 0
|
|
65
|
+
@durations = []
|
|
66
|
+
@sorted = nil
|
|
67
|
+
@errors = 0
|
|
68
|
+
@errors_by_type = Hash.new(0)
|
|
69
|
+
@errors_by_agent = Hash.new(0)
|
|
70
|
+
@requests_by_agent = Hash.new(0)
|
|
71
|
+
@tokens_in = 0
|
|
72
|
+
@tokens_out = 0
|
|
73
|
+
@cost = 0.0
|
|
74
|
+
@tool_calls = 0
|
|
75
|
+
@tool_errors = 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @param error_type [String, nil] one of ERROR_TYPES for an ERROR trace
|
|
79
|
+
def add(row, error_type:, cost:)
|
|
80
|
+
@requests += 1
|
|
81
|
+
if row.duration_ms
|
|
82
|
+
@durations << row.duration_ms
|
|
83
|
+
@sorted = nil
|
|
84
|
+
end
|
|
85
|
+
@requests_by_agent[row.agent_class] += 1 if row.agent_class
|
|
86
|
+
if error_type
|
|
87
|
+
@errors += 1
|
|
88
|
+
@errors_by_type[error_type] += 1
|
|
89
|
+
@errors_by_agent[row.agent_class] += 1 if row.agent_class
|
|
90
|
+
end
|
|
91
|
+
@tokens_in += row.input_tokens.to_i
|
|
92
|
+
@tokens_out += row.output_tokens.to_i
|
|
93
|
+
@cost += cost
|
|
94
|
+
@tool_calls += row.tool_calls.to_i
|
|
95
|
+
@tool_errors += row.tool_errors.to_i
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def tokens = @tokens_in + @tokens_out
|
|
99
|
+
|
|
100
|
+
def error_rate = MetricsReport.rate(@errors, @requests)
|
|
101
|
+
|
|
102
|
+
def tool_error_rate = MetricsReport.rate(@tool_errors, @tool_calls)
|
|
103
|
+
|
|
104
|
+
# Nearest-rank percentile of the recorded durations, in whole
|
|
105
|
+
# milliseconds; nil when nothing in the slice carried a duration.
|
|
106
|
+
def percentile(pct)
|
|
107
|
+
@sorted ||= @durations.sort
|
|
108
|
+
MetricsReport.percentile(@sorted, pct)&.round
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Aggregate = Struct.new(:window, :buckets, :by_agent, :by_model, :by_action, keyword_init: true)
|
|
113
|
+
|
|
114
|
+
# Which ERROR_TYPES class an ERROR trace belongs to, first match wins: a
|
|
115
|
+
# tool span that errored makes it a tool error whatever the message
|
|
116
|
+
# says, otherwise the message decides, and anything unrecognised is
|
|
117
|
+
# "other".
|
|
118
|
+
#
|
|
119
|
+
# @param error_message [String, nil] the trace's error_message
|
|
120
|
+
# @param tool_errors [Integer] how many of its tool spans errored
|
|
121
|
+
def self.classify_error(error_message, tool_errors: 0)
|
|
122
|
+
return "tool error" if tool_errors.to_i.positive?
|
|
123
|
+
|
|
124
|
+
message = error_message.to_s
|
|
125
|
+
ERROR_PATTERNS.each { |type, pattern| return type if message.match?(pattern) }
|
|
126
|
+
"other"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Nearest-rank percentile of +sorted+ (ascending): the value at rank
|
|
130
|
+
# ceil(p/100 × n). nil for an empty list.
|
|
131
|
+
def self.percentile(sorted, pct)
|
|
132
|
+
return nil if sorted.empty?
|
|
133
|
+
|
|
134
|
+
rank = (sorted.size * pct / 100.0).ceil
|
|
135
|
+
sorted[rank.clamp(1, sorted.size) - 1]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# +part+ as a percentage of +whole+, two decimals; 0.0 when whole is 0.
|
|
139
|
+
def self.rate(part, whole)
|
|
140
|
+
whole.to_i.positive? ? (part.to_f / whole * 100).round(2) : 0.0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# [range, bucket_seconds, bucket_count, window_hours]. A named range
|
|
144
|
+
# wins; a bare +hours+ is a custom window bucketed to about
|
|
145
|
+
# TARGET_BUCKETS points; neither means the default range.
|
|
146
|
+
def self.resolve_range(range, hours)
|
|
147
|
+
if RANGES.key?(range.to_s)
|
|
148
|
+
seconds, count = RANGES[range.to_s]
|
|
149
|
+
return [ range.to_s, seconds, count, (seconds * count) / 3600 ]
|
|
150
|
+
end
|
|
151
|
+
return resolve_range(DEFAULT_RANGE, nil) if hours.blank?
|
|
152
|
+
|
|
153
|
+
window_hours = hours.to_i.clamp(1, MAX_WINDOW_HOURS)
|
|
154
|
+
window_seconds = window_hours * 3600
|
|
155
|
+
bucket_count = ->(size) { (window_seconds.to_f / size).ceil }
|
|
156
|
+
seconds = BUCKET_SIZES.find { |size| bucket_count.call(size) <= TARGET_BUCKETS } || BUCKET_SIZES.last
|
|
157
|
+
[ CUSTOM_RANGE, seconds, bucket_count.call(seconds), window_hours ]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
attr_reader :range, :bucket_seconds, :bucket_count, :window_hours, :agent
|
|
161
|
+
|
|
162
|
+
# @param traces [ActiveRecord::Relation] every trace the caller can see
|
|
163
|
+
# @param agents [ActiveRecord::Relation, Array<Agent>] the caller's
|
|
164
|
+
# agents, whose versions become deploy markers
|
|
165
|
+
# @param range [String, nil] "1h" | "24h" | "7d"
|
|
166
|
+
# @param hours [Integer, String, nil] a custom window, used when
|
|
167
|
+
# +range+ is absent or unknown
|
|
168
|
+
# @param agent [String, nil] an agent_class to narrow everything to
|
|
169
|
+
# @param now [Time] the end of the window
|
|
170
|
+
def initialize(traces:, agents:, range: nil, hours: nil, agent: nil, now: Time.current)
|
|
171
|
+
@traces = traces
|
|
172
|
+
@agents = agents
|
|
173
|
+
@agent = agent.presence
|
|
174
|
+
@now = now
|
|
175
|
+
@range, @bucket_seconds, @bucket_count, @window_hours = self.class.resolve_range(range, hours)
|
|
176
|
+
@starts = trace_model.bucket_starts(now, @bucket_seconds, @bucket_count)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def window_minutes = (@bucket_seconds * @bucket_count) / 60
|
|
180
|
+
|
|
181
|
+
def window_start = Time.at(@starts.first).utc
|
|
182
|
+
|
|
183
|
+
# A little past +now+: the last bucket is the live one.
|
|
184
|
+
def window_end = Time.at(@starts.last + @bucket_seconds).utc
|
|
185
|
+
|
|
186
|
+
def previous_start = Time.at(@starts.first - (@bucket_seconds * @bucket_count)).utc
|
|
187
|
+
|
|
188
|
+
def to_h
|
|
189
|
+
{
|
|
190
|
+
range: @range,
|
|
191
|
+
bucket_seconds: @bucket_seconds,
|
|
192
|
+
window_minutes: window_minutes,
|
|
193
|
+
agent: @agent,
|
|
194
|
+
environment: environment,
|
|
195
|
+
totals: totals,
|
|
196
|
+
deltas: deltas,
|
|
197
|
+
series: series,
|
|
198
|
+
agents: agents_rail,
|
|
199
|
+
models: models_rail,
|
|
200
|
+
actions: actions_rail,
|
|
201
|
+
tools: tools_rail,
|
|
202
|
+
errors_by_type: errors_by_type,
|
|
203
|
+
markers: markers
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def totals
|
|
208
|
+
window = current.window
|
|
209
|
+
{
|
|
210
|
+
requests: window.requests,
|
|
211
|
+
requests_per_minute: (window.requests.to_f / window_minutes).round(2),
|
|
212
|
+
p50_ms: window.percentile(50),
|
|
213
|
+
p95_ms: window.percentile(95),
|
|
214
|
+
p99_ms: window.percentile(99),
|
|
215
|
+
errors: window.errors,
|
|
216
|
+
error_rate: window.error_rate,
|
|
217
|
+
tokens_in: window.tokens_in,
|
|
218
|
+
tokens_out: window.tokens_out,
|
|
219
|
+
tokens: window.tokens,
|
|
220
|
+
cost: window.cost.round(4),
|
|
221
|
+
cost_per_request: window.requests.positive? ? (window.cost / window.requests).round(6) : 0.0,
|
|
222
|
+
tool_calls: window.tool_calls,
|
|
223
|
+
tool_errors: window.tool_errors,
|
|
224
|
+
tool_error_rate: window.tool_error_rate
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Against the period of the same length just before the window. A
|
|
229
|
+
# percentage change is nil when the previous value is 0 or absent; the
|
|
230
|
+
# error-rate delta is in points and is nil only when the previous
|
|
231
|
+
# period had no requests (no rate to compare against).
|
|
232
|
+
def deltas
|
|
233
|
+
window = current.window
|
|
234
|
+
{
|
|
235
|
+
requests_pct: percent_change(previous.requests, window.requests),
|
|
236
|
+
p50_pct: percent_change(previous.percentile(50), window.percentile(50)),
|
|
237
|
+
error_rate_pt: previous.requests.positive? ? (window.error_rate - previous.error_rate).round(2) : nil,
|
|
238
|
+
tokens_pct: percent_change(previous.tokens, window.tokens),
|
|
239
|
+
cost_pct: percent_change(previous.cost, window.cost)
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# One entry per bucket, oldest first. Percentiles are nil for a bucket
|
|
244
|
+
# with no requests; errors_by_type always carries every ERROR_TYPES
|
|
245
|
+
# key so stacked bars have a stable shape.
|
|
246
|
+
def series
|
|
247
|
+
current.buckets.each_with_index.map do |bucket, index|
|
|
248
|
+
{
|
|
249
|
+
ts: Time.at(@starts[index]).utc.iso8601,
|
|
250
|
+
requests: bucket.requests,
|
|
251
|
+
requests_by_agent: bucket.requests_by_agent,
|
|
252
|
+
p50_ms: bucket.percentile(50),
|
|
253
|
+
p95_ms: bucket.percentile(95),
|
|
254
|
+
p99_ms: bucket.percentile(99),
|
|
255
|
+
errors: bucket.errors,
|
|
256
|
+
errors_by_type: ERROR_TYPES.to_h { |type| [ type, bucket.errors_by_type[type] ] },
|
|
257
|
+
tokens_in: bucket.tokens_in,
|
|
258
|
+
tokens_out: bucket.tokens_out,
|
|
259
|
+
cost: bucket.cost.round(4),
|
|
260
|
+
tool_calls: bucket.tool_calls,
|
|
261
|
+
tool_errors: bucket.tool_errors
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Requests descending. Traces with no agent_class are counted in the
|
|
267
|
+
# totals but have no row here: a row is a filter target, and there is
|
|
268
|
+
# no agent_class to filter by.
|
|
269
|
+
def agents_rail
|
|
270
|
+
total = current.window.requests
|
|
271
|
+
current.by_agent.map do |name, tally|
|
|
272
|
+
{
|
|
273
|
+
name: name,
|
|
274
|
+
requests: tally.requests,
|
|
275
|
+
share_pct: self.class.rate(tally.requests, total),
|
|
276
|
+
p95_ms: tally.percentile(95),
|
|
277
|
+
error_rate: tally.error_rate,
|
|
278
|
+
cost: tally.cost.round(4),
|
|
279
|
+
tokens: tally.tokens
|
|
280
|
+
}
|
|
281
|
+
end.sort_by { |row| [ -row[:requests], row[:name] ] }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Tokens descending, top TOP_MODELS; share is of the window's tokens.
|
|
285
|
+
def models_rail
|
|
286
|
+
total = current.window.tokens
|
|
287
|
+
current.by_model.map do |(model, provider), tally|
|
|
288
|
+
{
|
|
289
|
+
model: model,
|
|
290
|
+
provider: provider,
|
|
291
|
+
requests: tally.requests,
|
|
292
|
+
tokens: tally.tokens,
|
|
293
|
+
share_pct: self.class.rate(tally.tokens, total),
|
|
294
|
+
cost: tally.cost.round(4)
|
|
295
|
+
}
|
|
296
|
+
end.sort_by { |row| [ -row[:tokens], -row[:requests], row[:model] ] }.first(TOP_MODELS)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# p95 descending, top TOP_ACTIONS. An action none of whose traces
|
|
300
|
+
# carried a duration cannot be ranked and is left out.
|
|
301
|
+
def actions_rail
|
|
302
|
+
current.by_action.filter_map do |(agent_class, action), tally|
|
|
303
|
+
p95 = tally.percentile(95)
|
|
304
|
+
next unless p95
|
|
305
|
+
|
|
306
|
+
{ name: "#{agent_class}##{action}", agent: agent_class, requests: tally.requests, p95_ms: p95 }
|
|
307
|
+
end.sort_by { |row| [ -row[:p95_ms], -row[:requests], row[:name] ] }.first(TOP_ACTIONS)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Calls descending, top TOP_TOOLS, from the window's tool spans.
|
|
311
|
+
def tools_rail
|
|
312
|
+
trace_model.tool_span_stats(window_scope).map do |name, stats|
|
|
313
|
+
calls = stats[:calls].to_i
|
|
314
|
+
{
|
|
315
|
+
name: name,
|
|
316
|
+
calls: calls,
|
|
317
|
+
avg_ms: calls.positive? ? (stats[:total_ms].to_f / calls).round : 0,
|
|
318
|
+
error_rate: self.class.rate(stats[:errors], calls)
|
|
319
|
+
}
|
|
320
|
+
end.sort_by { |row| [ -row[:calls], row[:name] ] }.first(TOP_TOOLS)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Every ERROR_TYPES class in order, zero counts included.
|
|
324
|
+
def errors_by_type
|
|
325
|
+
ERROR_TYPES.map { |type| { type: type, count: current.window.errors_by_type[type] } }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Deploys (agent versions created inside the window) and at most one
|
|
329
|
+
# incident (the bucket with the error spike), by time. Ruby's sort is
|
|
330
|
+
# not stable, so ties (versions saved in the same second) keep their
|
|
331
|
+
# creation order explicitly.
|
|
332
|
+
def markers
|
|
333
|
+
(deploy_markers + incident_markers)
|
|
334
|
+
.each_with_index
|
|
335
|
+
.sort_by { |marker, position| [ marker[:ts], position ] }
|
|
336
|
+
.map(&:first)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# The environment most of the window's traces report, nil when none do.
|
|
340
|
+
def environment
|
|
341
|
+
counts = window_scope.group(:environment).count.reject { |env, _count| env.blank? }
|
|
342
|
+
counts.min_by { |env, count| [ -count, env ] }&.first
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
private
|
|
346
|
+
|
|
347
|
+
def trace_model = ActionAgent.trace_model
|
|
348
|
+
|
|
349
|
+
def window_scope = narrowed(@traces.where(timestamp: window_start...window_end))
|
|
350
|
+
|
|
351
|
+
def previous_scope = narrowed(@traces.where(timestamp: previous_start...window_start))
|
|
352
|
+
|
|
353
|
+
def narrowed(scope) = @agent ? scope.where(agent_class: @agent) : scope
|
|
354
|
+
|
|
355
|
+
# The one pass over the window: every row feeds the window total, its
|
|
356
|
+
# bucket, its agent, its model and its action.
|
|
357
|
+
def current
|
|
358
|
+
@current ||= begin
|
|
359
|
+
aggregate = Aggregate.new(
|
|
360
|
+
window: Tally.new,
|
|
361
|
+
buckets: Array.new(@bucket_count) { Tally.new },
|
|
362
|
+
by_agent: Hash.new { |hash, key| hash[key] = Tally.new },
|
|
363
|
+
by_model: Hash.new { |hash, key| hash[key] = Tally.new },
|
|
364
|
+
by_action: Hash.new { |hash, key| hash[key] = Tally.new }
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
rows_for(window_scope).each do |row|
|
|
368
|
+
index = bucket_index(row.timestamp)
|
|
369
|
+
next unless index
|
|
370
|
+
|
|
371
|
+
error_type = error_type_for(row)
|
|
372
|
+
cost = cost_for(row)
|
|
373
|
+
|
|
374
|
+
aggregate.window.add(row, error_type: error_type, cost: cost)
|
|
375
|
+
aggregate.buckets[index].add(row, error_type: error_type, cost: cost)
|
|
376
|
+
aggregate.by_agent[row.agent_class].add(row, error_type: error_type, cost: cost) if row.agent_class
|
|
377
|
+
aggregate.by_model[[ row.model || "unknown", row.provider ]].add(row, error_type: error_type, cost: cost)
|
|
378
|
+
if row.agent_class && row.agent_action
|
|
379
|
+
aggregate.by_action[[ row.agent_class, row.agent_action ]].add(row, error_type: error_type, cost: cost)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
aggregate
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# The previous period only feeds the deltas, so one tally is enough.
|
|
388
|
+
def previous
|
|
389
|
+
@previous ||= rows_for(previous_scope).each_with_object(Tally.new) do |row, tally|
|
|
390
|
+
tally.add(row, error_type: error_type_for(row), cost: cost_for(row))
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def rows_for(scope)
|
|
395
|
+
trace_model.pluck_metrics_rows(scope).map { |values| Row.new(*values) }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def bucket_index(timestamp)
|
|
399
|
+
index = (trace_model.bucket_epoch(timestamp, @bucket_seconds) - @starts.first) / @bucket_seconds
|
|
400
|
+
index if index >= 0 && index < @bucket_count
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def error_type_for(row)
|
|
404
|
+
return nil unless row.status == TelemetryTrace::STATUS_ERROR
|
|
405
|
+
|
|
406
|
+
self.class.classify_error(row.error_message, tool_errors: row.tool_errors)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def cost_for(row)
|
|
410
|
+
ModelPricing.estimate(model: row.model, input_tokens: row.input_tokens, output_tokens: row.output_tokens) || 0.0
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def percent_change(before, after)
|
|
414
|
+
return nil if before.nil? || after.nil? || before.to_f.zero?
|
|
415
|
+
|
|
416
|
+
(((after.to_f - before.to_f) / before.to_f) * 100).round(2)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# One marker per agent version created inside the window, for the
|
|
420
|
+
# caller's agents (narrowed to the filtered agent's versions when a
|
|
421
|
+
# filter is on). A version that changed the instructions is labelled
|
|
422
|
+
# as such — that is the deploy a latency or error shift most often
|
|
423
|
+
# traces back to.
|
|
424
|
+
#
|
|
425
|
+
# The comparison needs each version's predecessor. Two reads cover
|
|
426
|
+
# them all: the window's versions, then the predecessors that fall
|
|
427
|
+
# before the window (a predecessor inside it is already loaded).
|
|
428
|
+
# Version numbers are sequential per agent — Agent creates each one
|
|
429
|
+
# as latest + 1 and they are only destroyed with their agent — so the
|
|
430
|
+
# predecessor of version n is version n - 1.
|
|
431
|
+
def deploy_markers
|
|
432
|
+
agent_ids = @agents.respond_to?(:pluck) ? @agents.pluck(:id) : Array(@agents).map(&:id)
|
|
433
|
+
versions = AgentVersion
|
|
434
|
+
.includes(:agent)
|
|
435
|
+
.where(agent_id: agent_ids, created_at: window_start...window_end)
|
|
436
|
+
.order(:created_at, :id)
|
|
437
|
+
.to_a
|
|
438
|
+
predecessors = predecessors_of(versions)
|
|
439
|
+
|
|
440
|
+
versions.filter_map do |version|
|
|
441
|
+
agent = version.agent
|
|
442
|
+
next if agent.nil?
|
|
443
|
+
next if @agent && agent.telemetry_agent_class != @agent
|
|
444
|
+
|
|
445
|
+
previous = predecessors[[ version.agent_id, version.version_number - 1 ]]
|
|
446
|
+
instructions_changed = snapshot_of(version)["instructions"].to_s != snapshot_of(previous)["instructions"].to_s
|
|
447
|
+
{
|
|
448
|
+
kind: "deploy",
|
|
449
|
+
ts: version.created_at.utc.iso8601,
|
|
450
|
+
label: "#{instructions_changed ? 'instructions ' : ''}v#{version.version_number} · #{agent.name}",
|
|
451
|
+
agent: agent.name
|
|
452
|
+
}
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# { [agent_id, version_number] => version } holding the predecessor of
|
|
457
|
+
# every version in +versions+: the ones among them, plus one read for
|
|
458
|
+
# the rest.
|
|
459
|
+
def predecessors_of(versions)
|
|
460
|
+
loaded = versions.index_by { |version| [ version.agent_id, version.version_number ] }
|
|
461
|
+
missing = versions
|
|
462
|
+
.map { |version| [ version.agent_id, version.version_number - 1 ] }
|
|
463
|
+
.reject { |key| key.last < 1 || loaded.key?(key) }
|
|
464
|
+
return loaded if missing.empty?
|
|
465
|
+
|
|
466
|
+
missing
|
|
467
|
+
.group_by(&:first)
|
|
468
|
+
.map { |agent_id, keys| AgentVersion.where(agent_id: agent_id, version_number: keys.map(&:last)) }
|
|
469
|
+
.reduce(:or)
|
|
470
|
+
.each_with_object(loaded) { |version, map| map[[ version.agent_id, version.version_number ]] = version }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Snapshots are written with symbol keys and read back with strings.
|
|
474
|
+
def snapshot_of(version)
|
|
475
|
+
(version&.configuration_snapshot || {}).to_h.transform_keys(&:to_s)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# The bucket with the most errors, when it is a real spike: at least
|
|
479
|
+
# INCIDENT_MIN_ERRORS errors and INCIDENT_RATE_FACTOR times the window's
|
|
480
|
+
# error rate. Labelled by its dominant error class and attributed to
|
|
481
|
+
# the agent that errored most in it.
|
|
482
|
+
def incident_markers
|
|
483
|
+
window_rate = current.window.error_rate
|
|
484
|
+
return [] unless window_rate.positive?
|
|
485
|
+
|
|
486
|
+
buckets = current.buckets
|
|
487
|
+
index = buckets.each_index.max_by { |i| [ buckets[i].errors, -i ] }
|
|
488
|
+
bucket = buckets[index]
|
|
489
|
+
return [] if bucket.errors < INCIDENT_MIN_ERRORS
|
|
490
|
+
return [] if bucket.error_rate < window_rate * INCIDENT_RATE_FACTOR
|
|
491
|
+
|
|
492
|
+
type, _position = ERROR_TYPES.each_with_index.max_by { |name, i| [ bucket.errors_by_type[name], -i ] }
|
|
493
|
+
agent_class, _count = bucket.errors_by_agent.min_by { |name, count| [ -count, name ] }
|
|
494
|
+
|
|
495
|
+
[ { kind: "incident", ts: Time.at(@starts[index]).utc.iso8601, label: "#{type} spike", agent: agent_class } ]
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|