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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  4. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  5. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  6. data/docs/architecture.md +1 -1
  7. data/docs/configuration.md +1 -1
  8. data/docs/technical/data-flow.md +8 -5
  9. data/docs/technical/operational-notes.md +21 -1
  10. data/docs/upgrading.md +1 -0
  11. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  12. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  13. data/lib/llm_cost_tracker/doctor.rb +2 -0
  14. data/lib/llm_cost_tracker/event.rb +1 -0
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  17. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  18. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  19. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  20. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  21. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  22. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  23. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  24. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  25. data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
  26. data/lib/llm_cost_tracker/railtie.rb +1 -0
  27. data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
  28. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  29. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  30. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  31. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  32. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  33. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  34. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  35. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  36. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  37. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
  38. data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
  39. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  40. data/lib/llm_cost_tracker/tag_sql.rb +3 -3
  41. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  42. data/lib/llm_cost_tracker/tracker.rb +3 -0
  43. data/lib/llm_cost_tracker/version.rb +1 -1
  44. data/lib/llm_cost_tracker.rb +36 -1
  45. metadata +17 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3fe81fdf7b12f977d7dfc4aa86f629e8b5ad6e099100bfbfe1b18507db17fff
4
- data.tar.gz: dffe0c0ebeb30fa111273b654141ee06e1275426846796b1749435429da3414f
3
+ metadata.gz: fa3f705baf280c2c2239b2dab3522fe7db9b60e26060b00fc08dcc039117da83
4
+ data.tar.gz: ea34bdad7cb0d7c9fb3233b40b609cea1361ded833ad130c4a7a7ce559b34758
5
5
  SHA512:
6
- metadata.gz: 4849f0d0b09d640ed3a902bbc19a975e576b0539d2c71eac3165199eed196ecd88f249ff1d571c43b2686d59466dde26a0467f984c2cbb7804a5f771e184df31
7
- data.tar.gz: 8aa17efc9dd68b1489cdc69351a67fe2398c35720750cf1440f215bceeef0392c9f3af1d41ebd444ddcd9876c2c1e612ea50d6403fb7f650735fc9d9161a2a85
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
- latency = LlmApiCall.latency_column?
58
+ fields = csv_fields
58
59
  CSV.generate do |csv|
59
- headers = %w[tracked_at provider model input_tokens output_tokens total_tokens total_cost]
60
- headers << "latency_ms" if latency
61
- headers << "provider_response_id" if LlmApiCall.provider_response_id_column?
62
- headers << "tags"
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::LlmApiCall.this_month.total_cost
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
- case connection.adapter_name
46
- when /postgres/i then postgresql_sql
47
- when /mysql/i then mysql_sql
48
- else sqlite_sql
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
 
@@ -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`
@@ -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::ActiveRecordStore.save` converts tags for JSON or text storage.
45
- 2. Optional fields are written only when their columns exist.
46
- 3. The call row and period rollup updates happen in one transaction.
47
- 4. `ActiveRecordRollups.increment!` updates daily and monthly totals atomically.
48
- 5. Budget reads use period totals when available.
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  module LlmCostTracker
4
4
  Event = Data.define(
5
+ :event_id,
5
6
  :provider,
6
7
  :model,
7
8
  :input_tokens,
@@ -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
- case connection.adapter_name
77
- when /postgres/i
78
+ if postgresql?
78
79
  "DATE_TRUNC('day', tracked_at)::date"
79
- when /mysql/i
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
- case connection.adapter_name
88
- when /postgres/i
88
+ if postgresql?
89
89
  "DATE_TRUNC('month', tracked_at)::date"
90
- when /mysql/i
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
- connection.adapter_name.downcase.include?("postgres")
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 stores events in llm_api_calls for dashboards, reports, and shared budgets.
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
- connection.adapter_name.downcase.include?("postgres")
37
+ LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
36
38
  end
37
39
 
38
40
  def tags_jsonb?