llm_cost_tracker 0.7.3 → 0.9.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/.ruby-version +1 -0
- data/CHANGELOG.md +173 -0
- data/README.md +60 -220
- data/app/assets/llm_cost_tracker/application.css +282 -45
- data/app/controllers/llm_cost_tracker/application_controller.rb +25 -20
- data/app/controllers/llm_cost_tracker/assets_controller.rb +11 -1
- data/app/controllers/llm_cost_tracker/calls_controller.rb +22 -19
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +14 -2
- data/app/controllers/llm_cost_tracker/reconciliation_controller.rb +106 -0
- data/app/controllers/llm_cost_tracker/tags_controller.rb +15 -1
- data/app/helpers/llm_cost_tracker/application_helper.rb +18 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +3 -21
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +4 -4
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/inline_style_helper.rb +28 -0
- data/app/helpers/llm_cost_tracker/reconciliation_helper.rb +13 -0
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +24 -7
- data/app/models/llm_cost_tracker/call.rb +166 -0
- data/app/models/llm_cost_tracker/call_line_item.rb +18 -0
- data/app/models/llm_cost_tracker/call_rollup.rb +6 -0
- data/app/models/llm_cost_tracker/call_tag.rb +12 -0
- data/app/models/llm_cost_tracker/ingestion/inbox_entry.rb +9 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +0 -3
- data/app/models/llm_cost_tracker/provider_invoice.rb +13 -0
- data/app/models/llm_cost_tracker/provider_invoice_import.rb +24 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +152 -32
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/filter.rb +8 -6
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +74 -21
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +6 -4
- data/app/services/llm_cost_tracker/dashboard/params.rb +8 -2
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -3
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +42 -9
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +14 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +1 -1
- data/app/views/layouts/llm_cost_tracker/application.html.erb +6 -1
- data/app/views/llm_cost_tracker/calls/index.html.erb +33 -75
- data/app/views/llm_cost_tracker/calls/show.html.erb +73 -33
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +16 -57
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +183 -167
- data/app/views/llm_cost_tracker/errors/database.html.erb +1 -1
- data/app/views/llm_cost_tracker/models/index.html.erb +18 -50
- data/app/views/llm_cost_tracker/reconciliation/index.html.erb +183 -0
- data/app/views/llm_cost_tracker/shared/_bar.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_filters.html.erb +66 -0
- data/app/views/llm_cost_tracker/shared/_metric_stack.html.erb +1 -1
- data/app/views/llm_cost_tracker/shared/_sort.html.erb +13 -0
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +1 -1
- data/app/views/llm_cost_tracker/tags/index.html.erb +3 -34
- data/app/views/llm_cost_tracker/tags/show.html.erb +64 -36
- data/config/routes.rb +3 -2
- data/lib/llm_cost_tracker/billing/components.rb +95 -0
- data/lib/llm_cost_tracker/billing/components.yml +188 -0
- data/lib/llm_cost_tracker/billing/cost_status.rb +45 -0
- data/lib/llm_cost_tracker/billing/line_item.rb +189 -0
- data/lib/llm_cost_tracker/budget.rb +26 -36
- data/lib/llm_cost_tracker/capture/stream_collector.rb +125 -38
- data/lib/llm_cost_tracker/capture/stream_tracker.rb +40 -5
- data/lib/llm_cost_tracker/configuration.rb +86 -17
- data/lib/llm_cost_tracker/dashboard_setup_state.rb +109 -0
- data/lib/llm_cost_tracker/doctor/cost_drift_check.rb +56 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +48 -30
- data/lib/llm_cost_tracker/doctor/invoice_reconciliation_check.rb +164 -0
- data/lib/llm_cost_tracker/doctor/legacy_audit_check.rb +36 -0
- data/lib/llm_cost_tracker/doctor/legacy_billing_status_check.rb +22 -0
- data/lib/llm_cost_tracker/doctor/price_check.rb +2 -2
- data/lib/llm_cost_tracker/doctor/pricing_snapshot_drift_check.rb +85 -0
- data/lib/llm_cost_tracker/doctor/probe.rb +17 -0
- data/lib/llm_cost_tracker/doctor/schema_check.rb +34 -0
- data/lib/llm_cost_tracker/doctor.rb +111 -44
- data/lib/llm_cost_tracker/engine.rb +9 -0
- data/lib/llm_cost_tracker/errors.rb +5 -19
- data/lib/llm_cost_tracker/event.rb +11 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/call_rollups_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/durable_ingestion_generator.rb +43 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +17 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +2 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/reconciliation_generator.rb +34 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_call_rollups.rb.erb +15 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_calls.rb.erb +104 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_durable_ingestion.rb.erb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_cost_tracker_reconciliation.rb.erb +55 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +28 -25
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_rollups_provider.rb.erb +20 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_call_tags_key_value_index.rb.erb +32 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_image_tokens.rb.erb +18 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_call_rollups_provider_generator.rb +38 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_provider_response_id_generator.rb → upgrade_call_tags_key_value_index_generator.rb} +5 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/{add_streaming_generator.rb → upgrade_image_tokens_generator.rb} +4 -4
- data/lib/llm_cost_tracker/ingestion/batch.rb +11 -12
- data/lib/llm_cost_tracker/ingestion/inbox.rb +39 -24
- data/lib/llm_cost_tracker/ingestion/inline.rb +22 -0
- data/lib/llm_cost_tracker/ingestion/worker.rb +24 -7
- data/lib/llm_cost_tracker/ingestion.rb +66 -22
- data/lib/llm_cost_tracker/integrations/anthropic.rb +68 -42
- data/lib/llm_cost_tracker/integrations/base.rb +56 -32
- data/lib/llm_cost_tracker/integrations/openai.rb +342 -63
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +110 -11
- data/lib/llm_cost_tracker/integrations.rb +21 -3
- data/lib/llm_cost_tracker/ledger/period/totals.rb +30 -11
- data/lib/llm_cost_tracker/ledger/period.rb +5 -5
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +2 -2
- data/lib/llm_cost_tracker/ledger/rollups.rb +90 -25
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +18 -0
- data/lib/llm_cost_tracker/ledger/schema/call_line_items.rb +79 -0
- data/lib/llm_cost_tracker/ledger/schema/call_rollups.rb +37 -0
- data/lib/llm_cost_tracker/ledger/schema/call_tags.rb +41 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +36 -23
- data/lib/llm_cost_tracker/ledger/schema/ingestion_inbox_entries.rb +47 -0
- data/lib/llm_cost_tracker/ledger/schema/ingestion_leases.rb +42 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoice_imports.rb +46 -0
- data/lib/llm_cost_tracker/ledger/schema/provider_invoices.rb +57 -0
- data/lib/llm_cost_tracker/ledger/store.rb +103 -20
- data/lib/llm_cost_tracker/ledger/tags/encoding.rb +37 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +6 -11
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +27 -15
- data/lib/llm_cost_tracker/ledger.rb +5 -2
- data/lib/llm_cost_tracker/logging.rb +2 -5
- data/lib/llm_cost_tracker/masking.rb +39 -0
- data/lib/llm_cost_tracker/middleware/faraday.rb +95 -35
- data/lib/llm_cost_tracker/parsers/anthropic.rb +74 -14
- data/lib/llm_cost_tracker/parsers/base.rb +13 -4
- data/lib/llm_cost_tracker/parsers/gemini.rb +105 -15
- data/lib/llm_cost_tracker/parsers/openai.rb +16 -2
- data/lib/llm_cost_tracker/parsers/openai_compatible.rb +15 -3
- data/lib/llm_cost_tracker/parsers/openai_service_charges.rb +126 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +157 -59
- data/lib/llm_cost_tracker/parsers/sse.rb +1 -1
- data/lib/llm_cost_tracker/parsers.rb +1 -1
- data/lib/llm_cost_tracker/prices.json +198 -22
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +28 -21
- data/lib/llm_cost_tracker/pricing/explainer.rb +4 -5
- data/lib/llm_cost_tracker/pricing/lookup.rb +73 -36
- data/lib/llm_cost_tracker/pricing/mode.rb +76 -0
- data/lib/llm_cost_tracker/pricing/registry.rb +67 -45
- data/lib/llm_cost_tracker/pricing/service_charges.rb +210 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +26 -17
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +6 -15
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +50 -1
- data/lib/llm_cost_tracker/pricing/sync.rb +59 -10
- data/lib/llm_cost_tracker/pricing/sync_change_printer.rb +32 -0
- data/lib/llm_cost_tracker/pricing.rb +220 -28
- data/lib/llm_cost_tracker/railtie.rb +6 -8
- data/lib/llm_cost_tracker/reconcile_tasks.rb +134 -0
- data/lib/llm_cost_tracker/reconciliation/diff.rb +428 -0
- data/lib/llm_cost_tracker/reconciliation/diff_result.rb +48 -0
- data/lib/llm_cost_tracker/reconciliation/import_result.rb +19 -0
- data/lib/llm_cost_tracker/reconciliation/importer.rb +253 -0
- data/lib/llm_cost_tracker/reconciliation/sources/anthropic_usage.rb +171 -0
- data/lib/llm_cost_tracker/reconciliation/sources/fingerprint.rb +20 -0
- data/lib/llm_cost_tracker/reconciliation/sources/openai_usage.rb +142 -0
- data/lib/llm_cost_tracker/reconciliation.rb +118 -0
- data/lib/llm_cost_tracker/report/data.rb +19 -8
- data/lib/llm_cost_tracker/report.rb +0 -4
- data/lib/llm_cost_tracker/retention.rb +22 -9
- data/lib/llm_cost_tracker/tags/context.rb +2 -5
- data/lib/llm_cost_tracker/tags/key.rb +4 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +71 -20
- data/lib/llm_cost_tracker/timing.rb +15 -0
- data/lib/llm_cost_tracker/token_usage.rb +64 -42
- data/lib/llm_cost_tracker/tracker.rb +97 -27
- data/lib/llm_cost_tracker/usage_capture.rb +29 -8
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +45 -35
- data/lib/tasks/llm_cost_tracker.rake +45 -17
- metadata +71 -41
- data/app/models/llm_cost_tracker/ingestion/event.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/call.rb +0 -45
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +0 -66
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +0 -71
- data/app/models/llm_cost_tracker/ledger/period/total.rb +0 -13
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +0 -19
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_latency_ms_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_period_totals_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +0 -42
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +0 -33
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_latency_ms_to_llm_api_calls.rb.erb +0 -9
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +0 -104
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_provider_response_id_to_llm_api_calls.rb.erb +0 -15
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +0 -22
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +0 -83
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +0 -26
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +0 -44
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_cost_precision_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/upgrade_tags_to_jsonb_generator.rb +0 -29
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +0 -43
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +0 -32
- data/lib/llm_cost_tracker/pricing/components.rb +0 -37
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +0 -63
|
@@ -19,9 +19,10 @@ module LlmCostTracker
|
|
|
19
19
|
format.html do
|
|
20
20
|
@page = Dashboard::Pagination.call(params)
|
|
21
21
|
@calls_count = scope.count
|
|
22
|
-
@calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
|
|
22
|
+
@calls = ordered_scope.includes(:tag_records).limit(@page.limit).offset(@page.offset).to_a
|
|
23
23
|
end
|
|
24
24
|
format.csv do
|
|
25
|
+
response.headers["Cache-Control"] = "no-store"
|
|
25
26
|
send_data render_csv(ordered_scope.limit(CSV_EXPORT_LIMIT)),
|
|
26
27
|
type: "text/csv",
|
|
27
28
|
disposition: %(attachment; filename="llm_calls_#{Time.now.utc.strftime('%Y%m%d_%H%M%S')}.csv")
|
|
@@ -30,7 +31,7 @@ module LlmCostTracker
|
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def show
|
|
33
|
-
@call =
|
|
34
|
+
@call = LlmCostTracker::Call.find(params[:id])
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
private
|
|
@@ -55,38 +56,40 @@ module LlmCostTracker
|
|
|
55
56
|
CSV.generate do |csv|
|
|
56
57
|
csv << fields.map(&:to_s)
|
|
57
58
|
|
|
58
|
-
relation.
|
|
59
|
-
csv << fields.
|
|
59
|
+
relation.includes(:tag_records).each do |call|
|
|
60
|
+
csv << fields.map { |field| csv_value(field, call) }
|
|
60
61
|
end
|
|
61
62
|
end
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
def csv_fields
|
|
65
66
|
%i[tracked_at provider model] +
|
|
66
|
-
TokenUsage
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
TokenUsage.members +
|
|
68
|
+
%i[
|
|
69
|
+
total_cost cost_status pricing_snapshot latency_ms provider_response_id provider_project_id
|
|
70
|
+
provider_api_key_id provider_workspace_id batch tags
|
|
71
|
+
]
|
|
69
72
|
end
|
|
70
73
|
|
|
71
|
-
def csv_value(field,
|
|
74
|
+
def csv_value(field, call)
|
|
72
75
|
case field
|
|
73
76
|
when :tracked_at
|
|
74
|
-
|
|
75
|
-
when :
|
|
76
|
-
csv_safe(
|
|
77
|
+
call.tracked_at&.utc&.iso8601
|
|
78
|
+
when :provider_api_key_id, :provider_workspace_id, :provider_project_id
|
|
79
|
+
csv_safe(LlmCostTracker::Masking.mask_value(field, call[field]))
|
|
80
|
+
when :provider, :model, :provider_response_id, :cost_status
|
|
81
|
+
csv_safe(call[field])
|
|
82
|
+
when :pricing_snapshot
|
|
83
|
+
csv_safe(csv_json(call.pricing_snapshot))
|
|
77
84
|
when :tags
|
|
78
|
-
csv_safe(
|
|
85
|
+
csv_safe(call.parsed_tags.to_json)
|
|
79
86
|
else
|
|
80
|
-
|
|
87
|
+
call[field]
|
|
81
88
|
end
|
|
82
89
|
end
|
|
83
90
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
JSON.parse(value || "{}").to_json
|
|
88
|
-
rescue JSON::ParserError
|
|
89
|
-
"{}"
|
|
91
|
+
def csv_json(value)
|
|
92
|
+
Hash(value).deep_stringify_keys.to_json
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
def csv_safe(value)
|
|
@@ -5,9 +5,21 @@ module LlmCostTracker
|
|
|
5
5
|
def index
|
|
6
6
|
scope = Dashboard::Filter.call(params: params)
|
|
7
7
|
@stats = Dashboard::DataQuality.call(scope: scope)
|
|
8
|
-
@
|
|
8
|
+
@summary = Dashboard::DataQuality.summary(@stats)
|
|
9
|
+
@usage_rows = Dashboard::DataQuality.usage_rows(
|
|
10
|
+
@stats,
|
|
11
|
+
component_costs: Dashboard::DataQuality.component_costs(scope)
|
|
12
|
+
)
|
|
9
13
|
@hidden_output_summary = Dashboard::DataQuality.hidden_output_summary(@stats)
|
|
10
|
-
@unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(
|
|
14
|
+
@unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(
|
|
15
|
+
scope,
|
|
16
|
+
total_calls: @summary.total
|
|
17
|
+
)
|
|
18
|
+
@service_charge_rows = Dashboard::DataQuality.service_charge_rows(scope).to_a
|
|
19
|
+
@streaming_health_rows = Dashboard::DataQuality.streaming_health_rows(
|
|
20
|
+
scope,
|
|
21
|
+
total_streaming: @summary.streaming_count
|
|
22
|
+
)
|
|
11
23
|
end
|
|
12
24
|
end
|
|
13
25
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
class ReconciliationController < ApplicationController
|
|
5
|
+
def index
|
|
6
|
+
@reconciliation_enabled = LlmCostTracker::Reconciliation.enabled?
|
|
7
|
+
@reconciliation_installed = LlmCostTracker::ProviderInvoice.table_exists?
|
|
8
|
+
if @reconciliation_enabled && @reconciliation_installed
|
|
9
|
+
@scopes = invoice_scopes
|
|
10
|
+
@sources = @scopes.map { |scope| scope[:source] }.uniq
|
|
11
|
+
@diffs = @scopes.filter_map { |scope| diff_for(scope) }
|
|
12
|
+
@last_imported_at = LlmCostTracker::ProviderInvoice.maximum(:imported_at)
|
|
13
|
+
else
|
|
14
|
+
@scopes = []
|
|
15
|
+
@sources = []
|
|
16
|
+
@diffs = []
|
|
17
|
+
@last_imported_at = nil
|
|
18
|
+
end
|
|
19
|
+
@threshold = LlmCostTracker::Reconciliation::DEFAULT_THRESHOLD_PERCENT
|
|
20
|
+
@configured_importers = @reconciliation_enabled ? configured_importers : {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def trigger_import
|
|
24
|
+
unless LlmCostTracker::Reconciliation.enabled?
|
|
25
|
+
return redirect_to reconciliation_path, alert: "Reconciliation is disabled"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
source = params[:source].to_s
|
|
29
|
+
importer = configured_importers[source.to_sym]
|
|
30
|
+
return redirect_to reconciliation_path, alert: "No importer configured for #{source}" if importer.nil?
|
|
31
|
+
|
|
32
|
+
result = importer.call
|
|
33
|
+
if result.respond_to?(:errors) && result.errors.any?
|
|
34
|
+
LlmCostTracker::Logging.warn(
|
|
35
|
+
"Reconciliation import for #{source} returned #{result.errors.size} row error(s)"
|
|
36
|
+
)
|
|
37
|
+
return redirect_to(
|
|
38
|
+
reconciliation_path,
|
|
39
|
+
alert: "Imported #{result.respond_to?(:total_imported) ? result.total_imported : 0} " \
|
|
40
|
+
"#{source} rows with #{result.errors.size} row error(s); see Rails logs for details."
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
message = if result.respond_to?(:total_imported)
|
|
44
|
+
"Imported #{result.total_imported} #{source} rows"
|
|
45
|
+
else
|
|
46
|
+
"Triggered #{source} importer"
|
|
47
|
+
end
|
|
48
|
+
redirect_to reconciliation_path, notice: message
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
LlmCostTracker::Logging.warn("Reconciliation import failed for #{source}: #{e.class}: #{e.message}")
|
|
51
|
+
redirect_to reconciliation_path,
|
|
52
|
+
alert: "Import failed (#{e.class.name}); see Rails logs for details."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def configured_importers
|
|
58
|
+
LlmCostTracker.configuration.reconciliation_importers
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invoice_scopes
|
|
62
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
63
|
+
provider_expr =
|
|
64
|
+
if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
65
|
+
Arel.sql("metadata->>'provider'")
|
|
66
|
+
else
|
|
67
|
+
Arel.sql("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider'))")
|
|
68
|
+
end
|
|
69
|
+
LlmCostTracker::ProviderInvoice
|
|
70
|
+
.group(:source, provider_expr, :currency)
|
|
71
|
+
.order(:source, :currency)
|
|
72
|
+
.pluck(:source, provider_expr, :currency)
|
|
73
|
+
.map { |source, provider, currency| { source: source, provider: provider, currency: currency.upcase } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def diff_for(scope)
|
|
77
|
+
window = scope_invoices(scope)
|
|
78
|
+
.order(period_end: :desc, period_start: :desc)
|
|
79
|
+
.limit(1)
|
|
80
|
+
.pick(:period_start, :period_end)
|
|
81
|
+
return nil unless window
|
|
82
|
+
|
|
83
|
+
LlmCostTracker::Reconciliation.diff(
|
|
84
|
+
source: scope[:source], provider: scope[:provider], currency: scope[:currency],
|
|
85
|
+
period_start: window[0], period_end: window[1]
|
|
86
|
+
)
|
|
87
|
+
rescue ArgumentError => e
|
|
88
|
+
LlmCostTracker::Logging.warn("Reconciliation diff skipped for #{scope.inspect}: #{e.message}")
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scope_invoices(scope)
|
|
93
|
+
relation = LlmCostTracker::ProviderInvoice
|
|
94
|
+
.where(source: scope[:source], currency: scope[:currency])
|
|
95
|
+
connection = LlmCostTracker::ProviderInvoice.connection
|
|
96
|
+
provider = scope[:provider]
|
|
97
|
+
return relation if provider.nil? || provider.empty?
|
|
98
|
+
|
|
99
|
+
if LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
|
|
100
|
+
relation.where("metadata->>'provider' = ?", provider)
|
|
101
|
+
else
|
|
102
|
+
relation.where("JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.provider')) = ?", provider)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -7,7 +7,21 @@ module LlmCostTracker
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
|
|
10
|
+
scope = Dashboard::Filter.call(params: params)
|
|
11
|
+
@value = params[:tag_value].to_s
|
|
12
|
+
|
|
13
|
+
if @value.empty?
|
|
14
|
+
@breakdown = Dashboard::TagBreakdown.call(scope: scope, key: params[:key])
|
|
15
|
+
else
|
|
16
|
+
@key = LlmCostTracker::Tags::Key.validate!(
|
|
17
|
+
params[:key],
|
|
18
|
+
error_class: LlmCostTracker::InvalidFilterError
|
|
19
|
+
)
|
|
20
|
+
value_scope = scope.by_tag(@key, @value)
|
|
21
|
+
@value_total_cost = value_scope.sum(:total_cost).to_f
|
|
22
|
+
@value_calls = value_scope.count
|
|
23
|
+
@value_points = Dashboard::TimeSeries.call(scope: value_scope)
|
|
24
|
+
end
|
|
11
25
|
end
|
|
12
26
|
end
|
|
13
27
|
end
|
|
@@ -13,9 +13,11 @@ module LlmCostTracker
|
|
|
13
13
|
include ChartHelper
|
|
14
14
|
include PaginationHelper
|
|
15
15
|
include TokenUsageHelper
|
|
16
|
+
include InlineStyleHelper
|
|
16
17
|
|
|
17
18
|
def coverage_percent(numerator, denominator)
|
|
18
|
-
|
|
19
|
+
denominator = denominator.to_f
|
|
20
|
+
return 0.0 unless denominator.positive?
|
|
19
21
|
|
|
20
22
|
(numerator.to_f / denominator) * 100.0
|
|
21
23
|
end
|
|
@@ -39,16 +41,19 @@ module LlmCostTracker
|
|
|
39
41
|
number_with_delimiter(value.to_i)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
def format_tokens(value)
|
|
43
|
-
number(value)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
44
|
def format_date(value)
|
|
47
45
|
value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
def pricing_status(call)
|
|
51
|
-
|
|
49
|
+
return "Unknown pricing" if call.total_cost.nil?
|
|
50
|
+
return "Estimated" unless call.has_attribute?(:cost_status)
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
LlmCostTracker::Billing::CostStatus::COMPLETE => "Estimated",
|
|
54
|
+
LlmCostTracker::Billing::CostStatus::FREE => "Free",
|
|
55
|
+
LlmCostTracker::Billing::CostStatus::PARTIAL => "Partial pricing"
|
|
56
|
+
}.fetch(call.cost_status, "Unknown pricing")
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
def percent(value)
|
|
@@ -100,13 +105,13 @@ module LlmCostTracker
|
|
|
100
105
|
value.to_s
|
|
101
106
|
end
|
|
102
107
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
return
|
|
108
|
+
def masked_metadata_hash(value)
|
|
109
|
+
return value if value.is_a?(Hash)
|
|
110
|
+
return {} if value.nil?
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
112
|
+
JSON.parse(value.to_s)
|
|
113
|
+
rescue JSON::ParserError, TypeError
|
|
114
|
+
{}
|
|
110
115
|
end
|
|
111
116
|
|
|
112
117
|
def tag_chip_entries(tags, limit: 3)
|
|
@@ -124,14 +129,6 @@ module LlmCostTracker
|
|
|
124
129
|
truncate_text(safe_json(tags), TAG_TOOLTIP_BYTES)
|
|
125
130
|
end
|
|
126
131
|
|
|
127
|
-
def budget_fill_modifier(percent)
|
|
128
|
-
percent = percent.to_f
|
|
129
|
-
return "lct-budget-fill--over" if percent >= 100.0
|
|
130
|
-
return "lct-budget-fill--warn" if percent >= 80.0
|
|
131
|
-
|
|
132
|
-
""
|
|
133
|
-
end
|
|
134
|
-
|
|
135
132
|
def current_query(overrides = {})
|
|
136
133
|
request.query_parameters.symbolize_keys.merge(overrides)
|
|
137
134
|
end
|
|
@@ -164,7 +161,7 @@ module LlmCostTracker
|
|
|
164
161
|
def truncate_text(string, limit)
|
|
165
162
|
return string if string.bytesize <= limit
|
|
166
163
|
|
|
167
|
-
"#{string.byteslice(0, limit).
|
|
164
|
+
"#{string.byteslice(0, limit).encode('UTF-8', invalid: :replace, undef: :replace)}..."
|
|
168
165
|
end
|
|
169
166
|
end
|
|
170
167
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
module DashboardFilterHelper
|
|
5
|
-
FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag
|
|
5
|
+
FILTER_PARAM_KEYS = %i[from to provider model stream usage_source tag].freeze
|
|
6
6
|
|
|
7
7
|
STREAM_FILTER_OPTIONS = [
|
|
8
8
|
["Streaming only", "yes"],
|
|
@@ -14,33 +14,15 @@ module LlmCostTracker
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def active_tag_filters
|
|
17
|
-
tag_params = LlmCostTracker::Dashboard::Params.
|
|
17
|
+
tag_params = LlmCostTracker::Dashboard::Params.tag_query(params[:tag])
|
|
18
18
|
|
|
19
19
|
tag_params.filter_map do |key, value|
|
|
20
|
-
next if key.blank? || value.blank?
|
|
21
|
-
|
|
22
20
|
{
|
|
23
21
|
label: "Tag",
|
|
24
22
|
value: "#{key}=#{value}",
|
|
25
|
-
path: dashboard_filter_path(current_query(tag: tag_params.except(key
|
|
23
|
+
path: dashboard_filter_path(current_query(tag: tag_params.except(key).presence, page: nil))
|
|
26
24
|
}
|
|
27
25
|
end
|
|
28
26
|
end
|
|
29
|
-
|
|
30
|
-
def dashboard_date_range_label(from, to)
|
|
31
|
-
from_label = short_date_label(from) || "Any time"
|
|
32
|
-
to_label = short_date_label(to) || "Now"
|
|
33
|
-
"#{from_label} - #{to_label}"
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def short_date_label(value)
|
|
39
|
-
return nil if value.blank?
|
|
40
|
-
|
|
41
|
-
Date.iso8601(value.to_s).strftime("%b %-d, %Y")
|
|
42
|
-
rescue ArgumentError
|
|
43
|
-
value.to_s
|
|
44
|
-
end
|
|
45
27
|
end
|
|
46
28
|
end
|
|
@@ -15,14 +15,14 @@ module LlmCostTracker
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def filter_options_for(column, filter_params:)
|
|
18
|
-
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params)
|
|
19
|
-
scope_params = source.
|
|
20
|
-
column
|
|
18
|
+
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params).symbolize_keys
|
|
19
|
+
scope_params = source.merge(
|
|
20
|
+
column => nil, format: nil, page: nil, per: nil, sort: nil
|
|
21
21
|
)
|
|
22
22
|
values = LlmCostTracker::Dashboard::Filter.call(params: scope_params)
|
|
23
23
|
.where.not(column => [nil, ""])
|
|
24
24
|
.distinct.order(column).limit(MAX_FILTER_OPTIONS).pluck(column)
|
|
25
|
-
current = source[column
|
|
25
|
+
current = source[column].presence
|
|
26
26
|
values.unshift(current) if current && !values.include?(current)
|
|
27
27
|
values
|
|
28
28
|
end
|
|
@@ -11,7 +11,7 @@ module LlmCostTracker
|
|
|
11
11
|
|
|
12
12
|
def calls_query_for_tag(key:, value:)
|
|
13
13
|
query = current_query(page: nil, per: nil, format: nil)
|
|
14
|
-
tags = LlmCostTracker::Dashboard::Params.
|
|
14
|
+
tags = LlmCostTracker::Dashboard::Params.tag_query(query[:tag])
|
|
15
15
|
query[:tag] = tags.merge(key.to_s => value.to_s)
|
|
16
16
|
query
|
|
17
17
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module InlineStyleHelper
|
|
5
|
+
UNSAFE_CSS_CHARS = /[<>{}"]/
|
|
6
|
+
|
|
7
|
+
def inline_style(declarations)
|
|
8
|
+
registry = inline_style_registry
|
|
9
|
+
token = "lct-i-#{registry.length}"
|
|
10
|
+
registry << [token, declarations.to_s.gsub(UNSAFE_CSS_CHARS, "")]
|
|
11
|
+
token
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def inline_style_block
|
|
15
|
+
registry = inline_style_registry
|
|
16
|
+
return "".html_safe if registry.empty?
|
|
17
|
+
|
|
18
|
+
rules = registry.map { |token, decl| %([data-lct-style="#{token}"]{#{decl}}) }.join("\n")
|
|
19
|
+
content_tag(:style, rules.html_safe, nonce: dashboard_csp_nonce)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def inline_style_registry
|
|
25
|
+
@inline_style_registry ||= []
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ReconciliationHelper
|
|
5
|
+
def attribution_summary(attribution)
|
|
6
|
+
LlmCostTracker::Masking.format_attribution(attribution)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def mask_secret(value)
|
|
10
|
+
LlmCostTracker::Masking.mask_value(:provider_api_key_id, value)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -6,22 +6,30 @@ module LlmCostTracker
|
|
|
6
6
|
input_tokens: "Input",
|
|
7
7
|
cache_read_input_tokens: "Cache read",
|
|
8
8
|
cache_write_input_tokens: "Cache write",
|
|
9
|
-
|
|
9
|
+
cache_write_extended_input_tokens: "Extended cache write",
|
|
10
|
+
audio_input_tokens: "Audio input",
|
|
11
|
+
image_input_tokens: "Image input",
|
|
10
12
|
output_tokens: "Output",
|
|
13
|
+
audio_output_tokens: "Audio output",
|
|
14
|
+
image_output_tokens: "Image output",
|
|
11
15
|
hidden_output_tokens: "Hidden output"
|
|
12
16
|
}.freeze
|
|
13
17
|
QUALITY_LABELS = COMPONENT_LABELS.merge(
|
|
14
18
|
input_tokens: "Regular input",
|
|
15
19
|
cache_read_input_tokens: "Cache read input",
|
|
16
20
|
cache_write_input_tokens: "Cache write input",
|
|
17
|
-
|
|
21
|
+
cache_write_extended_input_tokens: "Extended cache write input"
|
|
18
22
|
).freeze
|
|
19
23
|
STACK_CLASSES = {
|
|
20
24
|
input_tokens: "lct-stack-fill-input",
|
|
21
25
|
cache_read_input_tokens: "lct-stack-fill-cache-read",
|
|
22
26
|
cache_write_input_tokens: "lct-stack-fill-cache-write",
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
cache_write_extended_input_tokens: "lct-stack-fill-cache-write-extended",
|
|
28
|
+
audio_input_tokens: "lct-stack-fill-audio-input",
|
|
29
|
+
image_input_tokens: "lct-stack-fill-image-input",
|
|
30
|
+
output_tokens: "lct-stack-fill-output",
|
|
31
|
+
audio_output_tokens: "lct-stack-fill-audio-output",
|
|
32
|
+
image_output_tokens: "lct-stack-fill-image-output"
|
|
25
33
|
}.freeze
|
|
26
34
|
|
|
27
35
|
def token_usage_stack_components
|
|
@@ -30,18 +38,26 @@ module LlmCostTracker
|
|
|
30
38
|
end
|
|
31
39
|
end
|
|
32
40
|
|
|
33
|
-
def
|
|
34
|
-
|
|
41
|
+
def call_line_item_costs_by_component(call)
|
|
42
|
+
call.line_items.each_with_object({}) do |line_item, accumulator|
|
|
43
|
+
component = LlmCostTracker::Billing::Components::TOKEN_PRICED.find do |item|
|
|
44
|
+
item.kind.to_s == line_item.kind.to_s &&
|
|
45
|
+
item.direction.to_s == line_item.direction.to_s &&
|
|
46
|
+
item.cache_state.to_s == line_item.cache_state.to_s
|
|
47
|
+
end
|
|
48
|
+
accumulator[component.key] = line_item.cost if component && line_item.cost
|
|
49
|
+
end
|
|
35
50
|
end
|
|
36
51
|
|
|
37
52
|
private
|
|
38
53
|
|
|
39
54
|
def token_usage_display_components(labels:)
|
|
40
|
-
LlmCostTracker::
|
|
55
|
+
LlmCostTracker::Billing::Components::TOKEN_PRICED.map do |component|
|
|
41
56
|
token_key = component.token_key
|
|
42
57
|
{
|
|
43
58
|
token_key: token_key,
|
|
44
59
|
cost_key: component.cost_key,
|
|
60
|
+
price_key: component.key,
|
|
45
61
|
label: labels.fetch(token_key),
|
|
46
62
|
css_class: STACK_CLASSES[token_key]
|
|
47
63
|
}
|
|
@@ -49,6 +65,7 @@ module LlmCostTracker
|
|
|
49
65
|
{
|
|
50
66
|
token_key: :hidden_output_tokens,
|
|
51
67
|
cost_key: nil,
|
|
68
|
+
price_key: nil,
|
|
52
69
|
label: labels.fetch(:hidden_output_tokens),
|
|
53
70
|
css_class: nil
|
|
54
71
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require "llm_cost_tracker/billing/cost_status"
|
|
6
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
7
|
+
require "llm_cost_tracker/ledger/tags/sql"
|
|
8
|
+
|
|
9
|
+
module LlmCostTracker
|
|
10
|
+
class Call < ActiveRecord::Base
|
|
11
|
+
before_validation :assign_event_id
|
|
12
|
+
|
|
13
|
+
PERIOD_FORMATS = {
|
|
14
|
+
day: {
|
|
15
|
+
postgres: "YYYY-MM-DD",
|
|
16
|
+
mysql: "%Y-%m-%d"
|
|
17
|
+
},
|
|
18
|
+
month: {
|
|
19
|
+
postgres: "YYYY-MM",
|
|
20
|
+
mysql: "%Y-%m"
|
|
21
|
+
}
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
private_constant :PERIOD_FORMATS
|
|
25
|
+
|
|
26
|
+
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
27
|
+
scope :without_cost, -> { where(total_cost: nil) }
|
|
28
|
+
scope :unknown_pricing, lambda {
|
|
29
|
+
where(total_cost: nil).or(
|
|
30
|
+
where(cost_status: [Billing::CostStatus::UNKNOWN, Billing::CostStatus::PARTIAL])
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
scope :with_latency, -> { where.not(latency_ms: nil) }
|
|
34
|
+
scope :streaming, -> { where(stream: true) }
|
|
35
|
+
scope :non_streaming, -> { where(stream: [false, nil]) }
|
|
36
|
+
scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
|
|
37
|
+
scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
|
|
38
|
+
scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
|
|
39
|
+
scope :streaming_missing_usage, lambda {
|
|
40
|
+
where(stream: true).where(usage_source: ["unknown", nil])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
has_many :line_items,
|
|
44
|
+
class_name: "LlmCostTracker::CallLineItem",
|
|
45
|
+
foreign_key: :llm_cost_tracker_call_id,
|
|
46
|
+
inverse_of: :call,
|
|
47
|
+
dependent: :delete_all
|
|
48
|
+
|
|
49
|
+
has_many :tag_records,
|
|
50
|
+
class_name: "LlmCostTracker::CallTag",
|
|
51
|
+
foreign_key: :llm_cost_tracker_call_id,
|
|
52
|
+
inverse_of: :call,
|
|
53
|
+
dependent: :delete_all
|
|
54
|
+
|
|
55
|
+
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
56
|
+
scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
|
|
57
|
+
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
58
|
+
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def by_tag(key, value) = by_tags(key => value)
|
|
62
|
+
|
|
63
|
+
def by_tags(tags) = Ledger::Tags::Query.apply(tags)
|
|
64
|
+
|
|
65
|
+
def total_cost = sum(:total_cost).to_f
|
|
66
|
+
|
|
67
|
+
def total_tokens = sum(:total_tokens).to_i
|
|
68
|
+
|
|
69
|
+
def cost_by_model(limit: nil) = cost_by_column(:model, limit: limit)
|
|
70
|
+
|
|
71
|
+
def cost_by_provider(limit: nil) = cost_by_column(:provider, limit: limit)
|
|
72
|
+
|
|
73
|
+
def group_by_tag(key)
|
|
74
|
+
Ledger::Tags::Sql.join_relation(self, key).group(Ledger::Tags::Sql.value_arel)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cost_by_tag(key, limit: nil)
|
|
78
|
+
label = Ledger::Tags::Sql.label_sql(connection)
|
|
79
|
+
raw_value = Ledger::Tags::Sql.raw_value_sql(connection)
|
|
80
|
+
relation = Ledger::Tags::Sql.join_relation(self, key)
|
|
81
|
+
.select("#{label} AS name", "COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
82
|
+
.group(Arel.sql(label))
|
|
83
|
+
.order(
|
|
84
|
+
Arel.sql("COALESCE(SUM(total_cost), 0) DESC"),
|
|
85
|
+
Arel.sql("MAX(CASE WHEN #{raw_value} IS NULL THEN 1 ELSE 0 END) ASC"),
|
|
86
|
+
Arel.sql("#{label} DESC")
|
|
87
|
+
)
|
|
88
|
+
relation = relation.limit(limit) if limit
|
|
89
|
+
relation
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def average_latency_ms = average(:latency_ms)&.to_f
|
|
93
|
+
|
|
94
|
+
def latency_by_model = group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
95
|
+
|
|
96
|
+
def latency_by_provider = group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
97
|
+
|
|
98
|
+
def group_by_period(period, column: :tracked_at)
|
|
99
|
+
group(Arel.sql(period_group_expression(period, column: column)))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def daily_costs(days: 30)
|
|
103
|
+
where(tracked_at: days.days.ago..)
|
|
104
|
+
.group_by_period(:day)
|
|
105
|
+
.sum(:total_cost)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def cost_by_column(column, limit:)
|
|
111
|
+
quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
|
|
112
|
+
relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
113
|
+
.group(column)
|
|
114
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
115
|
+
relation = relation.limit(limit) if limit
|
|
116
|
+
relation
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def period_group_expression(period, column:)
|
|
120
|
+
period = validated_period(period)
|
|
121
|
+
column = period_column_expression(column)
|
|
122
|
+
formats = PERIOD_FORMATS.fetch(period)
|
|
123
|
+
|
|
124
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
125
|
+
postgres_period_expression(period, column, formats)
|
|
126
|
+
elsif Ledger::Schema::Adapter.mysql?(connection)
|
|
127
|
+
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
128
|
+
else
|
|
129
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def postgres_period_expression(period, column, formats)
|
|
134
|
+
"TO_CHAR(" \
|
|
135
|
+
"DATE_TRUNC(#{connection.quote(period.name)}, #{column}), " \
|
|
136
|
+
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
137
|
+
")"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validated_period(period)
|
|
141
|
+
return period if PERIOD_FORMATS.key?(period)
|
|
142
|
+
|
|
143
|
+
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def period_column_expression(column)
|
|
147
|
+
column = column.to_s
|
|
148
|
+
return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
|
|
149
|
+
|
|
150
|
+
raise ArgumentError, "invalid period column: #{column.inspect}"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parsed_tags
|
|
155
|
+
tag_records.to_h do |record|
|
|
156
|
+
[record.key, record.value]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def assign_event_id
|
|
163
|
+
self.event_id ||= SecureRandom.uuid
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|