ruby_llm-agents 3.15.1 → 3.16.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/app/controllers/concerns/ruby_llm/agents/paginatable.rb +29 -8
- data/app/controllers/ruby_llm/agents/agents_controller.rb +48 -44
- data/app/controllers/ruby_llm/agents/analytics_controller.rb +38 -72
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +69 -56
- data/app/controllers/ruby_llm/agents/executions_controller.rb +25 -41
- data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -45
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +67 -47
- data/app/models/ruby_llm/agents/execution/analytics.rb +174 -133
- data/app/models/ruby_llm/agents/execution/breakdown.rb +243 -0
- data/app/models/ruby_llm/agents/execution/scopes.rb +39 -3
- data/app/models/ruby_llm/agents/execution.rb +23 -5
- data/app/services/ruby_llm/agents/agent_registry.rb +75 -12
- data/app/views/ruby_llm/agents/agents/index.html.erb +24 -7
- data/app/views/ruby_llm/agents/agents/show.html.erb +3 -2
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -7
- data/app/views/ruby_llm/agents/executions/_list.html.erb +1 -52
- data/app/views/ruby_llm/agents/requests/index.html.erb +8 -57
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +1 -52
- data/app/views/ruby_llm/agents/shared/_pagination.html.erb +66 -0
- data/app/views/ruby_llm/agents/shared/query_timeout.html.erb +18 -0
- data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -0
- data/app/views/ruby_llm/agents/tenants/index.html.erb +1 -1
- data/app/views/ruby_llm/agents/tenants/show.html.erb +6 -5
- data/config/routes.rb +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_executions_analytics_index_migration.rb.tt +50 -0
- data/lib/generators/ruby_llm_agents/templates/add_root_execution_list_index_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +5 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +26 -0
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +44 -0
- data/lib/ruby_llm/agents/core/configuration.rb +20 -0
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +39 -1
- data/lib/ruby_llm/agents/rails/engine.rb +87 -7
- metadata +7 -2
|
@@ -22,6 +22,9 @@ module RubyLLM
|
|
|
22
22
|
CSV_COLUMNS = %w[id agent_type status model_id total_tokens total_cost
|
|
23
23
|
duration_ms created_at error_class error_message].freeze
|
|
24
24
|
|
|
25
|
+
# Seconds the totals strip may spend before the list renders without it
|
|
26
|
+
TOTALS_TIMEOUT = 1
|
|
27
|
+
|
|
25
28
|
# Lists all executions with filtering and pagination
|
|
26
29
|
#
|
|
27
30
|
# @return [void]
|
|
@@ -39,7 +42,7 @@ module RubyLLM
|
|
|
39
42
|
#
|
|
40
43
|
# @return [void]
|
|
41
44
|
def show
|
|
42
|
-
@execution = tenant_scoped_executions.includes(:detail
|
|
45
|
+
@execution = tenant_scoped_executions.includes(:detail).find(params[:id])
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
# Handles filter search requests via Turbo Stream
|
|
@@ -83,7 +86,7 @@ module RubyLLM
|
|
|
83
86
|
self.response_body = Enumerator.new do |yielder|
|
|
84
87
|
yielder << CSV.generate_line(CSV_COLUMNS)
|
|
85
88
|
|
|
86
|
-
filtered_executions.find_each(batch_size: 1000) do |execution|
|
|
89
|
+
filtered_executions.preload(:error_detail).find_each(batch_size: 1000) do |execution|
|
|
87
90
|
yielder << generate_csv_row(execution)
|
|
88
91
|
end
|
|
89
92
|
end
|
|
@@ -124,50 +127,35 @@ module RubyLLM
|
|
|
124
127
|
@statuses = Execution.statuses.keys
|
|
125
128
|
end
|
|
126
129
|
|
|
127
|
-
# Returns distinct agent types from execution history
|
|
128
|
-
#
|
|
129
|
-
# Memoized to avoid duplicate queries within a request.
|
|
130
|
-
# Uses tenant_scoped_executions to respect multi-tenancy filtering.
|
|
131
|
-
#
|
|
132
|
-
# @return [Array<String>] Agent type names
|
|
133
|
-
def available_agent_types
|
|
134
|
-
@available_agent_types ||= tenant_scoped_executions.distinct.pluck(:agent_type)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Returns distinct model IDs from execution history
|
|
138
|
-
#
|
|
139
|
-
# Memoized to avoid duplicate queries within a request.
|
|
140
|
-
# Uses tenant_scoped_executions to respect multi-tenancy filtering.
|
|
141
|
-
#
|
|
142
|
-
# @return [Array<String>] Model IDs
|
|
143
|
-
def available_model_ids
|
|
144
|
-
@available_model_ids ||= tenant_scoped_executions.where.not(model_id: nil).distinct.pluck(:model_id).sort
|
|
145
|
-
end
|
|
146
|
-
|
|
147
130
|
# Loads paginated executions and associated statistics
|
|
148
131
|
#
|
|
149
132
|
# Sets @executions, @pagination, @sort_params, and @filter_stats instance variables
|
|
150
133
|
# for use in views.
|
|
151
134
|
#
|
|
135
|
+
# The list itself is an index walk and stays fast at any table size. The
|
|
136
|
+
# totals strip is not: it sums every execution the filters match, which
|
|
137
|
+
# with no time range is the whole table. So the totals are best-effort
|
|
138
|
+
# and cached — when the database cannot produce them in TOTALS_TIMEOUT
|
|
139
|
+
# the strip shows a placeholder and pagination falls back to prev/next,
|
|
140
|
+
# instead of the page waiting on (or timing out behind) an aggregate.
|
|
141
|
+
#
|
|
152
142
|
# @return [void]
|
|
153
143
|
def load_executions_with_stats
|
|
154
144
|
@sort_params = parse_sort_params
|
|
155
|
-
|
|
145
|
+
scope = filtered_executions
|
|
146
|
+
|
|
147
|
+
filters = request.query_parameters.except("page", "sort", "direction")
|
|
148
|
+
@filter_stats = cached_stats(:executions_totals, filters.to_query) do
|
|
149
|
+
Execution.best_effort(timeout: TOTALS_TIMEOUT) { scope.totals }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The count from the same aggregate drives pagination, so a page
|
|
153
|
+
# never runs a separate COUNT.
|
|
154
|
+
result = paginate(scope.preload(:error_detail),
|
|
155
|
+
sort_params: @sort_params,
|
|
156
|
+
total_count: @filter_stats&.fetch(:total_count))
|
|
156
157
|
@executions = result[:records]
|
|
157
158
|
@pagination = result[:pagination]
|
|
158
|
-
load_filter_stats
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Calculates aggregate statistics for the current filter
|
|
162
|
-
#
|
|
163
|
-
# @return [void]
|
|
164
|
-
def load_filter_stats
|
|
165
|
-
scope = filtered_executions
|
|
166
|
-
@filter_stats = {
|
|
167
|
-
total_count: scope.count,
|
|
168
|
-
total_cost: scope.sum(:total_cost) || 0,
|
|
169
|
-
total_tokens: scope.sum(:total_tokens) || 0
|
|
170
|
-
}
|
|
171
159
|
end
|
|
172
160
|
|
|
173
161
|
# Builds a filtered execution scope based on request params
|
|
@@ -212,11 +200,7 @@ module RubyLLM
|
|
|
212
200
|
scope = scope.where("attempts_count > 1") if params[:has_retries].present?
|
|
213
201
|
|
|
214
202
|
# Only show root executions - children are nested under parents
|
|
215
|
-
scope
|
|
216
|
-
|
|
217
|
-
# Eager load children for grouping and detail for error_message, which
|
|
218
|
-
# the list renders per row (otherwise an N+1 on error rows).
|
|
219
|
-
scope.includes(:child_executions, :detail)
|
|
203
|
+
scope.where(parent_execution_id: nil)
|
|
220
204
|
end
|
|
221
205
|
|
|
222
206
|
# Checks whether turbo-rails is available in the host application
|
|
@@ -11,43 +11,43 @@ module RubyLLM
|
|
|
11
11
|
class RequestsController < ApplicationController
|
|
12
12
|
include Paginatable
|
|
13
13
|
|
|
14
|
+
# Seconds the header totals may spend before the list renders without them
|
|
15
|
+
TOTALS_TIMEOUT = 1
|
|
16
|
+
|
|
17
|
+
# Progressively wider windows searched for the most recent requests
|
|
18
|
+
RECENT_WINDOWS = [1.day, 7.days, 30.days].freeze
|
|
19
|
+
|
|
14
20
|
# Lists all tracked requests with aggregated stats
|
|
15
21
|
#
|
|
22
|
+
# A request is a GROUP BY over its executions, so listing "the latest
|
|
23
|
+
# 25" naively aggregates every tracked execution ever recorded before
|
|
24
|
+
# it can sort. Two things keep the default view off that path: the
|
|
25
|
+
# page of request ids is found in a recent window first (see
|
|
26
|
+
# #recent_request_ids) and only those requests are aggregated, and the
|
|
27
|
+
# header totals are best-effort.
|
|
28
|
+
#
|
|
16
29
|
# @return [void]
|
|
17
30
|
def index
|
|
18
31
|
@sort_column = sanitize_sort_column(params[:sort])
|
|
19
32
|
@sort_direction = (params[:direction] == "asc") ? "asc" : "desc"
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
.where.not(request_id: [nil, ""])
|
|
23
|
-
.select(
|
|
24
|
-
"request_id",
|
|
25
|
-
"COUNT(*) AS call_count",
|
|
26
|
-
"SUM(total_cost) AS total_cost",
|
|
27
|
-
"SUM(total_tokens) AS total_tokens",
|
|
28
|
-
"MIN(started_at) AS started_at",
|
|
29
|
-
"MAX(completed_at) AS completed_at",
|
|
30
|
-
"SUM(duration_ms) AS total_duration_ms",
|
|
31
|
-
"GROUP_CONCAT(DISTINCT agent_type) AS agent_types_list",
|
|
32
|
-
"GROUP_CONCAT(DISTINCT status) AS statuses_list",
|
|
33
|
-
"MAX(created_at) AS latest_created_at"
|
|
34
|
-
)
|
|
35
|
-
.group(:request_id)
|
|
36
|
-
|
|
37
|
-
# Apply time filter
|
|
34
|
+
tracked = Execution.where.not(request_id: [nil, ""])
|
|
38
35
|
days = params[:days].to_i
|
|
39
|
-
|
|
36
|
+
tracked = tracked.where("created_at >= ?", days.days.ago) if days > 0
|
|
37
|
+
|
|
38
|
+
# One query for the distinct request count and total cost; the count
|
|
39
|
+
# is shared with pagination instead of being run a second time.
|
|
40
|
+
@stats = cached_stats(:requests_totals, days) do
|
|
41
|
+
Execution.best_effort(timeout: TOTALS_TIMEOUT) do
|
|
42
|
+
total_requests, total_cost = tracked
|
|
43
|
+
.pick(Arel.sql("COUNT(DISTINCT request_id)"), Arel.sql("COALESCE(SUM(total_cost), 0)"))
|
|
44
|
+
{total_requests: total_requests.to_i, total_cost: (total_cost || 0).to_d.round(6)}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
40
47
|
|
|
41
|
-
result = paginate_requests(
|
|
48
|
+
result = paginate_requests(tracked, total_count: @stats&.fetch(:total_requests))
|
|
42
49
|
@requests = result[:records]
|
|
43
50
|
@pagination = result[:pagination]
|
|
44
|
-
|
|
45
|
-
# Stats
|
|
46
|
-
total_scope = Execution.where.not(request_id: [nil, ""])
|
|
47
|
-
@stats = {
|
|
48
|
-
total_requests: total_scope.distinct.count(:request_id),
|
|
49
|
-
total_cost: total_scope.sum(:total_cost) || 0
|
|
50
|
-
}
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
# Shows a single tracked request with all its executions
|
|
@@ -55,9 +55,16 @@ module RubyLLM
|
|
|
55
55
|
# @return [void]
|
|
56
56
|
def show
|
|
57
57
|
@request_id = params[:id]
|
|
58
|
+
|
|
59
|
+
# Loaded once: the summary is derived from the same handful of rows
|
|
60
|
+
# the page lists, instead of nine more queries against them. (Two of
|
|
61
|
+
# those were DISTINCT plucks on an ordered relation, which PostgreSQL
|
|
62
|
+
# rejects, so this page raised there.)
|
|
58
63
|
@executions = Execution
|
|
59
64
|
.where(request_id: @request_id)
|
|
65
|
+
.preload(:error_detail)
|
|
60
66
|
.order(started_at: :asc)
|
|
67
|
+
.to_a
|
|
61
68
|
|
|
62
69
|
if @executions.empty?
|
|
63
70
|
redirect_to ruby_llm_agents.requests_path,
|
|
@@ -66,15 +73,15 @@ module RubyLLM
|
|
|
66
73
|
end
|
|
67
74
|
|
|
68
75
|
@summary = {
|
|
69
|
-
call_count: @executions.
|
|
70
|
-
total_cost: @executions.sum
|
|
71
|
-
total_tokens: @executions.sum
|
|
72
|
-
started_at: @executions.
|
|
73
|
-
completed_at: @executions.
|
|
74
|
-
agent_types: @executions.
|
|
75
|
-
models_used: @executions.
|
|
76
|
-
all_successful: @executions.
|
|
77
|
-
error_count: @executions.
|
|
76
|
+
call_count: @executions.size,
|
|
77
|
+
total_cost: @executions.sum { |e| e.total_cost || 0 },
|
|
78
|
+
total_tokens: @executions.sum { |e| e.total_tokens || 0 },
|
|
79
|
+
started_at: @executions.filter_map(&:started_at).min,
|
|
80
|
+
completed_at: @executions.filter_map(&:completed_at).max,
|
|
81
|
+
agent_types: @executions.map(&:agent_type).uniq,
|
|
82
|
+
models_used: @executions.map(&:model_id).uniq,
|
|
83
|
+
all_successful: @executions.all?(&:status_success?),
|
|
84
|
+
error_count: @executions.count(&:status_error?)
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
if @summary[:started_at] && @summary[:completed_at]
|
|
@@ -90,25 +97,90 @@ module RubyLLM
|
|
|
90
97
|
ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
|
|
91
98
|
end
|
|
92
99
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
# Comma-separated DISTINCT values of a column per group.
|
|
101
|
+
# GROUP_CONCAT exists on SQLite and MySQL; PostgreSQL spells it STRING_AGG.
|
|
102
|
+
def distinct_list_sql(column)
|
|
103
|
+
if Execution.connection.adapter_name.downcase.include?("postg")
|
|
104
|
+
"STRING_AGG(DISTINCT #{column}, ',')"
|
|
105
|
+
else
|
|
106
|
+
"GROUP_CONCAT(DISTINCT #{column})"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
96
109
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
110
|
+
# Aggregates executions into one row per request
|
|
111
|
+
def request_rows(scope)
|
|
112
|
+
scope
|
|
113
|
+
.select(
|
|
114
|
+
"request_id",
|
|
115
|
+
"COUNT(*) AS call_count",
|
|
116
|
+
"SUM(total_cost) AS total_cost",
|
|
117
|
+
"SUM(total_tokens) AS total_tokens",
|
|
118
|
+
"MIN(started_at) AS started_at",
|
|
119
|
+
"MAX(completed_at) AS completed_at",
|
|
120
|
+
"SUM(duration_ms) AS total_duration_ms",
|
|
121
|
+
"#{distinct_list_sql("agent_type")} AS agent_types_list",
|
|
122
|
+
"#{distinct_list_sql("status")} AS statuses_list",
|
|
123
|
+
"MAX(created_at) AS latest_created_at"
|
|
124
|
+
)
|
|
125
|
+
.group(:request_id)
|
|
126
|
+
end
|
|
101
127
|
|
|
102
|
-
|
|
128
|
+
# Request ids for one page of the newest-first list
|
|
129
|
+
#
|
|
130
|
+
# The requests active most recently are, by definition, the ones with
|
|
131
|
+
# an execution in the most recent stretch of time. So instead of
|
|
132
|
+
# ranking every request, rank the ones seen in the last day; if that
|
|
133
|
+
# does not fill the page, the last week; and so on, ending with no
|
|
134
|
+
# bound at all. Each attempt is a bounded read of the created_at index,
|
|
135
|
+
# and the first one that fills the page is exact.
|
|
136
|
+
#
|
|
137
|
+
# @param tracked [ActiveRecord::Relation] Executions that carry a request_id
|
|
138
|
+
# @param offset [Integer] Requests to skip
|
|
139
|
+
# @param limit [Integer] Requests to return
|
|
140
|
+
# @return [Array<String>] Request ids, newest activity first
|
|
141
|
+
def recent_request_ids(tracked, offset:, limit:)
|
|
142
|
+
[*RECENT_WINDOWS, nil].each do |window|
|
|
143
|
+
scope = window ? tracked.where("created_at >= ?", window.ago) : tracked
|
|
144
|
+
ids = scope.group(:request_id)
|
|
145
|
+
.order(Arel.sql("MAX(created_at) DESC"))
|
|
146
|
+
.offset(offset).limit(limit)
|
|
147
|
+
.pluck(:request_id)
|
|
148
|
+
|
|
149
|
+
return ids if ids.size >= limit || window.nil?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Loads one page of requests
|
|
154
|
+
#
|
|
155
|
+
# Fetches one row more than the page holds, so a next page can be
|
|
156
|
+
# detected when the total count is unavailable.
|
|
157
|
+
#
|
|
158
|
+
# @param tracked [ActiveRecord::Relation] Executions that carry a request_id
|
|
159
|
+
# @param total_count [Integer, nil] Distinct requests, when known
|
|
160
|
+
# @return [Hash] :records and :pagination, as Paginatable#paginate
|
|
161
|
+
def paginate_requests(tracked, total_count:)
|
|
162
|
+
page = [(params[:page] || 1).to_i, 1].max
|
|
163
|
+
per_page = RubyLLM::Agents.configuration.per_page
|
|
103
164
|
offset = (page - 1) * per_page
|
|
104
165
|
|
|
166
|
+
records = if @sort_column == "latest_created_at" && @sort_direction == "desc"
|
|
167
|
+
ids = recent_request_ids(tracked, offset: offset, limit: per_page + 1)
|
|
168
|
+
request_rows(tracked.where(request_id: ids)).sort_by { |row| ids.index(row.request_id) }
|
|
169
|
+
else
|
|
170
|
+
# Sorting on an aggregate has to aggregate every request first.
|
|
171
|
+
request_rows(tracked)
|
|
172
|
+
.order("#{@sort_column} #{@sort_direction.upcase}")
|
|
173
|
+
.offset(offset).limit(per_page + 1).to_a
|
|
174
|
+
end
|
|
175
|
+
|
|
105
176
|
{
|
|
106
|
-
records:
|
|
177
|
+
records: records.first(per_page),
|
|
107
178
|
pagination: {
|
|
108
179
|
current_page: page,
|
|
109
180
|
per_page: per_page,
|
|
110
181
|
total_count: total_count,
|
|
111
|
-
total_pages: (total_count.to_f / per_page).ceil
|
|
182
|
+
total_pages: total_count && (total_count.to_f / per_page).ceil,
|
|
183
|
+
next_page: records.size > per_page
|
|
112
184
|
}
|
|
113
185
|
}
|
|
114
186
|
end
|
|
@@ -38,14 +38,27 @@ module RubyLLM
|
|
|
38
38
|
|
|
39
39
|
# Shows a single tenant's budget details
|
|
40
40
|
#
|
|
41
|
+
# Usage figures cover the current month, from one grouped scan of it
|
|
42
|
+
# (see Execution.breakdown). A tenant's executions are scattered across
|
|
43
|
+
# the whole table, so the all-time versions of these figures were the
|
|
44
|
+
# most expensive queries in the dashboard.
|
|
45
|
+
#
|
|
41
46
|
# @return [void]
|
|
42
47
|
def show
|
|
43
48
|
@tenant = TenantBudget.find(params[:id])
|
|
44
|
-
@executions = tenant_executions(@tenant.tenant_id).recent
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
@executions = tenant_executions(@tenant.tenant_id).preload(:error_detail).recent(10)
|
|
50
|
+
|
|
51
|
+
month = Execution::Breakdown.new(cached_tenant_stats(:month) do
|
|
52
|
+
@tenant.executions.where(created_at: Time.current.all_month).breakdown.rows
|
|
53
|
+
end)
|
|
54
|
+
@usage_stats = calculate_usage_stats(@tenant, month)
|
|
55
|
+
@usage_by_agent = month.agent_stats.transform_values do |stats|
|
|
56
|
+
{cost: stats[:total_cost], tokens: stats[:total_tokens], count: stats[:count]}
|
|
57
|
+
end
|
|
58
|
+
@usage_by_model = month.configured_model_usage.to_h do |usage|
|
|
59
|
+
[usage[:model_id], {cost: usage[:cost], tokens: usage[:tokens], count: usage[:runs]}]
|
|
60
|
+
end
|
|
61
|
+
load_tenant_analytics(month)
|
|
49
62
|
end
|
|
50
63
|
|
|
51
64
|
# Renders the edit form for a tenant budget
|
|
@@ -101,45 +114,56 @@ module RubyLLM
|
|
|
101
114
|
Execution.by_tenant(tenant_id)
|
|
102
115
|
end
|
|
103
116
|
|
|
117
|
+
# Caches one of the tenant page's figures
|
|
118
|
+
def cached_tenant_stats(name, expires_in: 5.minutes, &block)
|
|
119
|
+
cached_stats(:tenant, @tenant.tenant_id, name, expires_in: expires_in, &block)
|
|
120
|
+
end
|
|
121
|
+
|
|
104
122
|
# Calculates usage statistics for a tenant
|
|
105
123
|
#
|
|
106
124
|
# @param tenant [TenantBudget] The tenant budget record
|
|
107
|
-
# @
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
monthly_spend = month_scope.sum(:total_cost) || 0
|
|
115
|
-
daily_tokens = today_scope.sum(:total_tokens) || 0
|
|
116
|
-
monthly_tokens = month_scope.sum(:total_tokens) || 0
|
|
125
|
+
# @param month [Execution::Breakdown] The tenant's executions this month
|
|
126
|
+
# @return [Hash] Usage statistics; the total_* keys cover this month
|
|
127
|
+
def calculate_usage_stats(tenant, month)
|
|
128
|
+
totals = month.totals
|
|
129
|
+
daily_spend, daily_tokens = tenant_executions(tenant.tenant_id)
|
|
130
|
+
.where("created_at >= ?", Time.current.beginning_of_day)
|
|
131
|
+
.pick(Arel.sql("COALESCE(SUM(total_cost), 0)"), Arel.sql("COALESCE(SUM(total_tokens), 0)"))
|
|
117
132
|
|
|
118
133
|
{
|
|
119
134
|
daily_spend: daily_spend,
|
|
120
|
-
monthly_spend:
|
|
135
|
+
monthly_spend: totals[:cost],
|
|
121
136
|
daily_tokens: daily_tokens,
|
|
122
|
-
monthly_tokens:
|
|
137
|
+
monthly_tokens: totals[:tokens],
|
|
123
138
|
daily_spend_percentage: percentage_used(daily_spend, tenant.effective_daily_limit),
|
|
124
|
-
monthly_spend_percentage: percentage_used(
|
|
139
|
+
monthly_spend_percentage: percentage_used(totals[:cost], tenant.effective_monthly_limit),
|
|
125
140
|
daily_token_percentage: percentage_used(daily_tokens, tenant.effective_daily_token_limit),
|
|
126
|
-
monthly_token_percentage: percentage_used(
|
|
127
|
-
total_executions:
|
|
128
|
-
total_cost:
|
|
129
|
-
total_tokens:
|
|
141
|
+
monthly_token_percentage: percentage_used(totals[:tokens], tenant.effective_monthly_token_limit),
|
|
142
|
+
total_executions: totals[:total],
|
|
143
|
+
total_cost: totals[:cost],
|
|
144
|
+
total_tokens: totals[:tokens],
|
|
145
|
+
success_count: totals[:success]
|
|
130
146
|
}
|
|
131
147
|
end
|
|
132
148
|
|
|
133
149
|
# Loads trend data and period comparison for the tenant analytics section.
|
|
134
150
|
#
|
|
151
|
+
# @param month [Execution::Breakdown] The tenant's executions this month
|
|
135
152
|
# @return [void]
|
|
136
|
-
def load_tenant_analytics
|
|
153
|
+
def load_tenant_analytics(month)
|
|
137
154
|
# 30-day daily cost/tokens trend
|
|
138
|
-
@daily_trend =
|
|
155
|
+
@daily_trend = cached_tenant_stats(:daily_trend) do
|
|
156
|
+
@tenant.usage_by_day(period: 30.days.ago..Time.current)
|
|
157
|
+
end
|
|
139
158
|
|
|
140
|
-
# Period comparison: this month vs last month
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
# Period comparison: this month vs last month. Last month no longer
|
|
160
|
+
# changes, so one aggregate of it is kept for an hour.
|
|
161
|
+
totals = month.totals
|
|
162
|
+
last = cached_tenant_stats(:last_month, expires_in: 1.hour) do
|
|
163
|
+
@tenant.executions.where(created_at: 1.month.ago.all_month).totals
|
|
164
|
+
end
|
|
165
|
+
this_month = {cost: totals[:cost], tokens: totals[:tokens], executions: totals[:total]}
|
|
166
|
+
last_month = {cost: last[:total_cost], tokens: last[:total_tokens], executions: last[:total_count]}
|
|
143
167
|
@period_comparison = {
|
|
144
168
|
this_month: this_month,
|
|
145
169
|
last_month: last_month,
|
|
@@ -153,10 +177,10 @@ module RubyLLM
|
|
|
153
177
|
@period_comparison[:avg_cost_last], @period_comparison[:avg_cost_this]
|
|
154
178
|
)
|
|
155
179
|
|
|
156
|
-
# Error cost: money spent on failed executions
|
|
157
|
-
|
|
158
|
-
@error_cost =
|
|
159
|
-
@error_count =
|
|
180
|
+
# Error cost: money spent on failed executions this month
|
|
181
|
+
error_cost = month.error_cost
|
|
182
|
+
@error_cost = error_cost[:total_cost]
|
|
183
|
+
@error_count = error_cost[:total_count]
|
|
160
184
|
end
|
|
161
185
|
|
|
162
186
|
# Calculates percentage change between two values
|
|
@@ -167,25 +191,21 @@ module RubyLLM
|
|
|
167
191
|
((new_val.to_f - old_val.to_f) / old_val.to_f * 100).round(1)
|
|
168
192
|
end
|
|
169
193
|
|
|
170
|
-
#
|
|
171
|
-
#
|
|
194
|
+
# Builds the cost and last-execution lookups for the index view
|
|
195
|
+
#
|
|
196
|
+
# Read from the counter columns each tenant row already carries (they
|
|
197
|
+
# are updated on every execution), rather than by grouping the whole
|
|
198
|
+
# executions table by tenant. Cost is month-to-date; a counter that has
|
|
199
|
+
# not been reset yet this month belongs to an earlier one.
|
|
172
200
|
#
|
|
173
201
|
# @return [void]
|
|
174
202
|
def preload_tenant_index_data
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
@tenant_costs = Execution.where(tenant_id: tenant_ids)
|
|
182
|
-
.group(:tenant_id)
|
|
183
|
-
.sum(:total_cost)
|
|
184
|
-
|
|
185
|
-
# Batch-load last execution time per tenant
|
|
186
|
-
@tenant_last_executions = Execution.where(tenant_id: tenant_ids)
|
|
187
|
-
.group(:tenant_id)
|
|
188
|
-
.maximum(:created_at)
|
|
203
|
+
month_start = Date.current.beginning_of_month
|
|
204
|
+
|
|
205
|
+
@tenant_costs = @tenants.to_h do |tenant|
|
|
206
|
+
[tenant.tenant_id, (tenant.monthly_reset_date == month_start) ? tenant.monthly_cost_spent : 0]
|
|
207
|
+
end
|
|
208
|
+
@tenant_last_executions = @tenants.to_h { |tenant| [tenant.tenant_id, tenant.last_execution_at] }
|
|
189
209
|
end
|
|
190
210
|
|
|
191
211
|
# Parses and validates sort parameters for tenants list
|