llm_cost_tracker 0.5.3 → 0.6.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -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/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  7. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  8. data/lib/llm_cost_tracker/doctor.rb +2 -0
  9. data/lib/llm_cost_tracker/event.rb +1 -0
  10. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  11. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  12. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  13. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  14. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  15. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  16. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  17. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  18. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  19. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  20. data/lib/llm_cost_tracker/pricing/lookup.rb +44 -11
  21. data/lib/llm_cost_tracker/railtie.rb +1 -0
  22. data/lib/llm_cost_tracker/storage/active_record_backend.rb +54 -3
  23. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  24. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  25. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  26. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  27. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  28. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  29. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  30. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  31. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  32. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +31 -69
  33. data/lib/llm_cost_tracker/storage/active_record_store.rb +42 -9
  34. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  35. data/lib/llm_cost_tracker/tag_sql.rb +3 -3
  36. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  37. data/lib/llm_cost_tracker/tracker.rb +3 -0
  38. data/lib/llm_cost_tracker/version.rb +1 -1
  39. data/lib/llm_cost_tracker.rb +36 -1
  40. metadata +17 -19
  41. data/docs/architecture.md +0 -28
  42. data/docs/budgets.md +0 -45
  43. data/docs/configuration.md +0 -65
  44. data/docs/cookbook.md +0 -185
  45. data/docs/dashboard-overview.png +0 -0
  46. data/docs/dashboard.md +0 -38
  47. data/docs/extending.md +0 -32
  48. data/docs/operations.md +0 -44
  49. data/docs/pricing.md +0 -94
  50. data/docs/querying.md +0 -36
  51. data/docs/streaming.md +0 -70
  52. data/docs/technical/README.md +0 -10
  53. data/docs/technical/data-flow.md +0 -67
  54. data/docs/technical/extension-points.md +0 -111
  55. data/docs/technical/module-map.md +0 -197
  56. data/docs/technical/operational-notes.md +0 -77
  57. data/docs/upgrading.md +0 -46
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3fe81fdf7b12f977d7dfc4aa86f629e8b5ad6e099100bfbfe1b18507db17fff
4
- data.tar.gz: dffe0c0ebeb30fa111273b654141ee06e1275426846796b1749435429da3414f
3
+ metadata.gz: 6942269ea8e3ce5f22bb30a1dc6f6c73ee056b33f4f5d7697d1749ce5742ca93
4
+ data.tar.gz: 1b0712cfcbea56ee0e03dd1cb6c400969fc485c5648f75616140a62f6e82fc02
5
5
  SHA512:
6
- metadata.gz: 4849f0d0b09d640ed3a902bbc19a975e576b0539d2c71eac3165199eed196ecd88f249ff1d571c43b2686d59466dde26a0467f984c2cbb7804a5f771e184df31
7
- data.tar.gz: 8aa17efc9dd68b1489cdc69351a67fe2398c35720750cf1440f215bceeef0392c9f3af1d41ebd444ddcd9876c2c1e612ea50d6403fb7f650735fc9d9161a2a85
6
+ metadata.gz: 9793cc211dd08669deecf4a1d6a242ec4f2fff3fc41922472d158002731f4db40d618163d6fb054eeb36cae4e8aa708a0055a0fe1820faf88081b26de407107a
7
+ data.tar.gz: 05ed21f8309b16475b4ca7a0a938c999336f0ebb09baf897225422c69dd8e7e81f73ef01e69ff2d4c80d392f49230c212a87ec5c925c6e242b24942c5369f37f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.1] - 2026-04-29
8
+
9
+ ### Fixed
10
+
11
+ - Exclude repository documentation from the published gem package.
12
+
13
+ ## [0.6.0] - 2026-04-29
14
+
15
+ ### Added
16
+
17
+ - Durable ActiveRecord ingestion through `llm_cost_tracker_inbox_events` and `llm_cost_tracker_ingestor_leases`.
18
+ - `llm_cost_tracker:add_ingestion` generator for upgrading existing ActiveRecord installs.
19
+ - `LlmCostTracker.flush!` and `LlmCostTracker.shutdown!` for draining or stopping durable ingestion.
20
+ - Doctor diagnostics for missing durable ingestion schema, stale pending inbox rows, and quarantined inbox rows.
21
+ - PostgreSQL and MySQL smoke checks for ActiveRecord durable ingestion.
22
+
23
+ ### Changed
24
+
25
+ - Fresh ActiveRecord installs now include durable ingestion tables, event IDs, and production indexes.
26
+ - ActiveRecord budget totals now read stored period rollups plus pending inbox totals while durable ingestion is enabled.
27
+ - ActiveRecord writes now use a durable inbox before batching ledger inserts and period rollup updates when ingestion tables are present.
28
+ - Pricing lookup now caches normalized runtime price tables and model matches by configuration generation.
29
+ - Stream capture now estimates buffered event size without serializing every captured event.
30
+ - CSV export now selects only exported columns instead of loading full ActiveRecord objects.
31
+
32
+ ### Fixed
33
+
34
+ - ActiveRecord rollups no longer double-count retried events when duplicate event IDs race across workers.
35
+ - Invalid inbox rows are retried and quarantined without blocking healthy rows behind them.
36
+ - Idle ingestors no longer acquire the leader lease while the inbox is empty.
37
+ - ActiveRecord inbox writes now fail honestly when a separate connection is unavailable inside a caller transaction.
38
+ - Ingestor shutdown/reset no longer lets an old sleeping thread resume as a second local ingestor.
39
+ - `flush!` now returns `false` instead of raising when its timeout expires during ingestion.
40
+ - ActiveRecord adapter family detection now works through known adapter class ancestry with an adapter-name fallback.
41
+ - CSV export now emits `{}` for invalid stored tag payloads.
42
+
7
43
  ## [0.5.3] - 2026-04-28
8
44
 
9
45
  ### 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
@@ -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?
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class InboxEvent < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_inbox_events"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module LlmCostTracker
6
+ class IngestorLease < ActiveRecord::Base
7
+ self.table_name = "llm_cost_tracker_ingestor_leases"
8
+ end
9
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "active_record_adapter"
4
+
3
5
  module LlmCostTracker
4
6
  module PeriodGrouping
5
7
  PERIOD_FORMATS = {
@@ -34,10 +36,9 @@ module LlmCostTracker
34
36
  column = period_column_expression(column)
35
37
  formats = PERIOD_FORMATS.fetch(period)
36
38
 
37
- case connection.adapter_name
38
- when /postgres/i
39
+ if ActiveRecordAdapter.postgresql?(connection)
39
40
  postgres_period_expression(period, column, formats)
40
- when /mysql/i
41
+ elsif ActiveRecordAdapter.mysql?(connection)
41
42
  "DATE_FORMAT(#{column}, #{connection.quote(formats.fetch(:mysql))})"
42
43
  else
43
44
  "strftime(#{connection.quote(formats.fetch(:sqlite))}, #{column})"