llm_cost_tracker 0.14.2 → 0.15.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/CHANGELOG.md +116 -0
- data/LICENSE.txt +28 -0
- data/README.md +33 -41
- data/app/controllers/llm_cost_tracker/application_controller.rb +7 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +12 -94
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +11 -12
- data/app/controllers/llm_cost_tracker/pricing_controller.rb +15 -4
- data/app/controllers/llm_cost_tracker/tags_controller.rb +21 -14
- data/app/helpers/llm_cost_tracker/application_helper.rb +22 -31
- data/app/helpers/llm_cost_tracker/chart_helper.rb +57 -108
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +30 -17
- data/app/helpers/llm_cost_tracker/sortable_table_helper.rb +18 -25
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +30 -0
- data/app/models/llm_cost_tracker/call.rb +6 -4
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -6
- data/app/services/llm_cost_tracker/dashboard/call_order.rb +36 -0
- data/app/services/llm_cost_tracker/dashboard/calls_export.rb +68 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/stats_query.rb +77 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality/summary.rb +47 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +66 -210
- data/app/services/llm_cost_tracker/dashboard/filter.rb +2 -1
- data/app/services/llm_cost_tracker/dashboard/monthly_budget.rb +42 -35
- data/app/services/llm_cost_tracker/dashboard/percent.rb +12 -0
- data/app/services/llm_cost_tracker/dashboard/sort.rb +9 -0
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +19 -17
- data/app/services/llm_cost_tracker/dashboard/spend_chart.rb +50 -0
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +7 -8
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +1 -4
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +3 -4
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -6
- data/app/views/llm_cost_tracker/calls/index.html.erb +2 -1
- data/app/views/llm_cost_tracker/calls/show.html.erb +3 -14
- data/app/views/llm_cost_tracker/errors/query.html.erb +6 -0
- data/app/views/llm_cost_tracker/shared/_filter_pill_stream.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/show.html.erb +1 -1
- data/lib/llm_cost_tracker/budget/estimate.rb +35 -0
- data/lib/llm_cost_tracker/budget/global.rb +69 -0
- data/lib/llm_cost_tracker/budget/limit.rb +52 -0
- data/lib/llm_cost_tracker/budget/per_tag.rb +59 -27
- data/lib/llm_cost_tracker/budget/tagged.rb +74 -0
- data/lib/llm_cost_tracker/budget.rb +19 -157
- data/lib/llm_cost_tracker/capture/event_window.rb +16 -13
- data/lib/llm_cost_tracker/capture/sdk_payload.rb +22 -17
- data/lib/llm_cost_tracker/capture/sse.rb +7 -5
- data/lib/llm_cost_tracker/capture/stream_collector.rb +89 -96
- data/lib/llm_cost_tracker/capture/stream_tap.rb +2 -2
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +32 -37
- data/lib/llm_cost_tracker/capture_verifier.rb +2 -3
- data/lib/llm_cost_tracker/charges/cost_status.rb +23 -19
- data/lib/llm_cost_tracker/charges/line_item.rb +31 -33
- data/lib/llm_cost_tracker/configuration/budgets.rb +49 -18
- data/lib/llm_cost_tracker/configuration/capture.rb +7 -1
- data/lib/llm_cost_tracker/configuration/mutability.rb +3 -3
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +20 -2
- data/lib/llm_cost_tracker/doctor/price_check.rb +24 -35
- data/lib/llm_cost_tracker/doctor/probe.rb +1 -1
- data/lib/llm_cost_tracker/doctor.rb +23 -20
- data/lib/llm_cost_tracker/engine.rb +8 -0
- data/lib/llm_cost_tracker/errors.rb +2 -4
- data/lib/llm_cost_tracker/event.rb +22 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +4 -2
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +6 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_indexes.rb.erb +2 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_per_tag_budgets.rb.erb +1 -0
- data/lib/llm_cost_tracker/ingestion/batch.rb +33 -19
- data/lib/llm_cost_tracker/ingestion/inbox.rb +20 -11
- data/lib/llm_cost_tracker/ingestion/worker.rb +58 -37
- data/lib/llm_cost_tracker/ingestion.rb +20 -35
- data/lib/llm_cost_tracker/integrations/anthropic/batch_results_capture.rb +40 -0
- data/lib/llm_cost_tracker/integrations/anthropic/patches.rb +56 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +59 -120
- data/lib/llm_cost_tracker/integrations/base/patch_target.rb +35 -0
- data/lib/llm_cost_tracker/integrations/base.rb +65 -81
- data/lib/llm_cost_tracker/integrations/openai/batch_capture.rb +20 -19
- data/lib/llm_cost_tracker/integrations/openai/patches.rb +51 -40
- data/lib/llm_cost_tracker/integrations/openai/websocket_capture.rb +89 -0
- data/lib/llm_cost_tracker/integrations/openai.rb +114 -83
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/patches.rb +67 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1/reply.rb +155 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v1.rb +154 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt/token_counts.rb +159 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/attempt.rb +175 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/frame.rb +75 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/recorder.rb +70 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2/seams.rb +86 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm/v2.rb +161 -0
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +7 -408
- data/lib/llm_cost_tracker/ledger/isolation.rb +15 -2
- data/lib/llm_cost_tracker/ledger/period/totals.rb +1 -1
- data/lib/llm_cost_tracker/ledger/rollups.rb +24 -8
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +23 -6
- data/lib/llm_cost_tracker/ledger/schema/base.rb +9 -5
- data/lib/llm_cost_tracker/ledger/store.rb +17 -35
- data/lib/llm_cost_tracker/ledger/tags/breakdown.rb +2 -2
- data/lib/llm_cost_tracker/middleware/faraday/body.rb +57 -0
- data/lib/llm_cost_tracker/middleware/faraday/exchange.rb +153 -0
- data/lib/llm_cost_tracker/middleware/faraday/response_reader.rb +75 -0
- data/lib/llm_cost_tracker/middleware/faraday/stream_tee.rb +47 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +2 -301
- data/lib/llm_cost_tracker/parsers.rb +12 -0
- data/lib/llm_cost_tracker/prices.json +1179 -208
- data/lib/llm_cost_tracker/pricing/backfill.rb +59 -29
- data/lib/llm_cost_tracker/pricing/calculation/iterations.rb +49 -0
- data/lib/llm_cost_tracker/pricing/calculation/quantities.rb +40 -0
- data/lib/llm_cost_tracker/pricing/calculation/snapshot.rb +51 -0
- data/lib/llm_cost_tracker/pricing/calculation/totals.rb +59 -0
- data/lib/llm_cost_tracker/pricing/calculation.rb +82 -173
- data/lib/llm_cost_tracker/pricing/matcher.rb +31 -14
- data/lib/llm_cost_tracker/pricing/mode.rb +6 -19
- data/lib/llm_cost_tracker/pricing/off_peak.rb +50 -0
- data/lib/llm_cost_tracker/pricing/rate.rb +1 -0
- data/lib/llm_cost_tracker/pricing/registry/normalizer.rb +110 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +38 -111
- data/lib/llm_cost_tracker/pricing/sync/change_printer.rb +40 -20
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +35 -34
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +29 -18
- data/lib/llm_cost_tracker/pricing/sync/remote_snapshot.rb +78 -0
- data/lib/llm_cost_tracker/pricing/sync/snapshot_guard.rb +2 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +7 -53
- data/lib/llm_cost_tracker/pricing/unknown.rb +1 -1
- data/lib/llm_cost_tracker/providers/anthropic/parser.rb +30 -25
- data/lib/llm_cost_tracker/providers/anthropic/response_parser.rb +5 -3
- data/lib/llm_cost_tracker/providers/anthropic/usage_extractor.rb +122 -101
- data/lib/llm_cost_tracker/providers/azure/hosts.rb +1 -1
- data/lib/llm_cost_tracker/providers/azure/parser.rb +3 -6
- data/lib/llm_cost_tracker/providers/gemini/grounding.rb +87 -0
- data/lib/llm_cost_tracker/providers/gemini/parser.rb +120 -183
- data/lib/llm_cost_tracker/providers/gemini/usage_extractor.rb +66 -47
- data/lib/llm_cost_tracker/providers/openai/hosts.rb +11 -0
- data/lib/llm_cost_tracker/providers/openai/model_families.rb +1 -1
- data/lib/llm_cost_tracker/providers/openai/output_item.rb +84 -0
- data/lib/llm_cost_tracker/providers/openai/parser.rb +2 -1
- data/lib/llm_cost_tracker/providers/openai/response_parser.rb +81 -149
- data/lib/llm_cost_tracker/providers/openai/service_charges.rb +133 -156
- data/lib/llm_cost_tracker/providers/openai/stream_parser.rb +127 -0
- data/lib/llm_cost_tracker/providers/openai/usage_extractor.rb +72 -62
- data/lib/llm_cost_tracker/providers/openai_compatible/parser.rb +15 -4
- data/lib/llm_cost_tracker/providers.rb +3 -0
- data/lib/llm_cost_tracker/redaction.rb +13 -0
- data/lib/llm_cost_tracker/report/data.rb +25 -28
- data/lib/llm_cost_tracker/tags/context.rb +22 -7
- data/lib/llm_cost_tracker/tracker.rb +32 -26
- data/lib/llm_cost_tracker/usage/dimensions.yml +24 -0
- data/lib/llm_cost_tracker/usage/token_usage.rb +8 -4
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +8 -4
- data/lib/tasks/llm_cost_tracker.rake +35 -5
- data/llm_cost_tracker.gemspec +6 -5
- metadata +58 -5
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Dashboard
|
|
5
|
+
class SpendChart
|
|
6
|
+
WIDTH = 1180
|
|
7
|
+
PADDING = { top: 16, right: 16, bottom: 28, left: 56 }.freeze
|
|
8
|
+
MIN_MAX_COST = 0.0001
|
|
9
|
+
|
|
10
|
+
attr_reader :points, :height, :y_ticks, :max_cost, :coords, :comparison_coords
|
|
11
|
+
|
|
12
|
+
def initialize(points, comparison_points: nil, height: 180, y_ticks: 3)
|
|
13
|
+
@points = points
|
|
14
|
+
@height = height
|
|
15
|
+
@y_ticks = y_ticks
|
|
16
|
+
costs = points.map { |point| point[:cost].to_f } + Array(comparison_points).map { |point| point[:cost].to_f }
|
|
17
|
+
@max_cost = [costs.max.to_f, MIN_MAX_COST].max
|
|
18
|
+
@coords = coords_for(points)
|
|
19
|
+
@comparison_coords = coords_for(comparison_points) if comparison_points.present?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def peak_index
|
|
23
|
+
@peak_index ||= points.each_with_index.max_by { |point, _| point[:cost].to_f }.last
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def left = PADDING[:left]
|
|
27
|
+
def right = left + plot_width
|
|
28
|
+
def baseline = PADDING[:top] + plot_height
|
|
29
|
+
def tick_y(index) = PADDING[:top] + (plot_height * index.to_f / y_ticks)
|
|
30
|
+
def tick_value(index) = max_cost * (y_ticks - index).to_f / y_ticks
|
|
31
|
+
|
|
32
|
+
def label_indexes
|
|
33
|
+
count = points.size
|
|
34
|
+
count <= 2 ? (0...count).to_a : [0, count / 2, count - 1].uniq
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def plot_width = WIDTH - PADDING[:left] - PADDING[:right]
|
|
40
|
+
def plot_height = height - PADDING[:top] - PADDING[:bottom]
|
|
41
|
+
|
|
42
|
+
def coords_for(series)
|
|
43
|
+
step = series.size > 1 ? plot_width.to_f / (series.size - 1) : 0.0
|
|
44
|
+
series.each_with_index.map do |point, index|
|
|
45
|
+
[left + (index * step), baseline - ((point[:cost].to_f / max_cost) * plot_height)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -20,8 +20,7 @@ module LlmCostTracker
|
|
|
20
20
|
@key = LlmCostTracker::Tags::Key.validate!(key, error_class: LlmCostTracker::InvalidFilterError)
|
|
21
21
|
limit = limit.to_i
|
|
22
22
|
@limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
23
|
-
@
|
|
24
|
-
@direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
|
|
23
|
+
@order = Sort.resolve(sort, direction, natural_directions: DEFAULT_DIRECTIONS, fallback: "cost")
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
def rows
|
|
@@ -34,7 +33,7 @@ module LlmCostTracker
|
|
|
34
33
|
calls: calls,
|
|
35
34
|
total_cost: row.total_cost,
|
|
36
35
|
average_cost_per_call: row.average_cost_per_call,
|
|
37
|
-
share_percent:
|
|
36
|
+
share_percent: Percent.of(calls, total)
|
|
38
37
|
)
|
|
39
38
|
end
|
|
40
39
|
end
|
|
@@ -54,7 +53,7 @@ module LlmCostTracker
|
|
|
54
53
|
|
|
55
54
|
private
|
|
56
55
|
|
|
57
|
-
attr_reader :scope, :key, :
|
|
56
|
+
attr_reader :scope, :key, :order
|
|
58
57
|
|
|
59
58
|
def summary_counts
|
|
60
59
|
@summary_counts ||= scope.klass.find_by_sql(summary_sql).first
|
|
@@ -76,8 +75,8 @@ module LlmCostTracker
|
|
|
76
75
|
end
|
|
77
76
|
|
|
78
77
|
def order_clause
|
|
79
|
-
dir = direction.upcase
|
|
80
|
-
case
|
|
78
|
+
dir = order.direction.upcase
|
|
79
|
+
case order.column
|
|
81
80
|
when "value" then "#{tag_value_column} #{dir}"
|
|
82
81
|
when "calls" then "COUNT(*) #{dir}, total_cost DESC"
|
|
83
82
|
when "avg_cost" then "average_cost_per_call #{dir}, total_cost DESC"
|
|
@@ -108,11 +107,11 @@ module LlmCostTracker
|
|
|
108
107
|
end
|
|
109
108
|
|
|
110
109
|
def quote_column(name)
|
|
111
|
-
scope.
|
|
110
|
+
scope.lease_connection.quote_column_name(name)
|
|
112
111
|
end
|
|
113
112
|
|
|
114
113
|
def quoted_key
|
|
115
|
-
scope.
|
|
114
|
+
scope.lease_connection.quote(key)
|
|
116
115
|
end
|
|
117
116
|
end
|
|
118
117
|
end
|
|
@@ -13,16 +13,13 @@ module LlmCostTracker
|
|
|
13
13
|
|
|
14
14
|
def initialize(scope:, limit:)
|
|
15
15
|
@scope = scope
|
|
16
|
-
@connection = LlmCostTracker::Call.
|
|
16
|
+
@connection = LlmCostTracker::Call.lease_connection
|
|
17
17
|
limit = limit.to_i
|
|
18
18
|
@limit = limit.positive? ? [limit, DEFAULT_LIMIT].min : DEFAULT_LIMIT
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def rows
|
|
22
22
|
scope.klass.find_by_sql(build_sql)
|
|
23
|
-
rescue ActiveRecord::StatementInvalid => e
|
|
24
|
-
LlmCostTracker::Logging.warn("Tag key discovery failed (#{connection.adapter_name}): #{e.class}: #{e.message}")
|
|
25
|
-
[]
|
|
26
23
|
end
|
|
27
24
|
|
|
28
25
|
private
|
|
@@ -42,21 +42,20 @@ module LlmCostTracker
|
|
|
42
42
|
def initialize(scope:, limit:, sort: DEFAULT_SORT, direction: nil)
|
|
43
43
|
@scope = scope
|
|
44
44
|
@limit = limit
|
|
45
|
-
@
|
|
46
|
-
@direction = Sort::DIRECTIONS.include?(direction.to_s) ? direction.to_s : DEFAULT_DIRECTIONS[@sort]
|
|
45
|
+
@order = Sort.resolve(sort, direction, natural_directions: DEFAULT_DIRECTIONS, fallback: DEFAULT_SORT)
|
|
47
46
|
end
|
|
48
47
|
|
|
49
48
|
def rows
|
|
50
49
|
scope
|
|
51
50
|
.group(:provider, :model)
|
|
52
51
|
.select(selects)
|
|
53
|
-
.order(*ORDER_NODES.fetch([
|
|
52
|
+
.order(*ORDER_NODES.fetch([order.column, order.direction]))
|
|
54
53
|
.then { |r| limit ? r.limit(limit) : r }
|
|
55
54
|
end
|
|
56
55
|
|
|
57
56
|
private
|
|
58
57
|
|
|
59
|
-
attr_reader :scope, :limit, :
|
|
58
|
+
attr_reader :scope, :limit, :order
|
|
60
59
|
|
|
61
60
|
def selects
|
|
62
61
|
columns = [
|
|
@@ -54,28 +54,28 @@
|
|
|
54
54
|
<aside class="lct-sidebar" aria-label="Dashboard">
|
|
55
55
|
<div class="lct-sidebar-inner">
|
|
56
56
|
<div class="lct-sidebar-section">Insights</div>
|
|
57
|
-
<%= link_to root_path,
|
|
57
|
+
<%= link_to root_path, sidebar_link_options(:overview) do %>
|
|
58
58
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
|
59
59
|
Overview
|
|
60
60
|
<% end %>
|
|
61
|
-
<%= link_to models_path,
|
|
61
|
+
<%= link_to models_path, sidebar_link_options(:models) do %>
|
|
62
62
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>
|
|
63
63
|
Models
|
|
64
64
|
<% end %>
|
|
65
|
-
<%= link_to calls_path,
|
|
65
|
+
<%= link_to calls_path, sidebar_link_options(:calls) do %>
|
|
66
66
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
67
67
|
Calls
|
|
68
68
|
<% end %>
|
|
69
|
-
<%= link_to tags_path,
|
|
69
|
+
<%= link_to tags_path, sidebar_link_options(:tags) do %>
|
|
70
70
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
|
|
71
71
|
Tags
|
|
72
72
|
<% end %>
|
|
73
|
-
<%= link_to data_quality_path,
|
|
73
|
+
<%= link_to data_quality_path, sidebar_link_options(:data_quality) do %>
|
|
74
74
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
|
75
75
|
Data Quality
|
|
76
76
|
<% end %>
|
|
77
77
|
<div class="lct-sidebar-section">Reference</div>
|
|
78
|
-
<%= link_to pricing_path,
|
|
78
|
+
<%= link_to pricing_path, sidebar_link_options(:pricing) do %>
|
|
79
79
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
|
80
80
|
Pricing
|
|
81
81
|
<% end %>
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
<% end %>
|
|
20
20
|
|
|
21
21
|
<span class="lct-filter-row-meta"><%= number_with_delimiter(@calls_count) %> call<%= "s" unless @calls_count == 1 %> · <%= money(@calls_total_cost) %></span>
|
|
22
|
-
|
|
22
|
+
<% export_limit = LlmCostTracker::CallsController::CSV_EXPORT_LIMIT %>
|
|
23
|
+
<%= link_to(@calls_count > export_limit ? "Export CSV (first #{number_with_delimiter(export_limit)})" : "Export CSV", calls_path(current_query(format: :csv)), class: "lct-page-link", title: "Capped at #{number_with_delimiter(export_limit)} rows per request") %>
|
|
23
24
|
</div>
|
|
24
25
|
|
|
25
26
|
<% if @calls_count.zero? %>
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
<%
|
|
2
|
-
<% line_item_costs_by_component = call_line_item_costs_by_component(@call) %>
|
|
3
|
-
<% token_segments = priced_components.map do |component|
|
|
4
|
-
token_key = component.fetch(:token_key)
|
|
5
|
-
value = @call[token_key]
|
|
6
|
-
{ label: component.fetch(:label), value: value, formatted_value: number_with_delimiter(value), css_class: component.fetch(:css_class) }
|
|
7
|
-
end %>
|
|
8
|
-
<% cost_segments = priced_components.map do |component|
|
|
9
|
-
value = line_item_costs_by_component[component.fetch(:price_key)]
|
|
10
|
-
{ label: component.fetch(:label), value: value, formatted_value: optional_money(value), css_class: component.fetch(:css_class) }
|
|
11
|
-
end %>
|
|
12
|
-
<% service_line_items = @call.line_items.reject { |li| li.unit == "token" }.sort_by(&:position) %>
|
|
1
|
+
<% service_line_items = call_service_line_items(@call) %>
|
|
13
2
|
|
|
14
3
|
<p class="lct-breadcrumb-back"><%= link_to "← All calls", calls_path %></p>
|
|
15
4
|
<h2 class="lct-page-title"><code class="lct-code-id"><%= @call.model %></code> <span class="lct-page-title-meta">#<%= @call.id %></span></h2>
|
|
@@ -42,7 +31,7 @@ end %>
|
|
|
42
31
|
<section class="lct-panel">
|
|
43
32
|
<div class="lct-panel-head"><h2 class="lct-panel-title">Token mix</h2></div>
|
|
44
33
|
<div class="lct-panel-body">
|
|
45
|
-
<%= render "llm_cost_tracker/shared/metric_stack", segments:
|
|
34
|
+
<%= render "llm_cost_tracker/shared/metric_stack", segments: call_token_segments(@call), empty_message: "No token data for this call." %>
|
|
46
35
|
</div>
|
|
47
36
|
</section>
|
|
48
37
|
|
|
@@ -52,7 +41,7 @@ end %>
|
|
|
52
41
|
<% if @call.total_cost.nil? %>
|
|
53
42
|
<p class="lct-stack-empty">Pricing not available for this call.</p>
|
|
54
43
|
<% else %>
|
|
55
|
-
<%= render "llm_cost_tracker/shared/metric_stack", segments:
|
|
44
|
+
<%= render "llm_cost_tracker/shared/metric_stack", segments: call_cost_segments(@call), empty_message: "No cost breakdown for this call." %>
|
|
56
45
|
<% end %>
|
|
57
46
|
</div>
|
|
58
47
|
</section>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<% active = params[:stream].presence %>
|
|
2
|
-
<% display_value =
|
|
2
|
+
<% display_value = LlmCostTracker::Dashboard::Filter::STREAM_FILTER_LABELS.fetch(active, "All") %>
|
|
3
3
|
<details class="lct-filter-pop" name="lct-filter">
|
|
4
4
|
<summary class="lct-filter-pill <%= 'lct-active' if active %>">
|
|
5
5
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% form_url = tag_path(params[:key]) %>
|
|
2
2
|
|
|
3
|
-
<% if @
|
|
3
|
+
<% if @breakdown.nil? %>
|
|
4
4
|
<p class="lct-breadcrumb-back"><%= link_to "← All values for #{params[:key]}", tag_path(params[:key], current_query.except(:tag_value)) %></p>
|
|
5
5
|
<h2 class="lct-page-title">Tag: <code class="lct-code-id"><%= params[:key] %></code> = <code class="lct-code-id"><%= @value %></code></h2>
|
|
6
6
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "../pricing/estimator"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Budget
|
|
9
|
+
Estimate = Data.define(:total, :largest) do
|
|
10
|
+
def self.for(provider:, model:, request:)
|
|
11
|
+
costs = request_costs(provider, model, request)
|
|
12
|
+
total = costs.sum(BigDecimal("0"))
|
|
13
|
+
new(total: total, largest: costs.max || total)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.request_costs(provider, model, request)
|
|
17
|
+
batch = request["requests"] if request && !model
|
|
18
|
+
return [cost(provider, model, request)] unless batch.is_a?(Array)
|
|
19
|
+
|
|
20
|
+
batch.map do |entry|
|
|
21
|
+
params = entry["params"] if entry.is_a?(Hash)
|
|
22
|
+
params.is_a?(Hash) ? cost(provider, params["model"], params) : 0
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.cost(provider, model, request)
|
|
27
|
+
return BigDecimal("0") unless provider && model && request
|
|
28
|
+
|
|
29
|
+
Pricing::Estimator.call(provider: provider, model: model, request: request) || BigDecimal("0")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private_class_method :request_costs, :cost
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "limit"
|
|
6
|
+
require_relative "../ledger"
|
|
7
|
+
|
|
8
|
+
module LlmCostTracker
|
|
9
|
+
module Budget
|
|
10
|
+
module Global
|
|
11
|
+
PERIODS = { monthly: :month, daily: :day }.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def enforce!(estimate, time:)
|
|
15
|
+
budgets = LlmCostTracker.configuration.budgets
|
|
16
|
+
per_call = budgets.per_call
|
|
17
|
+
largest = estimate.largest
|
|
18
|
+
Limit.global(:per_call, per_call).block!(largest) if per_call && largest.positive? && largest >= per_call
|
|
19
|
+
|
|
20
|
+
windows = { monthly: budgets.monthly, daily: budgets.daily }
|
|
21
|
+
each_exceeded(windows, time: time, estimate: estimate.total) { |limit, total| limit.block!(total) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def post_spend_errors(event, behavior_override)
|
|
25
|
+
budgets = LlmCostTracker.configuration.budgets
|
|
26
|
+
errors = [per_call_error(event, budgets.per_call, behavior_override)]
|
|
27
|
+
each_exceeded({ daily: budgets.daily, monthly: budgets.monthly }, time: event.tracked_at) do |limit, total|
|
|
28
|
+
errors << limit.handle_exceeded(total: total,
|
|
29
|
+
previous_total: total - event.total_cost,
|
|
30
|
+
last_event: event,
|
|
31
|
+
behavior_override: behavior_override)
|
|
32
|
+
end
|
|
33
|
+
errors
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def notify_repriced(changes, time:)
|
|
37
|
+
budgets = LlmCostTracker.configuration.budgets
|
|
38
|
+
each_exceeded({ daily: budgets.daily, monthly: budgets.monthly }, time: time) do |limit, total|
|
|
39
|
+
limit.handle_exceeded(total: total,
|
|
40
|
+
previous_total: total - limit.amount_in_window(changes, time),
|
|
41
|
+
behavior_override: :notify)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def per_call_error(event, per_call, behavior_override)
|
|
48
|
+
total = event.total_cost
|
|
49
|
+
return unless per_call && total >= per_call
|
|
50
|
+
|
|
51
|
+
Limit.global(:per_call, per_call).handle_exceeded(
|
|
52
|
+
total: total, previous_total: nil, last_event: event, behavior_override: behavior_override
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def each_exceeded(windows, time:, estimate: BigDecimal("0"))
|
|
57
|
+
windows = windows.compact
|
|
58
|
+
return if windows.empty?
|
|
59
|
+
|
|
60
|
+
totals = Ledger::Period::Totals.call(windows.keys.map { |type| PERIODS.fetch(type) }, time: time)
|
|
61
|
+
windows.each do |type, budget|
|
|
62
|
+
total = totals.fetch(PERIODS.fetch(type)) + estimate
|
|
63
|
+
yield Limit.global(type, budget), total if total >= budget
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Budget
|
|
7
|
+
Limit = Data.define(:budget_type, :budget, :scope, :behavior, :on_exceeded) do
|
|
8
|
+
def self.global(budget_type, budget)
|
|
9
|
+
new(budget_type: budget_type, budget: budget, scope: nil, behavior: nil, on_exceeded: nil)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def over?(total)
|
|
13
|
+
budget_type == :calls ? total > budget : total >= budget
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def charge(cost)
|
|
17
|
+
budget_type == :calls ? 1 : cost
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def counts?(event)
|
|
21
|
+
budget_type == :calls || !event.total_cost.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def amount_in_window(changes, time)
|
|
25
|
+
start = PerTag.window_start(budget_type, time)
|
|
26
|
+
changes.sum(BigDecimal("0")) { |change| start.nil? || change.tracked_at >= start ? change.total_cost : 0 }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def block!(total)
|
|
30
|
+
raise BudgetExceededError.new(
|
|
31
|
+
budget_type: budget_type, total: total, budget: budget, stage: :pre_send, scope: scope
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def handle_exceeded(total:, previous_total:, last_event: nil, behavior_override: nil)
|
|
36
|
+
budgets = LlmCostTracker.configuration.budgets
|
|
37
|
+
raising = %i[raise block_requests].include?(behavior_override || behavior || budgets.exceeded_behavior)
|
|
38
|
+
callback = on_exceeded || budgets.on_exceeded
|
|
39
|
+
payload = {
|
|
40
|
+
budget_type: budget_type,
|
|
41
|
+
total: total,
|
|
42
|
+
budget: budget,
|
|
43
|
+
last_event: last_event,
|
|
44
|
+
stage: :post_spend,
|
|
45
|
+
scope: scope
|
|
46
|
+
}
|
|
47
|
+
callback.call(payload) if callback && (previous_total.nil? || !over?(previous_total))
|
|
48
|
+
BudgetExceededError.new(**payload) if raising
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "limit"
|
|
4
|
+
require_relative "../timing"
|
|
3
5
|
require_relative "../ledger/isolation"
|
|
4
6
|
require_relative "../ledger/schema/adapter"
|
|
5
7
|
require_relative "../ledger/tags/encoding"
|
|
@@ -14,7 +16,15 @@ module LlmCostTracker
|
|
|
14
16
|
SLOW_READ_SECONDS = 0.1
|
|
15
17
|
DEFAULT_BACKFILL_BATCH = 5_000
|
|
16
18
|
|
|
17
|
-
Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded)
|
|
19
|
+
Rule = Data.define(:key, :value, :windows, :behavior, :on_exceeded) do
|
|
20
|
+
def limit(window)
|
|
21
|
+
Limit.new(budget_type: window,
|
|
22
|
+
budget: windows.fetch(window),
|
|
23
|
+
scope: { key: key, value: value },
|
|
24
|
+
behavior: behavior,
|
|
25
|
+
on_exceeded: on_exceeded)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
18
28
|
|
|
19
29
|
class << self
|
|
20
30
|
def configured
|
|
@@ -53,45 +63,42 @@ module LlmCostTracker
|
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
def rules_for_events(events)
|
|
56
|
-
events.
|
|
66
|
+
events.each_with_object({}) do |event, grouped|
|
|
57
67
|
rules_for(event.tags).each { |rule| (grouped[rule] ||= []) << event }
|
|
58
68
|
end
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
def spend(key, value, window, time:)
|
|
62
|
-
spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first
|
|
72
|
+
spend_by_value(key, [value], window, window_start(window, time)).fetch(value, [0]).first
|
|
63
73
|
end
|
|
64
74
|
|
|
65
75
|
def spend_by_value(key, values, window, bucket, upto = nil)
|
|
66
|
-
started_at =
|
|
67
|
-
|
|
68
|
-
upto_sum = LlmCostTracker::CallTag.sanitize_sql_array(
|
|
69
|
-
["SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{COST_COLUMN} ELSE 0 END)", upto || range.end]
|
|
70
|
-
)
|
|
76
|
+
started_at = Timing.now_monotonic
|
|
77
|
+
aggregates = sum_columns(window, upto)
|
|
71
78
|
totals = Ledger::Isolation.guard(LlmCostTracker::CallTag) do
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
.pluck(:value, Arel.sql("SUM(#{COST_COLUMN})"), Arel.sql(upto_sum))
|
|
76
|
-
.to_h { |value, *sums| [value, sums.map(&:to_d)] }
|
|
79
|
+
pluck_by_exact_value(spend_rows(key, values, window, bucket), values, aggregates).to_h do |value, *sums|
|
|
80
|
+
[value.dup.force_encoding(Encoding::UTF_8), sums.map { |sum| window == :calls ? sum.to_i : sum.to_d }]
|
|
81
|
+
end
|
|
77
82
|
end
|
|
78
|
-
warn_slow_read(key, window,
|
|
83
|
+
warn_slow_read(key, window, Timing.now_monotonic - started_at)
|
|
79
84
|
totals
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
def window_start(window, time)
|
|
83
|
-
time.to_time.utc.public_send(
|
|
88
|
+
WINDOW_STARTS[window]&.then { |start| time.to_time.utc.public_send(start) }
|
|
84
89
|
end
|
|
85
90
|
|
|
86
91
|
def backfill(batch_size: DEFAULT_BACKFILL_BATCH)
|
|
87
92
|
return 0 unless columns?
|
|
88
93
|
|
|
89
94
|
filled = 0
|
|
95
|
+
after = 0
|
|
90
96
|
loop do
|
|
91
|
-
|
|
92
|
-
break if
|
|
97
|
+
ids = unfilled_ids(after, batch_size)
|
|
98
|
+
break if ids.empty?
|
|
93
99
|
|
|
94
|
-
filled +=
|
|
100
|
+
filled += LlmCostTracker::CallTag.lease_connection.update(copy_sql(ids))
|
|
101
|
+
after = ids.last
|
|
95
102
|
end
|
|
96
103
|
filled
|
|
97
104
|
end
|
|
@@ -103,22 +110,42 @@ module LlmCostTracker
|
|
|
103
110
|
|
|
104
111
|
private
|
|
105
112
|
|
|
113
|
+
def sum_columns(window, upto)
|
|
114
|
+
measure = window == :calls ? "1" : COST_COLUMN
|
|
115
|
+
total = "SUM(#{measure})"
|
|
116
|
+
upto_sum = "SUM(CASE WHEN #{TIME_COLUMN} <= ? THEN #{measure} ELSE 0 END)"
|
|
117
|
+
upto_total = upto ? LlmCostTracker::CallTag.sanitize_sql_array([upto_sum, upto]) : total
|
|
118
|
+
[Arel.sql(total), Arel.sql(upto_total)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def spend_rows(key, values, window, bucket)
|
|
122
|
+
rows = LlmCostTracker::CallTag.where(key: key, value: values)
|
|
123
|
+
bucket ? rows.where(TIME_COLUMN => window_range(window, bucket)) : rows.where.not(TIME_COLUMN => nil)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def pluck_by_exact_value(rows, values, aggregates)
|
|
127
|
+
column = :value
|
|
128
|
+
if Ledger::Schema::Adapter.mysql?(LlmCostTracker::CallTag.lease_connection)
|
|
129
|
+
column = Arel.sql("CAST(value AS BINARY)")
|
|
130
|
+
rows = rows.where(column.in(values))
|
|
131
|
+
end
|
|
132
|
+
rows.group(column).pluck(column, *aggregates)
|
|
133
|
+
end
|
|
134
|
+
|
|
106
135
|
def window_range(window, bucket)
|
|
107
136
|
bucket...bucket.public_send(WINDOW_NEXTS.fetch(window))
|
|
108
137
|
end
|
|
109
138
|
|
|
110
|
-
def
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
LlmCostTracker::CallTag.connection.update(copy_sql(ids))
|
|
139
|
+
def unfilled_ids(after, batch_size)
|
|
140
|
+
LlmCostTracker::CallTag.where(TIME_COLUMN => nil).where(id: (after + 1)..)
|
|
141
|
+
.order(:id).limit(batch_size).pluck(:id)
|
|
115
142
|
end
|
|
116
143
|
|
|
117
144
|
def copy_sql(ids)
|
|
118
145
|
tags = LlmCostTracker::CallTag.quoted_table_name
|
|
119
146
|
calls = LlmCostTracker::Call.quoted_table_name
|
|
120
147
|
list = ids.join(",")
|
|
121
|
-
return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.
|
|
148
|
+
return <<~SQL.squish unless Ledger::Schema::Adapter.postgresql?(LlmCostTracker::CallTag.lease_connection)
|
|
122
149
|
UPDATE #{tags} t JOIN #{calls} c ON c.id = t.llm_cost_tracker_call_id
|
|
123
150
|
SET t.#{COST_COLUMN} = c.#{COST_COLUMN}, t.#{TIME_COLUMN} = c.#{TIME_COLUMN}
|
|
124
151
|
WHERE t.id IN (#{list})
|
|
@@ -146,10 +173,15 @@ module LlmCostTracker
|
|
|
146
173
|
@slow_keys ||= Set.new
|
|
147
174
|
return unless @slow_keys.add?(key)
|
|
148
175
|
|
|
176
|
+
reason = if WINDOW_STARTS.key?(window)
|
|
177
|
+
"A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
|
|
178
|
+
"an index effectively. Budget high-cardinality tags such as a tenant or user id."
|
|
179
|
+
else
|
|
180
|
+
"A #{window} limit sums every call the value has recorded, so it suits short-lived values " \
|
|
181
|
+
"such as a run."
|
|
182
|
+
end
|
|
149
183
|
Logging.warn(
|
|
150
|
-
"config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. "
|
|
151
|
-
"A tag with few distinct values covers most of the ledger, so its budget check cannot use " \
|
|
152
|
-
"an index effectively. Budget high-cardinality tags such as a tenant or user id."
|
|
184
|
+
"config.budgets.per_tag[#{key.inspect}] #{window} read took #{(seconds * 1000).round} ms. #{reason}"
|
|
153
185
|
)
|
|
154
186
|
end
|
|
155
187
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "per_tag"
|
|
4
|
+
require_relative "../tags/context"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Budget
|
|
8
|
+
module Tagged
|
|
9
|
+
class << self
|
|
10
|
+
def enforce!(tags, estimated_cost, time:, blocking_only:)
|
|
11
|
+
PerTag.rules_for(tags || Tags::Context.tags, blocking_only: blocking_only).each do |rule|
|
|
12
|
+
rule.windows.each_key do |window|
|
|
13
|
+
limit = rule.limit(window)
|
|
14
|
+
total = PerTag.spend(rule.key, rule.value, window, time: time) + limit.charge(estimated_cost)
|
|
15
|
+
limit.block!(total) if limit.over?(total)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def post_spend_errors(events, behavior_override)
|
|
21
|
+
window_buckets(scored_rules(events, behavior_override)).flat_map do |(key, window, bucket), recorded_by_rule|
|
|
22
|
+
upto = recorded_by_rule.values.flatten.map(&:tracked_at).max unless Ingestion.async?
|
|
23
|
+
totals = PerTag.spend_by_value(key, recorded_by_rule.keys.map(&:value), window, bucket, upto)
|
|
24
|
+
recorded_by_rule.filter_map do |rule, recorded|
|
|
25
|
+
total, total_through_batch = totals.fetch(rule.value, [0, 0])
|
|
26
|
+
bucket_error(rule.limit(window), recorded, total, total_through_batch, behavior_override)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def notify_repriced(changes, time:)
|
|
32
|
+
PerTag.rules_for_events(changes).each do |rule, repriced|
|
|
33
|
+
rule.windows.except(:calls).each_key do |window|
|
|
34
|
+
limit = rule.limit(window)
|
|
35
|
+
amount = limit.amount_in_window(repriced, time)
|
|
36
|
+
total = PerTag.spend(rule.key, rule.value, window, time: time) if rule.on_exceeded && amount.positive?
|
|
37
|
+
next unless total && limit.over?(total)
|
|
38
|
+
|
|
39
|
+
limit.handle_exceeded(total: total, previous_total: total - amount, behavior_override: :notify)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def scored_rules(events, behavior_override)
|
|
47
|
+
by_rule = PerTag.rules_for_events(events)
|
|
48
|
+
behavior_override == :notify ? by_rule.reject { |rule, _| rule.on_exceeded.nil? } : by_rule
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def bucket_error(limit, recorded, total, total_through_batch, behavior_override)
|
|
52
|
+
return unless limit.over?(total)
|
|
53
|
+
|
|
54
|
+
reference = limit.over?(total_through_batch) ? total_through_batch : total
|
|
55
|
+
limit.handle_exceeded(total: total,
|
|
56
|
+
previous_total: reference - recorded.sum { |event| limit.charge(event.total_cost) },
|
|
57
|
+
last_event: recorded.last,
|
|
58
|
+
behavior_override: behavior_override)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def window_buckets(by_rule)
|
|
62
|
+
by_rule.each_with_object({}) do |(rule, events), grouped|
|
|
63
|
+
rule.windows.each_key do |window|
|
|
64
|
+
limit = rule.limit(window)
|
|
65
|
+
events.select { |event| limit.counts?(event) }
|
|
66
|
+
.group_by { |event| PerTag.window_start(window, event.tracked_at) }
|
|
67
|
+
.each { |bucket, bucket_events| (grouped[[rule.key, window, bucket]] ||= {})[rule] = bucket_events }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|