llm_cost_tracker 0.5.3 → 0.6.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 +30 -0
- data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
- data/docs/architecture.md +1 -1
- data/docs/configuration.md +1 -1
- data/docs/technical/data-flow.md +8 -5
- data/docs/technical/operational-notes.md +21 -1
- data/docs/upgrading.md +1 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +2 -0
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
- data/lib/llm_cost_tracker/inbox_event.rb +9 -0
- data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
- data/lib/llm_cost_tracker/period_grouping.rb +4 -3
- data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
- data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
- data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
- data/lib/llm_cost_tracker/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +3 -3
- data/lib/llm_cost_tracker/tags_column.rb +7 -1
- data/lib/llm_cost_tracker/tracker.rb +3 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +36 -1
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa3f705baf280c2c2239b2dab3522fe7db9b60e26060b00fc08dcc039117da83
|
|
4
|
+
data.tar.gz: ea34bdad7cb0d7c9fb3233b40b609cea1361ded833ad130c4a7a7ce559b34758
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03c55866b522b36b0728f73fd0ae0075e0a42faa6f80c429865f120d2c597e0b0996665b6a82528c566a0f8554d7636dfd03d9ab600441dafd5a4f0233d3f56b
|
|
7
|
+
data.tar.gz: af01c4554912d80276bf54ed97aa379c2eaed791fa357ca135aa008fdcbdd41e365c236ac74c9ceb1982c0fbbf2538bc569df1690acb4b5758895dd48c4497b5
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-04-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Durable ActiveRecord ingestion through `llm_cost_tracker_inbox_events` and `llm_cost_tracker_ingestor_leases`.
|
|
12
|
+
- `llm_cost_tracker:add_ingestion` generator for upgrading existing ActiveRecord installs.
|
|
13
|
+
- `LlmCostTracker.flush!` and `LlmCostTracker.shutdown!` for draining or stopping durable ingestion.
|
|
14
|
+
- Doctor diagnostics for missing durable ingestion schema, stale pending inbox rows, and quarantined inbox rows.
|
|
15
|
+
- PostgreSQL and MySQL smoke checks for ActiveRecord durable ingestion.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Fresh ActiveRecord installs now include durable ingestion tables, event IDs, and production indexes.
|
|
20
|
+
- ActiveRecord budget totals now read stored period rollups plus pending inbox totals while durable ingestion is enabled.
|
|
21
|
+
- ActiveRecord writes now use a durable inbox before batching ledger inserts and period rollup updates when ingestion tables are present.
|
|
22
|
+
- Pricing lookup now caches normalized runtime price tables and model matches by configuration generation.
|
|
23
|
+
- Stream capture now estimates buffered event size without serializing every captured event.
|
|
24
|
+
- CSV export now selects only exported columns instead of loading full ActiveRecord objects.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- ActiveRecord rollups no longer double-count retried events when duplicate event IDs race across workers.
|
|
29
|
+
- Invalid inbox rows are retried and quarantined without blocking healthy rows behind them.
|
|
30
|
+
- Idle ingestors no longer acquire the leader lease while the inbox is empty.
|
|
31
|
+
- ActiveRecord inbox writes now fail honestly when a separate connection is unavailable inside a caller transaction.
|
|
32
|
+
- Ingestor shutdown/reset no longer lets an old sleeping thread resume as a second local ingestor.
|
|
33
|
+
- `flush!` now returns `false` instead of raising when its timeout expires during ingestion.
|
|
34
|
+
- ActiveRecord adapter family detection now works through known adapter class ancestry with an adapter-name fallback.
|
|
35
|
+
- CSV export now emits `{}` for invalid stored tag payloads.
|
|
36
|
+
|
|
7
37
|
## [0.5.3] - 2026-04-28
|
|
8
38
|
|
|
9
39
|
### Added
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "csv"
|
|
4
|
+
require "json"
|
|
4
5
|
|
|
5
6
|
module LlmCostTracker
|
|
6
7
|
class CallsController < ApplicationController
|
|
@@ -54,32 +55,45 @@ module LlmCostTracker
|
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def render_csv(relation)
|
|
57
|
-
|
|
58
|
+
fields = csv_fields
|
|
58
59
|
CSV.generate do |csv|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
csv << headers
|
|
64
|
-
|
|
65
|
-
relation.each do |call|
|
|
66
|
-
row = [
|
|
67
|
-
call.tracked_at&.utc&.iso8601,
|
|
68
|
-
csv_safe(call.provider),
|
|
69
|
-
csv_safe(call.model),
|
|
70
|
-
call.input_tokens,
|
|
71
|
-
call.output_tokens,
|
|
72
|
-
call.total_tokens,
|
|
73
|
-
call.total_cost
|
|
74
|
-
]
|
|
75
|
-
row << call.latency_ms if latency
|
|
76
|
-
row << csv_safe(call.provider_response_id) if LlmApiCall.provider_response_id_column?
|
|
77
|
-
row << csv_safe(call.parsed_tags.to_json)
|
|
78
|
-
csv << row
|
|
60
|
+
csv << fields.map(&:to_s)
|
|
61
|
+
|
|
62
|
+
relation.pluck(*fields).each do |values|
|
|
63
|
+
csv << fields.zip(values).map { |field, value| csv_value(field, value) }
|
|
79
64
|
end
|
|
80
65
|
end
|
|
81
66
|
end
|
|
82
67
|
|
|
68
|
+
def csv_fields
|
|
69
|
+
fields = %i[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
|
|
70
|
+
fields << :latency_ms if LlmApiCall.latency_column?
|
|
71
|
+
fields << :provider_response_id if LlmApiCall.provider_response_id_column?
|
|
72
|
+
fields << :tags
|
|
73
|
+
fields
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def csv_value(field, value)
|
|
77
|
+
case field
|
|
78
|
+
when :tracked_at
|
|
79
|
+
value&.utc&.iso8601
|
|
80
|
+
when :provider, :model, :provider_response_id
|
|
81
|
+
csv_safe(value)
|
|
82
|
+
when :tags
|
|
83
|
+
csv_safe(csv_tags(value))
|
|
84
|
+
else
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def csv_tags(value)
|
|
90
|
+
return value.transform_keys(&:to_s).to_json if value.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
JSON.parse(value || "{}").to_json
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
"{}"
|
|
95
|
+
end
|
|
96
|
+
|
|
83
97
|
def csv_safe(value)
|
|
84
98
|
return value if value.nil?
|
|
85
99
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "llm_cost_tracker/storage/active_record_store"
|
|
4
|
+
|
|
3
5
|
module LlmCostTracker
|
|
4
6
|
module Dashboard
|
|
5
7
|
OverviewStatsData = Data.define(
|
|
@@ -77,7 +79,7 @@ module LlmCostTracker
|
|
|
77
79
|
now = Time.now.utc
|
|
78
80
|
month_start = now.beginning_of_month
|
|
79
81
|
month_end = now.end_of_month
|
|
80
|
-
spent = LlmCostTracker::
|
|
82
|
+
spent = LlmCostTracker::Storage::ActiveRecordStore.monthly_total(time: now)
|
|
81
83
|
elapsed_seconds = now - month_start
|
|
82
84
|
total_seconds = month_end - month_start
|
|
83
85
|
projected_spent = if spent.zero? || !elapsed_seconds.positive?
|
|
@@ -42,11 +42,10 @@ module LlmCostTracker
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def build_sql
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
end
|
|
45
|
+
return postgresql_sql if ActiveRecordAdapter.postgresql?(connection)
|
|
46
|
+
return mysql_sql if ActiveRecordAdapter.mysql?(connection)
|
|
47
|
+
|
|
48
|
+
sqlite_sql
|
|
50
49
|
end
|
|
51
50
|
|
|
52
51
|
def mysql_sql
|
data/docs/architecture.md
CHANGED
|
@@ -18,7 +18,7 @@ Pricing logic should prefer generic mechanisms over provider branches. Use provi
|
|
|
18
18
|
|
|
19
19
|
Tags remain the extension point for app-specific attribution such as tenant, user, feature, trace, job, workflow, or agent session. Do not promote those dimensions into first-class columns unless the ledger itself needs them for provider-agnostic billing behavior.
|
|
20
20
|
|
|
21
|
-
Hot-path guardrails must not aggregate over the growing call ledger. ActiveRecord period budgets should read maintained rows in `llm_cost_tracker_period_totals`; dashboard analytics may run grouped queries because they are user-initiated reporting paths.
|
|
21
|
+
Hot-path guardrails must not aggregate over the growing call ledger. ActiveRecord period budgets should read maintained rows in `llm_cost_tracker_period_totals`; dashboard analytics may run grouped queries because they are user-initiated reporting paths. Do not add dashboard-only aggregate tables until bounded indexed reads from `llm_api_calls` are no longer enough for the supported date range.
|
|
22
22
|
|
|
23
23
|
## Technical Docs
|
|
24
24
|
|
data/docs/configuration.md
CHANGED
|
@@ -21,7 +21,7 @@ Until this page is expanded, use:
|
|
|
21
21
|
|
|
22
22
|
This page is scoped to:
|
|
23
23
|
|
|
24
|
-
- `storage_backend`: `:log`, `:active_record`, and `:custom
|
|
24
|
+
- `storage_backend`: `:log`, `:active_record`, and `:custom`; ActiveRecord capture uses a durable inbox when the ingestion migration is present
|
|
25
25
|
- `default_tags`: static tags and per-request callable tags
|
|
26
26
|
- `instrument`: RubyLLM and official SDK integrations
|
|
27
27
|
- `prices_file` and `pricing_overrides`
|
data/docs/technical/data-flow.md
CHANGED
|
@@ -41,11 +41,14 @@ This is the normal path from an application LLM call to stored ledger data.
|
|
|
41
41
|
|
|
42
42
|
## ActiveRecord Storage
|
|
43
43
|
|
|
44
|
-
1. `Storage::
|
|
45
|
-
2.
|
|
46
|
-
3.
|
|
47
|
-
4.
|
|
48
|
-
5.
|
|
44
|
+
1. `Storage::ActiveRecordInbox.save` writes a compact durable event row when the ingestion tables are present.
|
|
45
|
+
2. `Storage::ActiveRecordIngestor` claims retryable inbox rows through a database lease and writes batches into `llm_api_calls`.
|
|
46
|
+
3. `Storage::ActiveRecordStore.insert_many` converts tags for JSON or text storage and writes optional fields only when their columns exist.
|
|
47
|
+
4. The call rows, period rollup updates, and inbox deletes happen in one transaction.
|
|
48
|
+
5. `ActiveRecordRollups.increment_many!` updates daily and monthly totals only for rows inserted by the batch.
|
|
49
|
+
6. Budget reads use period totals plus pending inbox totals when available.
|
|
50
|
+
|
|
51
|
+
The inbox write is the durability boundary. Ledger freshness is eventually consistent unless the caller explicitly waits with `LlmCostTracker.flush!`.
|
|
49
52
|
|
|
50
53
|
## Dashboard Reads
|
|
51
54
|
|
|
@@ -34,10 +34,28 @@ Price update tasks are operational tooling. They can fetch the maintained LLM Co
|
|
|
34
34
|
|
|
35
35
|
## Budget Reads
|
|
36
36
|
|
|
37
|
-
Monthly and daily budgets should read `llm_cost_tracker_period_totals` when the table exists. Falling back to summing `llm_api_calls` is an upgrade compatibility path, not the preferred production path.
|
|
37
|
+
Monthly and daily budgets should read `llm_cost_tracker_period_totals` when the table exists and add pending `llm_cost_tracker_inbox_events` totals while durable ingestion is enabled. Falling back to summing `llm_api_calls` is an upgrade compatibility path, not the preferred production path.
|
|
38
|
+
|
|
39
|
+
The stored period total and pending inbox total should be read in one database statement so request-time budget checks do not undercount during the inbox-to-ledger handoff.
|
|
38
40
|
|
|
39
41
|
Per-call budgets are checked from the current event only.
|
|
40
42
|
|
|
43
|
+
## Durable Ingestion
|
|
44
|
+
|
|
45
|
+
Inbox writes inside an open caller transaction need a separate database connection to survive caller rollbacks. If the pool cannot provide one, storage should fail honestly through `storage_error_behavior` instead of writing into the caller transaction and pretending the event is durable.
|
|
46
|
+
|
|
47
|
+
The ingestor is database-leased and database-polled, with an opportunistic local wake after a successful inbox insert. The wake only reduces freshness latency in the process that wrote the row; correctness still comes from the shared database lease, retryable row locks, and adaptive polling across Puma, Sidekiq, Unicorn, deploy restarts, and multi-process hosts.
|
|
48
|
+
|
|
49
|
+
Freshness and durability are separate concerns. If the writing process exits before its local ingestor drains the row, another process can pick it up on a later poll; budget reads include pending inbox totals and operators can call `LlmCostTracker.flush!` when they need the ledger drained before continuing.
|
|
50
|
+
|
|
51
|
+
The ingestor should check for claimable rows before acquiring the leader lease. Empty queues should not create steady lease-table writes across an idle fleet.
|
|
52
|
+
|
|
53
|
+
Batch size is a conservative internal constant. Do not expose it as a configuration knob until production measurements show that a supported workload needs tuning; more knobs make installations harder to reason about.
|
|
54
|
+
|
|
55
|
+
Ingestors should claim only retryable rows. Rows that keep failing after the retry cap stay in `llm_cost_tracker_inbox_events` with `last_error` for operator inspection and must not block healthy rows behind them.
|
|
56
|
+
|
|
57
|
+
Process shutdown should stop the local ingestor thread without forcing every exiting process to drain the shared inbox. Operators can call `LlmCostTracker.flush!` when they intentionally want to wait for the durable inbox to drain.
|
|
58
|
+
|
|
41
59
|
## Retention
|
|
42
60
|
|
|
43
61
|
Retention may delete old `llm_api_calls`. Period rollups are the durable budget aggregate. Any migration or refactor that changes rollups must preserve the meaning of retained totals or clearly document a breaking change.
|
|
@@ -60,6 +78,8 @@ Dashboard queries can aggregate because they are user-initiated. They should sti
|
|
|
60
78
|
|
|
61
79
|
Avoid loading ledger rows into Ruby just to count, sum, group, or sort.
|
|
62
80
|
|
|
81
|
+
The dashboard is not the center of the storage design. Prefer bounded ranges, existing ledger indexes, pagination, and database-side aggregates over new dashboard-specific tables. Add a summary table only when a measured supported dashboard query cannot be made acceptable with the existing ledger and period totals.
|
|
82
|
+
|
|
63
83
|
## Streaming
|
|
64
84
|
|
|
65
85
|
Streaming capture must keep the host app's stream behavior intact.
|
data/docs/upgrading.md
CHANGED
|
@@ -20,6 +20,7 @@ Existing installs can add newer optional columns through focused generators:
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
bin/rails generate llm_cost_tracker:add_period_totals
|
|
23
|
+
bin/rails generate llm_cost_tracker:add_ingestion
|
|
23
24
|
bin/rails generate llm_cost_tracker:add_streaming
|
|
24
25
|
bin/rails generate llm_cost_tracker:add_provider_response_id
|
|
25
26
|
bin/rails generate llm_cost_tracker:add_usage_breakdown
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module ActiveRecordAdapter
|
|
5
|
+
MYSQL_ADAPTERS = %w[
|
|
6
|
+
ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
|
|
7
|
+
ActiveRecord::ConnectionAdapters::Mysql2Adapter
|
|
8
|
+
ActiveRecord::ConnectionAdapters::TrilogyAdapter
|
|
9
|
+
].freeze
|
|
10
|
+
POSTGRESQL_ADAPTERS = %w[
|
|
11
|
+
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
|
12
|
+
].freeze
|
|
13
|
+
SQLITE_ADAPTERS = %w[
|
|
14
|
+
ActiveRecord::ConnectionAdapters::SQLite3Adapter
|
|
15
|
+
].freeze
|
|
16
|
+
MYSQL_PATTERN = /mysql|trilogy|mariadb/i
|
|
17
|
+
POSTGRESQL_PATTERN = /postgres/i
|
|
18
|
+
SQLITE_PATTERN = /sqlite/i
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def mysql?(value) = adapter_instance?(value, MYSQL_ADAPTERS) || adapter_name(value).match?(MYSQL_PATTERN)
|
|
22
|
+
|
|
23
|
+
def postgresql?(value)
|
|
24
|
+
adapter_instance?(value, POSTGRESQL_ADAPTERS) || adapter_name(value).match?(POSTGRESQL_PATTERN)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sqlite?(value) = adapter_instance?(value, SQLITE_ADAPTERS) || adapter_name(value).match?(SQLITE_PATTERN)
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def adapter_instance?(value, class_names)
|
|
32
|
+
class_names.any? do |class_name|
|
|
33
|
+
adapter_class = constantize(class_name)
|
|
34
|
+
adapter_class && value.is_a?(adapter_class)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def constantize(name)
|
|
39
|
+
name.split("::").reduce(Object) { |namespace, part| namespace.const_get(part, false) }
|
|
40
|
+
rescue NameError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def adapter_name(value)
|
|
45
|
+
value.respond_to?(:adapter_name) ? value.adapter_name.to_s : value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
class Doctor
|
|
7
|
+
class IngestionCheck
|
|
8
|
+
PENDING_AGE_WARNING_SECONDS = 60
|
|
9
|
+
|
|
10
|
+
def self.call(check_class)
|
|
11
|
+
new(check_class).call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(check_class)
|
|
15
|
+
@check_class = check_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless active_record_storage? && llm_api_calls_table?
|
|
20
|
+
|
|
21
|
+
missing = missing_parts
|
|
22
|
+
if missing.empty?
|
|
23
|
+
quarantined = quarantined_count
|
|
24
|
+
if quarantined.positive?
|
|
25
|
+
return check_class.new(:warn, "durable ingestion", "#{quarantined} inbox events quarantined after retries")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
pending = pending_snapshot
|
|
29
|
+
if stale_pending?(pending)
|
|
30
|
+
return check_class.new(
|
|
31
|
+
:warn,
|
|
32
|
+
"durable ingestion",
|
|
33
|
+
"#{pending.fetch(:count)} inbox events pending; oldest pending age #{pending_age(pending).round}s"
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
return check_class.new(:ok, "durable ingestion", "inbox and ingestor lease tables available")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
check_class.new(
|
|
41
|
+
:warn,
|
|
42
|
+
"durable ingestion",
|
|
43
|
+
"missing #{missing.join(', ')}; run bin/rails generate llm_cost_tracker:add_ingestion && bin/rails db:migrate"
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :check_class
|
|
50
|
+
|
|
51
|
+
def missing_parts
|
|
52
|
+
[
|
|
53
|
+
column_names("llm_api_calls").include?("event_id") ? nil : "llm_api_calls.event_id",
|
|
54
|
+
table_exists?("llm_cost_tracker_inbox_events") ? nil : "llm_cost_tracker_inbox_events",
|
|
55
|
+
table_exists?("llm_cost_tracker_ingestor_leases") ? nil : "llm_cost_tracker_ingestor_leases"
|
|
56
|
+
].compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def active_record_storage? = LlmCostTracker.configuration.storage_backend == :active_record
|
|
60
|
+
|
|
61
|
+
def llm_api_calls_table? = table_exists?("llm_api_calls")
|
|
62
|
+
|
|
63
|
+
def table_exists?(name)
|
|
64
|
+
LlmCostTracker::LlmApiCall.connection.data_source_exists?(name)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def column_names(table) = LlmCostTracker::LlmApiCall.connection.columns(table).map(&:name)
|
|
70
|
+
|
|
71
|
+
def quarantined_count
|
|
72
|
+
return 0 unless table_exists?("llm_cost_tracker_inbox_events")
|
|
73
|
+
|
|
74
|
+
LlmCostTracker::LlmApiCall.connection.select_value(quarantined_sql).to_i
|
|
75
|
+
rescue StandardError
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def quarantined_sql
|
|
80
|
+
table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
|
|
81
|
+
"SELECT COUNT(*) FROM #{table} WHERE attempts >= #{max_attempts}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pending_snapshot
|
|
85
|
+
row = LlmCostTracker::LlmApiCall.connection.select_one(pending_sql) || {}
|
|
86
|
+
{
|
|
87
|
+
count: row.fetch("pending_count").to_i,
|
|
88
|
+
oldest_at: row["oldest_created_at"] && Time.parse(row.fetch("oldest_created_at").to_s).utc
|
|
89
|
+
}
|
|
90
|
+
rescue StandardError
|
|
91
|
+
{ count: 0, oldest_at: nil }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def pending_sql
|
|
95
|
+
table = LlmCostTracker::LlmApiCall.connection.quote_table_name("llm_cost_tracker_inbox_events")
|
|
96
|
+
"SELECT COUNT(*) AS pending_count, MIN(created_at) AS oldest_created_at " \
|
|
97
|
+
"FROM #{table} WHERE attempts < #{max_attempts}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stale_pending?(pending)
|
|
101
|
+
pending.fetch(:count).positive? &&
|
|
102
|
+
pending.fetch(:oldest_at) &&
|
|
103
|
+
pending_age(pending) >= PENDING_AGE_WARNING_SECONDS
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def pending_age(pending) = Time.now.utc - pending.fetch(:oldest_at)
|
|
107
|
+
|
|
108
|
+
def max_attempts
|
|
109
|
+
if defined?(LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS)
|
|
110
|
+
LlmCostTracker::Storage::ActiveRecordInbox::MAX_ATTEMPTS
|
|
111
|
+
else
|
|
112
|
+
5
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "price_freshness"
|
|
4
4
|
require_relative "doctor/capture_check"
|
|
5
|
+
require_relative "doctor/ingestion_check"
|
|
5
6
|
|
|
6
7
|
module LlmCostTracker
|
|
7
8
|
class Doctor
|
|
@@ -45,6 +46,7 @@ module LlmCostTracker
|
|
|
45
46
|
table_check,
|
|
46
47
|
column_check,
|
|
47
48
|
period_totals_check,
|
|
49
|
+
IngestionCheck.call(Check),
|
|
48
50
|
prices_check,
|
|
49
51
|
calls_check
|
|
50
52
|
].compact
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module LlmCostTracker
|
|
7
|
+
module Generators
|
|
8
|
+
class AddIngestionGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Creates a migration to add durable ActiveRecord ingestion"
|
|
14
|
+
|
|
15
|
+
def create_migration_file
|
|
16
|
+
migration_template(
|
|
17
|
+
"add_ingestion_to_llm_cost_tracker.rb.erb",
|
|
18
|
+
"db/migrate/add_ingestion_to_llm_cost_tracker.rb"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def migration_version
|
|
25
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class AddIngestionToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
add_column :llm_api_calls, :event_id, :string unless column_exists?(:llm_api_calls, :event_id)
|
|
4
|
+
add_index :llm_api_calls, :event_id, unique: true if column_exists?(:llm_api_calls, :event_id) &&
|
|
5
|
+
!index_exists?(:llm_api_calls, :event_id)
|
|
6
|
+
|
|
7
|
+
create_table :llm_cost_tracker_inbox_events do |t|
|
|
8
|
+
t.string :event_id, null: false
|
|
9
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
10
|
+
t.datetime :tracked_at, null: false
|
|
11
|
+
t.text :payload, null: false
|
|
12
|
+
t.datetime :locked_at
|
|
13
|
+
t.string :locked_by
|
|
14
|
+
t.integer :attempts, null: false, default: 0
|
|
15
|
+
t.text :last_error
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end unless table_exists?(:llm_cost_tracker_inbox_events)
|
|
19
|
+
|
|
20
|
+
create_table :llm_cost_tracker_ingestor_leases do |t|
|
|
21
|
+
t.string :name, null: false
|
|
22
|
+
t.string :locked_by
|
|
23
|
+
t.datetime :locked_until
|
|
24
|
+
|
|
25
|
+
t.timestamps
|
|
26
|
+
end unless table_exists?(:llm_cost_tracker_ingestor_leases)
|
|
27
|
+
|
|
28
|
+
add_index :llm_cost_tracker_inbox_events, :event_id, unique: true unless index_exists?(:llm_cost_tracker_inbox_events, :event_id)
|
|
29
|
+
add_index :llm_cost_tracker_inbox_events, :tracked_at unless index_exists?(:llm_cost_tracker_inbox_events, :tracked_at)
|
|
30
|
+
add_index :llm_cost_tracker_inbox_events, [:locked_at, :id] unless index_exists?(:llm_cost_tracker_inbox_events, [:locked_at, :id])
|
|
31
|
+
add_index :llm_cost_tracker_ingestor_leases, :name, unique: true unless index_exists?(:llm_cost_tracker_ingestor_leases, :name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def up
|
|
3
5
|
create_table :llm_cost_tracker_period_totals do |t|
|
|
@@ -73,10 +75,9 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
73
75
|
end
|
|
74
76
|
|
|
75
77
|
def day_bucket_sql
|
|
76
|
-
|
|
77
|
-
when /postgres/i
|
|
78
|
+
if postgresql?
|
|
78
79
|
"DATE_TRUNC('day', tracked_at)::date"
|
|
79
|
-
|
|
80
|
+
elsif mysql?
|
|
80
81
|
"DATE(tracked_at)"
|
|
81
82
|
else
|
|
82
83
|
"date(tracked_at)"
|
|
@@ -84,13 +85,20 @@ class AddPeriodTotalsToLlmCostTracker < ActiveRecord::Migration<%= migration_ver
|
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
def month_bucket_sql
|
|
87
|
-
|
|
88
|
-
when /postgres/i
|
|
88
|
+
if postgresql?
|
|
89
89
|
"DATE_TRUNC('month', tracked_at)::date"
|
|
90
|
-
|
|
90
|
+
elsif mysql?
|
|
91
91
|
"DATE_FORMAT(tracked_at, '%Y-%m-01')"
|
|
92
92
|
else
|
|
93
93
|
"strftime('%Y-%m-01', tracked_at)"
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
|
+
|
|
97
|
+
def postgresql?
|
|
98
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def mysql?
|
|
102
|
+
LlmCostTracker::ActiveRecordAdapter.mysql?(connection)
|
|
103
|
+
end
|
|
96
104
|
end
|
|
@@ -2,23 +2,19 @@ class AddStreamingToLlmApiCalls < ActiveRecord::Migration<%= migration_version %
|
|
|
2
2
|
def up
|
|
3
3
|
unless column_exists?(:llm_api_calls, :stream)
|
|
4
4
|
add_column :llm_api_calls, :stream, :boolean, null: false, default: false
|
|
5
|
-
add_index :llm_api_calls, :stream
|
|
6
5
|
end
|
|
7
6
|
|
|
8
7
|
unless column_exists?(:llm_api_calls, :usage_source)
|
|
9
8
|
add_column :llm_api_calls, :usage_source, :string
|
|
10
|
-
add_index :llm_api_calls, :usage_source
|
|
11
9
|
end
|
|
12
10
|
end
|
|
13
11
|
|
|
14
12
|
def down
|
|
15
13
|
if column_exists?(:llm_api_calls, :usage_source)
|
|
16
|
-
remove_index :llm_api_calls, :usage_source if index_exists?(:llm_api_calls, :usage_source)
|
|
17
14
|
remove_column :llm_api_calls, :usage_source
|
|
18
15
|
end
|
|
19
16
|
|
|
20
17
|
if column_exists?(:llm_api_calls, :stream)
|
|
21
|
-
remove_index :llm_api_calls, :stream if index_exists?(:llm_api_calls, :stream)
|
|
22
18
|
remove_column :llm_api_calls, :stream
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def change
|
|
3
5
|
create_table :llm_api_calls do |t|
|
|
6
|
+
t.string :event_id, null: false
|
|
4
7
|
t.string :provider, null: false
|
|
5
8
|
t.string :model, null: false
|
|
6
9
|
t.integer :input_tokens, null: false, default: 0
|
|
@@ -37,19 +40,43 @@ class CreateLlmApiCalls < ActiveRecord::Migration<%= migration_version %>
|
|
|
37
40
|
t.timestamps
|
|
38
41
|
end
|
|
39
42
|
|
|
43
|
+
create_table :llm_cost_tracker_inbox_events do |t|
|
|
44
|
+
t.string :event_id, null: false
|
|
45
|
+
t.decimal :total_cost, precision: 20, scale: 8
|
|
46
|
+
t.datetime :tracked_at, null: false
|
|
47
|
+
t.text :payload, null: false
|
|
48
|
+
t.datetime :locked_at
|
|
49
|
+
t.string :locked_by
|
|
50
|
+
t.integer :attempts, null: false, default: 0
|
|
51
|
+
t.text :last_error
|
|
52
|
+
|
|
53
|
+
t.timestamps
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
create_table :llm_cost_tracker_ingestor_leases do |t|
|
|
57
|
+
t.string :name, null: false
|
|
58
|
+
t.string :locked_by
|
|
59
|
+
t.datetime :locked_until
|
|
60
|
+
|
|
61
|
+
t.timestamps
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
add_index :llm_api_calls, :event_id, unique: true
|
|
40
65
|
add_index :llm_api_calls, :tracked_at
|
|
41
66
|
add_index :llm_api_calls, [:provider, :tracked_at]
|
|
42
67
|
add_index :llm_api_calls, [:model, :tracked_at]
|
|
43
|
-
add_index :llm_api_calls, :stream
|
|
44
|
-
add_index :llm_api_calls, :usage_source
|
|
45
68
|
add_index :llm_api_calls, :provider_response_id
|
|
46
69
|
add_index :llm_api_calls, :tags, using: :gin if postgresql?
|
|
47
70
|
add_index :llm_cost_tracker_period_totals, [:period, :period_start], unique: true
|
|
71
|
+
add_index :llm_cost_tracker_inbox_events, :event_id, unique: true
|
|
72
|
+
add_index :llm_cost_tracker_inbox_events, :tracked_at
|
|
73
|
+
add_index :llm_cost_tracker_inbox_events, [:locked_at, :id]
|
|
74
|
+
add_index :llm_cost_tracker_ingestor_leases, :name, unique: true
|
|
48
75
|
end
|
|
49
76
|
|
|
50
77
|
private
|
|
51
78
|
|
|
52
79
|
def postgresql?
|
|
53
|
-
|
|
80
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
54
81
|
end
|
|
55
82
|
end
|
|
@@ -4,7 +4,7 @@ LlmCostTracker.configure do |config|
|
|
|
4
4
|
# Set to false to temporarily disable tracking without removing middleware.
|
|
5
5
|
config.enabled = true
|
|
6
6
|
|
|
7
|
-
# :active_record
|
|
7
|
+
# :active_record captures events into a durable inbox and ingests them into llm_api_calls.
|
|
8
8
|
# Other options: :log for local logging, :custom for your own storage callable.
|
|
9
9
|
config.storage_backend = :active_record
|
|
10
10
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "llm_cost_tracker/active_record_adapter"
|
|
2
|
+
|
|
1
3
|
class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
|
|
2
4
|
def up
|
|
3
5
|
unless postgresql?
|
|
@@ -32,7 +34,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
|
|
|
32
34
|
private
|
|
33
35
|
|
|
34
36
|
def postgresql?
|
|
35
|
-
|
|
37
|
+
LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
def tags_jsonb?
|