llm_cost_tracker 0.6.1 → 0.7.1
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 +24 -0
- data/README.md +13 -12
- data/app/assets/llm_cost_tracker/application.css +3 -0
- data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
- data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
- data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
- data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
- data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
- data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
- data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
- data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
- data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
- data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
- data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
- data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
- data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
- data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
- data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
- data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
- data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
- data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
- data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
- data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
- data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
- data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
- data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
- data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
- data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
- data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -37
- data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
- data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
- data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
- data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
- data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
- data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
- data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
- data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
- data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
- data/config/routes.rb +1 -1
- data/lib/llm_cost_tracker/assets.rb +0 -6
- data/lib/llm_cost_tracker/budget.rb +10 -24
- data/lib/llm_cost_tracker/capture/stream.rb +9 -0
- data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
- data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
- data/lib/llm_cost_tracker/configuration.rb +30 -45
- data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
- data/lib/llm_cost_tracker/doctor/check.rb +7 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -61
- data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
- data/lib/llm_cost_tracker/doctor.rb +66 -79
- data/lib/llm_cost_tracker/engine.rb +0 -3
- data/lib/llm_cost_tracker/errors.rb +4 -15
- data/lib/llm_cost_tracker/event.rb +6 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +5 -5
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +15 -14
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -21
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
- data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
- data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
- data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
- data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
- data/lib/llm_cost_tracker/ingestion.rb +129 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
- data/lib/llm_cost_tracker/integrations/base.rb +73 -34
- data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
- data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
- data/lib/llm_cost_tracker/integrations.rb +43 -0
- data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
- data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
- data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
- data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
- data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
- data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
- data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
- data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
- data/lib/llm_cost_tracker/ledger/store.rb +60 -0
- data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
- data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
- data/lib/llm_cost_tracker/ledger.rb +13 -0
- data/lib/llm_cost_tracker/logging.rb +3 -6
- data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
- data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
- data/lib/llm_cost_tracker/parsers/base.rb +10 -19
- data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
- data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
- data/lib/llm_cost_tracker/parsers.rb +20 -0
- data/lib/llm_cost_tracker/prices.json +52 -11
- data/lib/llm_cost_tracker/pricing/components.rb +37 -0
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
- data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
- data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
- data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
- data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
- data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
- data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
- data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
- data/lib/llm_cost_tracker/pricing.rb +33 -32
- data/lib/llm_cost_tracker/railtie.rb +7 -10
- data/lib/llm_cost_tracker/report/data.rb +72 -0
- data/lib/llm_cost_tracker/report/formatter.rb +69 -0
- data/lib/llm_cost_tracker/report.rb +8 -10
- data/lib/llm_cost_tracker/retention.rb +27 -10
- data/lib/llm_cost_tracker/tags/context.rb +35 -0
- data/lib/llm_cost_tracker/tags/key.rb +18 -0
- data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
- data/lib/llm_cost_tracker/token_usage.rb +67 -0
- data/lib/llm_cost_tracker/tracker.rb +38 -70
- data/lib/llm_cost_tracker/usage_capture.rb +37 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +56 -90
- data/lib/tasks/llm_cost_tracker.rake +18 -13
- metadata +85 -99
- data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
- data/app/services/llm_cost_tracker/pagination.rb +0 -57
- data/lib/llm_cost_tracker/active_record_adapter.rb +0 -49
- data/lib/llm_cost_tracker/capture_verifier.rb +0 -71
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
- data/lib/llm_cost_tracker/cost.rb +0 -12
- data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
- data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
- data/lib/llm_cost_tracker/event_metadata.rb +0 -52
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
- data/lib/llm_cost_tracker/inbox_event.rb +0 -9
- data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
- data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
- data/lib/llm_cost_tracker/integrations/registry.rb +0 -73
- data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
- data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
- data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
- data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
- data/lib/llm_cost_tracker/period_grouping.rb +0 -69
- data/lib/llm_cost_tracker/period_total.rb +0 -9
- data/lib/llm_cost_tracker/price_freshness.rb +0 -38
- data/lib/llm_cost_tracker/price_registry.rb +0 -144
- data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
- data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
- data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
- data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
- data/lib/llm_cost_tracker/price_sync.rb +0 -144
- data/lib/llm_cost_tracker/report_data.rb +0 -94
- data/lib/llm_cost_tracker/report_formatter.rb +0 -67
- data/lib/llm_cost_tracker/request_url.rb +0 -20
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -166
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -165
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
- data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
- data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
- data/lib/llm_cost_tracker/storage/dispatcher.rb +0 -45
- data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
- data/lib/llm_cost_tracker/storage/registry.rb +0 -63
- data/lib/llm_cost_tracker/stream_capture.rb +0 -7
- data/lib/llm_cost_tracker/stream_collector.rb +0 -199
- data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
- data/lib/llm_cost_tracker/tag_context.rb +0 -52
- data/lib/llm_cost_tracker/tag_key.rb +0 -16
- data/lib/llm_cost_tracker/tag_query.rb +0 -43
- data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
- data/lib/llm_cost_tracker/tag_sql.rb +0 -34
- data/lib/llm_cost_tracker/tags_column.rb +0 -103
- data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
- data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
- data/lib/llm_cost_tracker/value_helpers.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fbf918d9a4886e24ba99f93dc1125e016e50b45208437dd3254adda76f58033a
|
|
4
|
+
data.tar.gz: 8a22ccfa517549f55d2a302287660a8a5d7faf03568e80c253e6024ad4988743
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ef6bc278f6f98ce37a91e515e8b4a004aaff5f573fc8580e312e53b903d1e7700b2cd58b9bea72fc0ec904010cba43fb0209e7993c31bb69205b42564554884
|
|
7
|
+
data.tar.gz: 4c9a193d16bb5e8bfa58aaefb6fb9b7d98a632a8e19203927c3fd29b957da6088af71caf5c14f5f2b7452660f78001d7898745f8d7611a4fd6e94311b723bc71
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.7.1] - 2026-04-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- BREAKING: ActiveRecord ledger write failures now raise directly; removed `storage_error_behavior` and `StorageError`.
|
|
12
|
+
- BREAKING: Removed custom parser and SDK integration registration APIs; use built-in capture or explicit `track` / `track_stream`.
|
|
13
|
+
- BREAKING: Usage and pricing APIs now use `TokenUsage`; removed `UsageBreakdown`, `add_usage_breakdown`, direct `Pricing` token arguments, and `Pricing::Cost`.
|
|
14
|
+
- BREAKING: `Tracker.record` now accepts `UsageCapture`, and notification payloads nest `token_usage`.
|
|
15
|
+
- BREAKING: Moved price registry and refresh APIs under `LlmCostTracker::Pricing`.
|
|
16
|
+
- BREAKING: ActiveRecord installs must run the current ledger and period-total migrations; doctor, dashboard setup, and flush now fail on stale schema.
|
|
17
|
+
- BREAKING: `cache_write_input_tokens` now stores only standard cache writes; 1-hour cache writes use `cache_write_1h_input_tokens` and `cache_write_1h_input_cost`.
|
|
18
|
+
- Dashboard model and data-quality pages now use canonical `TokenUsage` totals.
|
|
19
|
+
- OpenAI, Anthropic, and RubyLLM capture now populate `pricing_mode` from provider tier data.
|
|
20
|
+
- Pricing now handles Anthropic 1-hour cache-write TTLs, Gemini context-cache reads, stackable batch cache rates, and long-context tiers.
|
|
21
|
+
- Missing positive-token pricing-mode rates now return unknown pricing instead of falling back to standard prices.
|
|
22
|
+
|
|
23
|
+
## [0.7.0] - 2026-04-29
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- BREAKING: ActiveRecord is now the only storage path; removed `storage_backend`, `custom_storage`, `Storage.register`, `:log`, and `:custom`.
|
|
28
|
+
- BREAKING: PostgreSQL and MySQL are now the only supported database adapters; SQLite support was removed.
|
|
29
|
+
- Runtime dependencies now include Rails and ActiveRecord.
|
|
30
|
+
|
|
7
31
|
## [0.6.1] - 2026-04-29
|
|
8
32
|
|
|
9
33
|
### Fixed
|
data/README.md
CHANGED
|
@@ -10,7 +10,7 @@ If you have OpenAI, Anthropic, or Gemini in production and someone keeps asking
|
|
|
10
10
|
|
|
11
11
|
It is not Langfuse, Helicone, or LiteLLM. It does not capture prompts, score completions, or replay traces. It does one thing: tells you which provider, which model, which feature, and which user burned how much money. That's the entire pitch.
|
|
12
12
|
|
|
13
|
-
Requires Ruby 3.3+,
|
|
13
|
+
Requires Ruby 3.3+, Rails 7.1+, PostgreSQL or MySQL, and Faraday 2.0+.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -35,7 +35,6 @@ Drop this into `config/initializers/llm_cost_tracker.rb`:
|
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
37
|
LlmCostTracker.configure do |config|
|
|
38
|
-
config.storage_backend = :active_record
|
|
39
38
|
config.default_tags = -> { { environment: Rails.env } }
|
|
40
39
|
config.instrument :openai
|
|
41
40
|
end
|
|
@@ -91,7 +90,7 @@ LlmCostTracker.with_tags(feature: "support_chat") do
|
|
|
91
90
|
end
|
|
92
91
|
```
|
|
93
92
|
|
|
94
|
-
Captures usage, model, latency, response ID, cache tokens, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
|
|
93
|
+
Captures usage, model, latency, response ID, pricing mode, cache tokens, Anthropic cache-write TTLs, and reasoning tokens whenever the SDK exposes them. Provider SDKs are not added as gem dependencies — you install whichever you actually use.
|
|
95
94
|
|
|
96
95
|
Enabled integrations are checked at boot: the client gem must be loaded, meet the minimum supported version, and expose the expected classes and methods. If the contract check fails, boot raises instead of silently missing spend.
|
|
97
96
|
|
|
@@ -135,7 +134,7 @@ For streaming the same way, `track_stream` accepts a block, parses provider even
|
|
|
135
134
|
|
|
136
135
|
## Tags: who burned this money
|
|
137
136
|
|
|
138
|
-
Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings,
|
|
137
|
+
Tags answer the only question that matters in attribution: which feature, which user, which job, which tenant. They're free-form strings, stored as JSONB on PostgreSQL or JSON on MySQL, and queryable from both Ruby and the dashboard.
|
|
139
138
|
|
|
140
139
|
```ruby
|
|
141
140
|
LlmCostTracker.with_tags(user_id: current_user.id, feature: "support_chat", trace_id: request.uuid) do
|
|
@@ -173,7 +172,9 @@ Explain why a model is priced or unknown:
|
|
|
173
172
|
PROVIDER=openai MODEL=gpt-4o bin/rails llm_cost_tracker:prices:explain
|
|
174
173
|
```
|
|
175
174
|
|
|
176
|
-
Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
|
|
175
|
+
Precedence is `pricing_overrides` → `prices_file` → bundled. Provider-qualified keys like `openai/gpt-4o-mini` win over model-only keys.
|
|
176
|
+
|
|
177
|
+
`pricing_mode` selects mode-prefixed rates such as `batch_input` or `priority_output`. Built-in capture fills it from provider tier fields when available; explicit `track` calls can pass it directly for batch jobs or gateway-specific modes. Full pricing reference: [`docs/pricing.md`](docs/pricing.md).
|
|
177
178
|
|
|
178
179
|
## Budgets
|
|
179
180
|
|
|
@@ -196,10 +197,10 @@ Full behavior, error class, and preflight details: [`docs/budgets.md`](docs/budg
|
|
|
196
197
|
When you want to slice spend from a console, scheduled job, or your own admin page:
|
|
197
198
|
|
|
198
199
|
```ruby
|
|
199
|
-
LlmCostTracker::
|
|
200
|
-
LlmCostTracker::
|
|
201
|
-
LlmCostTracker::
|
|
202
|
-
LlmCostTracker::
|
|
200
|
+
LlmCostTracker::Ledger::Call.this_month.cost_by_model
|
|
201
|
+
LlmCostTracker::Ledger::Call.this_month.cost_by_tag("feature")
|
|
202
|
+
LlmCostTracker::Ledger::Call.daily_costs(days: 7)
|
|
203
|
+
LlmCostTracker::Ledger::Call.by_tags(user_id: 42, feature: "chat").this_month.total_cost
|
|
203
204
|
```
|
|
204
205
|
|
|
205
206
|
A text report is also one rake task away:
|
|
@@ -219,7 +220,7 @@ Mount the engine wherever you want — it's plain ERB, no JavaScript bundle, no
|
|
|
219
220
|
mount LlmCostTracker::Engine => "/llm-costs"
|
|
220
221
|
```
|
|
221
222
|
|
|
222
|
-
Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads
|
|
223
|
+
Pages: overview (spend trend, budget status, anomaly banner), models, calls (filterable, paginated, CSV export), tags, data quality. Reads the ActiveRecord ledger in `llm_api_calls`.
|
|
223
224
|
|
|
224
225
|
Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs/dashboard.md).
|
|
225
226
|
|
|
@@ -233,7 +234,7 @@ Auth is your job. Examples for basic auth and Devise: [`docs/dashboard.md`](docs
|
|
|
233
234
|
| OpenRouter | Yes | OpenAI-compatible usage; provider-prefixed model IDs are normalized |
|
|
234
235
|
| DeepSeek | Yes | OpenAI-compatible usage; add `pricing_overrides` for DeepSeek-specific rates |
|
|
235
236
|
| Other OpenAI-compatible hosts | Configurable | Register the host via `config.openai_compatible_providers` |
|
|
236
|
-
| Anything else |
|
|
237
|
+
| Anything else | Manual | Use `LlmCostTracker.track` / `track_stream` |
|
|
237
238
|
|
|
238
239
|
RubyLLM chat, embedding, and transcription calls are captured through RubyLLM's provider layer when `config.instrument :ruby_llm` is enabled.
|
|
239
240
|
|
|
@@ -267,8 +268,8 @@ is still brief.
|
|
|
267
268
|
|
|
268
269
|
- `:block_requests` is best-effort under concurrency, not a transactional cap.
|
|
269
270
|
- Streaming usage capture relies on the provider emitting a final-usage event. Missing events are stored with `usage_source: "unknown"` so they appear on the data-quality page rather than vanishing.
|
|
271
|
+
- Non-token line items such as Gemini explicit-cache storage duration, provider tool calls, and modality-specific surcharges are not folded into token cost.
|
|
270
272
|
- `provider_response_id` is stored only when the provider exposes a stable ID. Gemini is best-effort and varies by endpoint.
|
|
271
|
-
- Cache write TTL variants on Anthropic (1h vs 5min writes) are not modeled separately yet.
|
|
272
273
|
|
|
273
274
|
## Development
|
|
274
275
|
|
|
@@ -302,6 +302,9 @@
|
|
|
302
302
|
.lct-budget-fill--warn { background: linear-gradient(90deg, #f59e0b, #d97706); }
|
|
303
303
|
.lct-budget-fill--over { background: linear-gradient(90deg, #ef4444, #b91c1c); }
|
|
304
304
|
.lct-stack-fill-input { background: var(--lct-accent); }
|
|
305
|
+
.lct-stack-fill-cache-read { background: #22c55e; }
|
|
306
|
+
.lct-stack-fill-cache-write { background: #f59e0b; }
|
|
307
|
+
.lct-stack-fill-cache-write-1h { background: #a855f7; }
|
|
305
308
|
.lct-stack-fill-output { background: #0ea5e9; }
|
|
306
309
|
|
|
307
310
|
.lct-budget { display: grid; gap: 10px; }
|
|
@@ -4,7 +4,7 @@ module LlmCostTracker
|
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
5
|
layout "llm_cost_tracker/application"
|
|
6
6
|
|
|
7
|
-
before_action :
|
|
7
|
+
before_action :ensure_current_schema
|
|
8
8
|
|
|
9
9
|
rescue_from ActiveRecord::ConnectionNotEstablished, with: :render_database_error
|
|
10
10
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
@@ -13,9 +13,27 @@ module LlmCostTracker
|
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
def ensure_current_schema
|
|
17
|
+
unless LlmCostTracker::Ledger::Call.table_exists?
|
|
18
|
+
@setup_message = "The llm_api_calls table is not available yet."
|
|
19
|
+
return render template: "llm_cost_tracker/shared/setup_required"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
schema_errors = LlmCostTracker::Ledger::Schema::Calls.current_schema_errors
|
|
23
|
+
if schema_errors.any?
|
|
24
|
+
@setup_message = "The llm_api_calls table does not match the current LLM Cost Tracker schema."
|
|
25
|
+
@setup_details = schema_errors
|
|
26
|
+
render template: "llm_cost_tracker/shared/setup_required"
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
period_total_errors = LlmCostTracker::Ledger::Schema::PeriodTotals.current_schema_errors
|
|
31
|
+
return if period_total_errors.empty?
|
|
32
|
+
|
|
33
|
+
@setup_message = "The llm_cost_tracker_period_totals table does not match the current LLM Cost Tracker schema."
|
|
34
|
+
@setup_details = period_total_errors + [
|
|
35
|
+
"run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
|
|
36
|
+
]
|
|
19
37
|
render template: "llm_cost_tracker/shared/setup_required"
|
|
20
38
|
end
|
|
21
39
|
|
|
@@ -14,11 +14,10 @@ module LlmCostTracker
|
|
|
14
14
|
scope = Dashboard::Filter.call(params: params)
|
|
15
15
|
scope = scope.unknown_pricing if @sort == "unknown_pricing"
|
|
16
16
|
ordered_scope = scope.order(Arel.sql(calls_order(@sort)))
|
|
17
|
-
@latency_available = LlmApiCall.latency_column?
|
|
18
17
|
|
|
19
18
|
respond_to do |format|
|
|
20
19
|
format.html do
|
|
21
|
-
@page = Pagination.call(params)
|
|
20
|
+
@page = Dashboard::Pagination.call(params)
|
|
22
21
|
@calls_count = scope.count
|
|
23
22
|
@calls = ordered_scope.limit(@page.limit).offset(@page.offset).to_a
|
|
24
23
|
end
|
|
@@ -31,8 +30,7 @@ module LlmCostTracker
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def show
|
|
34
|
-
@call =
|
|
35
|
-
@latency_available = LlmApiCall.latency_column?
|
|
33
|
+
@call = Ledger::Call.find(params[:id])
|
|
36
34
|
end
|
|
37
35
|
|
|
38
36
|
private
|
|
@@ -46,8 +44,6 @@ module LlmCostTracker
|
|
|
46
44
|
when "output"
|
|
47
45
|
"output_tokens DESC, #{DEFAULT_ORDER}"
|
|
48
46
|
when "slow"
|
|
49
|
-
return DEFAULT_ORDER unless LlmApiCall.latency_column?
|
|
50
|
-
|
|
51
47
|
"CASE WHEN latency_ms IS NULL THEN 1 ELSE 0 END ASC, latency_ms DESC, #{DEFAULT_ORDER}"
|
|
52
48
|
else
|
|
53
49
|
DEFAULT_ORDER
|
|
@@ -66,11 +62,10 @@ module LlmCostTracker
|
|
|
66
62
|
end
|
|
67
63
|
|
|
68
64
|
def csv_fields
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
fields
|
|
65
|
+
%i[tracked_at provider model] +
|
|
66
|
+
TokenUsage::STORED_KEYS +
|
|
67
|
+
Pricing::COST_KEYS +
|
|
68
|
+
%i[latency_ms provider_response_id tags]
|
|
74
69
|
end
|
|
75
70
|
|
|
76
71
|
def csv_value(field, value)
|
|
@@ -7,7 +7,7 @@ module LlmCostTracker
|
|
|
7
7
|
@from_date = range.from
|
|
8
8
|
@to_date = range.to
|
|
9
9
|
prev_from, prev_to = previous_range
|
|
10
|
-
filter_params = LlmCostTracker::
|
|
10
|
+
filter_params = LlmCostTracker::Dashboard::Params.to_hash(params)
|
|
11
11
|
scope = Dashboard::Filter.call(
|
|
12
12
|
params: filter_params.merge("from" => @from_date.iso8601, "to" => @to_date.iso8601)
|
|
13
13
|
)
|
|
@@ -16,6 +16,7 @@ module LlmCostTracker
|
|
|
16
16
|
)
|
|
17
17
|
|
|
18
18
|
@stats = Dashboard::OverviewStats.call(scope: scope, previous_scope: previous_scope)
|
|
19
|
+
@monthly_budget_status = Dashboard::OverviewStats.monthly_budget_status
|
|
19
20
|
@time_series = Dashboard::TimeSeries.call(scope: scope, from: @from_date, to: @to_date)
|
|
20
21
|
@comparison_series = Dashboard::TimeSeries.call(scope: previous_scope, from: prev_from, to: prev_to)
|
|
21
22
|
@spend_anomaly = Dashboard::SpendAnomaly.call(from: @from_date, to: @to_date, scope: scope)
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
module LlmCostTracker
|
|
4
4
|
class DataQualityController < ApplicationController
|
|
5
5
|
def index
|
|
6
|
-
|
|
6
|
+
scope = Dashboard::Filter.call(params: params)
|
|
7
|
+
@stats = Dashboard::DataQuality.call(scope: scope)
|
|
8
|
+
@usage_rows = Dashboard::DataQuality.usage_rows(@stats)
|
|
9
|
+
@hidden_output_summary = Dashboard::DataQuality.hidden_output_summary(@stats)
|
|
10
|
+
@unknown_pricing_by_model = Dashboard::DataQuality.unknown_pricing_by_model(scope)
|
|
7
11
|
end
|
|
8
12
|
end
|
|
9
13
|
end
|
|
@@ -7,14 +7,7 @@ module LlmCostTracker
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def show
|
|
10
|
-
@
|
|
11
|
-
breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: @tag_key)
|
|
12
|
-
@rows = breakdown.rows
|
|
13
|
-
@total_calls = breakdown.total_calls
|
|
14
|
-
@tagged_calls = breakdown.tagged_calls
|
|
15
|
-
@distinct_values = breakdown.distinct_values
|
|
16
|
-
@tag_value_limit = breakdown.limit
|
|
17
|
-
@tag_values_limited = breakdown.limited?
|
|
10
|
+
@breakdown = Dashboard::TagBreakdown.call(scope: Dashboard::Filter.call(params: params), key: params[:key])
|
|
18
11
|
end
|
|
19
12
|
end
|
|
20
13
|
end
|
|
@@ -12,6 +12,7 @@ module LlmCostTracker
|
|
|
12
12
|
include DashboardQueryHelper
|
|
13
13
|
include ChartHelper
|
|
14
14
|
include PaginationHelper
|
|
15
|
+
include TokenUsageHelper
|
|
15
16
|
|
|
16
17
|
def coverage_percent(numerator, denominator)
|
|
17
18
|
return 0.0 unless denominator.to_i.positive?
|
|
@@ -43,7 +44,7 @@ module LlmCostTracker
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def format_date(value)
|
|
46
|
-
value.
|
|
47
|
+
value.try(:strftime, "%Y-%m-%d %H:%M") || value.to_s
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
def pricing_status(call)
|
|
@@ -14,8 +14,7 @@ module LlmCostTracker
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def active_tag_filters
|
|
17
|
-
tag_params =
|
|
18
|
-
return [] unless tag_params.is_a?(Hash)
|
|
17
|
+
tag_params = LlmCostTracker::Dashboard::Params.to_hash(params[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
|
|
19
18
|
|
|
20
19
|
tag_params.filter_map do |key, value|
|
|
21
20
|
next if key.blank? || value.blank?
|
|
@@ -15,7 +15,7 @@ module LlmCostTracker
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def filter_options_for(column, filter_params:)
|
|
18
|
-
source = LlmCostTracker::
|
|
18
|
+
source = LlmCostTracker::Dashboard::Params.to_hash(filter_params)
|
|
19
19
|
scope_params = source.stringify_keys.merge(
|
|
20
20
|
column.to_s => nil, "format" => nil, "page" => nil, "per" => nil, "sort" => nil
|
|
21
21
|
)
|
|
@@ -11,44 +11,27 @@ 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 =
|
|
14
|
+
tags = LlmCostTracker::Dashboard::Params.to_hash(query[:tag]).transform_keys(&:to_s).transform_values(&:to_s)
|
|
15
15
|
query[:tag] = tags.merge(key.to_s => value.to_s)
|
|
16
16
|
query
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
-
def normalized_query_tags(tags)
|
|
22
|
-
LlmCostTracker::ParameterHash.to_hash(tags).transform_keys(&:to_s).transform_values(&:to_s)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
21
|
def clean_dashboard_query(value)
|
|
26
|
-
if
|
|
27
|
-
return
|
|
28
|
-
|
|
22
|
+
if value.is_a?(Hash) || value.try(:to_unsafe_h).is_a?(Hash)
|
|
23
|
+
return LlmCostTracker::Dashboard::Params.to_hash(value).each_with_object({}) do |(key, nested), cleaned|
|
|
24
|
+
nested = clean_dashboard_query(nested)
|
|
25
|
+
next if nested.nil? || nested == {} || nested == []
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
value
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def clean_dashboard_hash(hash)
|
|
37
|
-
hash.each_with_object({}) do |(key, nested), cleaned|
|
|
38
|
-
nested = clean_dashboard_query(nested)
|
|
39
|
-
next if nested.nil? || nested == {} || nested == []
|
|
40
|
-
|
|
41
|
-
cleaned[key] = nested
|
|
27
|
+
cleaned[key] = nested
|
|
28
|
+
end
|
|
42
29
|
end
|
|
43
|
-
end
|
|
44
30
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
end
|
|
31
|
+
return value.filter_map { |item| clean_dashboard_query(item) }.presence if value.is_a?(Array)
|
|
32
|
+
return value.strip.presence if value.is_a?(String)
|
|
48
33
|
|
|
49
|
-
|
|
50
|
-
stripped = string.strip
|
|
51
|
-
stripped.empty? ? nil : stripped
|
|
34
|
+
value
|
|
52
35
|
end
|
|
53
36
|
end
|
|
54
37
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module TokenUsageHelper
|
|
5
|
+
COMPONENT_LABELS = {
|
|
6
|
+
input_tokens: "Input",
|
|
7
|
+
cache_read_input_tokens: "Cache read",
|
|
8
|
+
cache_write_input_tokens: "Cache write",
|
|
9
|
+
cache_write_1h_input_tokens: "1h cache write",
|
|
10
|
+
output_tokens: "Output",
|
|
11
|
+
hidden_output_tokens: "Hidden output"
|
|
12
|
+
}.freeze
|
|
13
|
+
QUALITY_LABELS = COMPONENT_LABELS.merge(
|
|
14
|
+
input_tokens: "Regular input",
|
|
15
|
+
cache_read_input_tokens: "Cache read input",
|
|
16
|
+
cache_write_input_tokens: "Cache write input",
|
|
17
|
+
cache_write_1h_input_tokens: "1h cache write input"
|
|
18
|
+
).freeze
|
|
19
|
+
STACK_CLASSES = {
|
|
20
|
+
input_tokens: "lct-stack-fill-input",
|
|
21
|
+
cache_read_input_tokens: "lct-stack-fill-cache-read",
|
|
22
|
+
cache_write_input_tokens: "lct-stack-fill-cache-write",
|
|
23
|
+
cache_write_1h_input_tokens: "lct-stack-fill-cache-write-1h",
|
|
24
|
+
output_tokens: "lct-stack-fill-output"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def token_usage_stack_components
|
|
28
|
+
token_usage_display_components(labels: COMPONENT_LABELS).select do |component|
|
|
29
|
+
component.fetch(:cost_key)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def token_usage_quality_label(token_key)
|
|
34
|
+
QUALITY_LABELS.fetch(token_key.to_sym)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def token_usage_display_components(labels:)
|
|
40
|
+
LlmCostTracker::Pricing::COMPONENTS.map do |component|
|
|
41
|
+
token_key = component.token_key
|
|
42
|
+
{
|
|
43
|
+
token_key: token_key,
|
|
44
|
+
cost_key: component.cost_key,
|
|
45
|
+
label: labels.fetch(token_key),
|
|
46
|
+
css_class: STACK_CLASSES[token_key]
|
|
47
|
+
}
|
|
48
|
+
end + [
|
|
49
|
+
{
|
|
50
|
+
token_key: :hidden_output_tokens,
|
|
51
|
+
cost_key: nil,
|
|
52
|
+
label: labels.fetch(:hidden_output_tokens),
|
|
53
|
+
css_class: nil
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
class Call < ActiveRecord::Base
|
|
8
|
+
extend Period::Grouping
|
|
9
|
+
extend Ledger::CallMetrics
|
|
10
|
+
include Ledger::Tags::Accessors
|
|
11
|
+
|
|
12
|
+
self.table_name = "llm_api_calls"
|
|
13
|
+
|
|
14
|
+
scope :with_cost, -> { where.not(total_cost: nil) }
|
|
15
|
+
scope :without_cost, -> { where(total_cost: nil) }
|
|
16
|
+
scope :unknown_pricing, -> { without_cost }
|
|
17
|
+
scope :with_latency, -> { where.not(latency_ms: nil) }
|
|
18
|
+
scope :streaming, -> { where(stream: true) }
|
|
19
|
+
scope :non_streaming, -> { where(stream: [false, nil]) }
|
|
20
|
+
scope :by_usage_source, ->(source) { where(usage_source: source.to_s) }
|
|
21
|
+
scope :with_provider_response_id, -> { where.not(provider_response_id: [nil, ""]) }
|
|
22
|
+
scope :missing_provider_response_id, -> { where(provider_response_id: [nil, ""]) }
|
|
23
|
+
scope :streaming_missing_usage, lambda {
|
|
24
|
+
where(stream: true).where(usage_source: ["unknown", nil])
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
scope :with_json_tags, lambda {
|
|
28
|
+
where.not(tags: {})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
scope :today, -> { where(tracked_at: Time.now.utc.beginning_of_day..) }
|
|
32
|
+
scope :this_week, -> { where(tracked_at: Time.now.utc.beginning_of_week..) }
|
|
33
|
+
scope :this_month, -> { where(tracked_at: Time.now.utc.beginning_of_month..) }
|
|
34
|
+
scope :between, ->(from, to) { where(tracked_at: from..to) }
|
|
35
|
+
|
|
36
|
+
def self.by_tag(key, value)
|
|
37
|
+
by_tags(key => value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.by_tags(tags)
|
|
41
|
+
Ledger::Tags::Query.apply(self, tags)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger/tags/sql"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module CallMetrics
|
|
8
|
+
def total_cost
|
|
9
|
+
sum(:total_cost).to_f
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def total_tokens
|
|
13
|
+
sum(:total_tokens).to_i
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def cost_by_model(limit: nil)
|
|
17
|
+
cost_by_column(:model, limit: limit)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def cost_by_provider(limit: nil)
|
|
21
|
+
cost_by_column(:provider, limit: limit)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def group_by_tag(key)
|
|
25
|
+
group(Arel.sql(tag_value_expression(key)))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def cost_by_tag(key, limit: nil)
|
|
29
|
+
expression = tag_value_expression(key)
|
|
30
|
+
label_expression = "COALESCE(NULLIF(#{expression}, ''), #{connection.quote('(untagged)')})"
|
|
31
|
+
relation = select("#{label_expression} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
32
|
+
.group(Arel.sql(label_expression))
|
|
33
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
34
|
+
relation = relation.limit(limit) if limit
|
|
35
|
+
relation
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def average_latency_ms
|
|
39
|
+
average(:latency_ms)&.to_f
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def latency_by_model
|
|
43
|
+
group(:model).average(:latency_ms).transform_values(&:to_f)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def latency_by_provider
|
|
47
|
+
group(:provider).average(:latency_ms).transform_values(&:to_f)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tag_value_expression(key, table_name: quoted_table_name)
|
|
51
|
+
Ledger::Tags::Sql.value_expression(self, key, table_name: table_name)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def cost_by_column(column, limit:)
|
|
57
|
+
quoted_column = "#{quoted_table_name}.#{connection.quote_column_name(column)}"
|
|
58
|
+
relation = select("#{quoted_column} AS name, COALESCE(SUM(total_cost), 0) AS total_cost")
|
|
59
|
+
.group(column)
|
|
60
|
+
.order(Arel.sql("COALESCE(SUM(total_cost), 0) DESC"))
|
|
61
|
+
relation = relation.limit(limit) if limit
|
|
62
|
+
relation
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "llm_cost_tracker/ledger/schema/adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Ledger
|
|
7
|
+
module Period
|
|
8
|
+
module Grouping
|
|
9
|
+
PERIOD_FORMATS = {
|
|
10
|
+
day: {
|
|
11
|
+
postgres: "YYYY-MM-DD",
|
|
12
|
+
mysql: "%Y-%m-%d"
|
|
13
|
+
},
|
|
14
|
+
month: {
|
|
15
|
+
postgres: "YYYY-MM",
|
|
16
|
+
mysql: "%Y-%m"
|
|
17
|
+
}
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
private_constant :PERIOD_FORMATS
|
|
21
|
+
|
|
22
|
+
def group_by_period(period, column: :tracked_at)
|
|
23
|
+
group(Arel.sql(period_group_expression(period, column: column)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def daily_costs(days: 30)
|
|
27
|
+
where(tracked_at: days.days.ago..)
|
|
28
|
+
.group_by_period(:day)
|
|
29
|
+
.sum(:total_cost)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def period_group_expression(period, column:)
|
|
35
|
+
period = validated_period(period)
|
|
36
|
+
column = period_column_expression(column)
|
|
37
|
+
formats = PERIOD_FORMATS.fetch(period)
|
|
38
|
+
|
|
39
|
+
if Ledger::Schema::Adapter.postgresql?(connection)
|
|
40
|
+
postgres_period_expression(period, column, formats)
|
|
41
|
+
elsif Ledger::Schema::Adapter.mysql?(connection)
|
|
42
|
+
"DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
|
|
43
|
+
else
|
|
44
|
+
Ledger::Schema::Adapter.ensure_supported!(connection)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def postgres_period_expression(period, column, formats)
|
|
49
|
+
"TO_CHAR(" \
|
|
50
|
+
"DATE_TRUNC(#{connection.quote(period.to_s)}, #{column}), " \
|
|
51
|
+
"#{connection.quote(formats.fetch(:postgres))}" \
|
|
52
|
+
")"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def validated_period(period)
|
|
56
|
+
normalized_period = period.try(:to_sym)
|
|
57
|
+
return normalized_period if PERIOD_FORMATS.key?(normalized_period)
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "invalid period: #{period.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def period_column_expression(column)
|
|
63
|
+
column = column.to_s
|
|
64
|
+
return "#{quoted_table_name}.#{connection.quote_column_name(column)}" if column_names.include?(column)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "invalid period column: #{column.inspect}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|