llm_cost_tracker 0.7.0 → 0.7.2

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 (174) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/README.md +21 -16
  4. data/app/assets/llm_cost_tracker/application.css +3 -0
  5. data/app/controllers/llm_cost_tracker/application_controller.rb +22 -4
  6. data/app/controllers/llm_cost_tracker/calls_controller.rb +6 -11
  7. data/app/controllers/llm_cost_tracker/dashboard_controller.rb +2 -1
  8. data/app/controllers/llm_cost_tracker/data_quality_controller.rb +5 -1
  9. data/app/controllers/llm_cost_tracker/models_controller.rb +0 -1
  10. data/app/controllers/llm_cost_tracker/tags_controller.rb +1 -8
  11. data/app/helpers/llm_cost_tracker/application_helper.rb +2 -1
  12. data/app/helpers/llm_cost_tracker/dashboard_filter_helper.rb +1 -2
  13. data/app/helpers/llm_cost_tracker/dashboard_filter_options_helper.rb +1 -1
  14. data/app/helpers/llm_cost_tracker/dashboard_query_helper.rb +10 -27
  15. data/app/helpers/llm_cost_tracker/token_usage_helper.rb +58 -0
  16. data/app/models/llm_cost_tracker/ingestion/event.rb +13 -0
  17. data/app/models/llm_cost_tracker/ingestion/lease.rb +11 -0
  18. data/app/models/llm_cost_tracker/ledger/call.rb +45 -0
  19. data/app/models/llm_cost_tracker/ledger/call_metrics.rb +66 -0
  20. data/app/models/llm_cost_tracker/ledger/period/grouping.rb +71 -0
  21. data/app/models/llm_cost_tracker/ledger/period/total.rb +13 -0
  22. data/app/models/llm_cost_tracker/ledger/tags/accessors.rb +19 -0
  23. data/app/services/llm_cost_tracker/dashboard/data_quality.rb +111 -94
  24. data/app/services/llm_cost_tracker/dashboard/date_range.rb +2 -2
  25. data/app/services/llm_cost_tracker/dashboard/filter.rb +7 -18
  26. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +58 -67
  27. data/app/services/llm_cost_tracker/dashboard/pagination.rb +59 -0
  28. data/app/services/llm_cost_tracker/dashboard/params.rb +26 -0
  29. data/app/services/llm_cost_tracker/dashboard/provider_breakdown.rb +18 -20
  30. data/app/services/llm_cost_tracker/dashboard/spend_anomaly.rb +4 -13
  31. data/app/services/llm_cost_tracker/dashboard/tag_breakdown.rb +28 -61
  32. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +8 -21
  33. data/app/services/llm_cost_tracker/dashboard/time_series.rb +1 -1
  34. data/app/services/llm_cost_tracker/dashboard/top_models.rb +12 -47
  35. data/app/views/llm_cost_tracker/calls/index.html.erb +12 -18
  36. data/app/views/llm_cost_tracker/calls/show.html.erb +30 -32
  37. data/app/views/llm_cost_tracker/dashboard/index.html.erb +17 -19
  38. data/app/views/llm_cost_tracker/data_quality/index.html.erb +108 -135
  39. data/app/views/llm_cost_tracker/models/index.html.erb +8 -9
  40. data/app/views/llm_cost_tracker/shared/setup_required.html.erb +13 -2
  41. data/app/views/llm_cost_tracker/tags/show.html.erb +20 -20
  42. data/lib/llm_cost_tracker/budget.rb +8 -20
  43. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  44. data/lib/llm_cost_tracker/capture/stream_collector.rb +189 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +41 -73
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +33 -36
  48. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  49. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  50. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -59
  51. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  52. data/lib/llm_cost_tracker/doctor.rb +63 -71
  53. data/lib/llm_cost_tracker/errors.rb +4 -15
  54. data/lib/llm_cost_tracker/event.rb +6 -6
  55. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  56. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  57. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +3 -3
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +9 -14
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +0 -4
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  64. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  65. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  66. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  67. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  68. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  69. data/lib/llm_cost_tracker/integrations/anthropic.rb +66 -31
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +43 -37
  72. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  73. data/lib/llm_cost_tracker/integrations.rb +43 -0
  74. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  75. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  76. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  78. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  79. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  80. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  81. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  82. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  83. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  84. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  85. data/lib/llm_cost_tracker/ledger.rb +13 -0
  86. data/lib/llm_cost_tracker/logging.rb +3 -6
  87. data/lib/llm_cost_tracker/middleware/faraday.rb +88 -46
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +62 -29
  89. data/lib/llm_cost_tracker/parsers/base.rb +12 -21
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +50 -25
  91. data/lib/llm_cost_tracker/parsers/openai.rb +27 -5
  92. data/lib/llm_cost_tracker/parsers/openai_compatible.rb +14 -4
  93. data/lib/llm_cost_tracker/parsers/openai_usage.rb +58 -25
  94. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  95. data/lib/llm_cost_tracker/parsers.rb +20 -0
  96. data/lib/llm_cost_tracker/prices.json +361 -36
  97. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  98. data/lib/llm_cost_tracker/pricing/effective_prices.rb +46 -50
  99. data/lib/llm_cost_tracker/pricing/explainer.rb +25 -30
  100. data/lib/llm_cost_tracker/pricing/lookup.rb +67 -46
  101. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  102. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  106. data/lib/llm_cost_tracker/pricing/sync.rb +159 -0
  107. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  108. data/lib/llm_cost_tracker/pricing.rb +33 -32
  109. data/lib/llm_cost_tracker/railtie.rb +7 -8
  110. data/lib/llm_cost_tracker/report/data.rb +72 -0
  111. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  112. data/lib/llm_cost_tracker/report.rb +8 -8
  113. data/lib/llm_cost_tracker/retention.rb +27 -10
  114. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  115. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  116. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  117. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  118. data/lib/llm_cost_tracker/tracker.rb +39 -69
  119. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  120. data/lib/llm_cost_tracker/version.rb +1 -1
  121. data/lib/llm_cost_tracker.rb +56 -78
  122. data/lib/tasks/llm_cost_tracker.rake +18 -13
  123. metadata +54 -58
  124. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  125. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  126. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  127. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  128. data/lib/llm_cost_tracker/cost.rb +0 -12
  129. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  130. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  131. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  132. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  133. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  134. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  135. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  136. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  137. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  138. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  139. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  140. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  141. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  142. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  143. data/lib/llm_cost_tracker/period_total.rb +0 -9
  144. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  145. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  146. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  147. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  148. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  149. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  150. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  151. data/lib/llm_cost_tracker/report_data.rb +0 -94
  152. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  153. data/lib/llm_cost_tracker/request_url.rb +0 -20
  154. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  155. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  156. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  157. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  158. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  159. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  160. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  161. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  162. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  163. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  164. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  165. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  166. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  167. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  168. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  169. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  170. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  171. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  172. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  173. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  174. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -1,14 +1,25 @@
1
1
  class UpgradeLlmApiCallCostPrecision < ActiveRecord::Migration<%= migration_version %>
2
- COST_COLUMNS = %i[input_cost output_cost total_cost].freeze
2
+ COST_COLUMNS = %i[
3
+ input_cost
4
+ cache_read_input_cost
5
+ cache_write_input_cost
6
+ cache_write_1h_input_cost
7
+ output_cost
8
+ total_cost
9
+ ].freeze
3
10
 
4
11
  def up
5
12
  COST_COLUMNS.each do |column|
13
+ next unless column_exists?(:llm_api_calls, column)
14
+
6
15
  change_column :llm_api_calls, column, :decimal, precision: 20, scale: 8
7
16
  end
8
17
  end
9
18
 
10
19
  def down
11
20
  COST_COLUMNS.each do |column|
21
+ next unless column_exists?(:llm_api_calls, column)
22
+
12
23
  change_column :llm_api_calls, column, :decimal, precision: 12, scale: 8
13
24
  end
14
25
  end
@@ -1,4 +1,4 @@
1
- require "llm_cost_tracker/active_record_adapter"
1
+ require "llm_cost_tracker/ledger/schema/adapter"
2
2
 
3
3
  class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_version %>
4
4
  def up
@@ -34,7 +34,7 @@ class UpgradeLlmApiCallTagsToJsonb < ActiveRecord::Migration<%= migration_versio
34
34
  private
35
35
 
36
36
  def postgresql?
37
- LlmCostTracker::ActiveRecordAdapter.postgresql?(connection)
37
+ LlmCostTracker::Ledger::Schema::Adapter.postgresql?(connection)
38
38
  end
39
39
 
40
40
  def tags_jsonb?
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../inbox_event"
4
- require_relative "active_record_inbox"
5
- require_relative "active_record_store"
3
+ require_relative "inbox"
4
+ require_relative "../ledger/store"
6
5
 
7
6
  module LlmCostTracker
8
- module Storage
9
- class ActiveRecordInboxBatch
7
+ module Ingestion
8
+ class Batch
10
9
  BATCH_SIZE = 100
11
10
  LOCK_TIMEOUT_SECONDS = 30
12
11
 
@@ -27,14 +26,18 @@ module LlmCostTracker
27
26
  raise
28
27
  end
29
28
 
30
- def pending? = model.where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS).exists?
29
+ def pending?
30
+ Ingestion::Event.where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS).exists?
31
+ end
31
32
 
32
- def claimable? = claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
33
+ def claimable?
34
+ claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
35
+ end
33
36
 
34
37
  def mark_failed(rows, error)
35
38
  message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
36
39
  now = Time.now.utc
37
- model
40
+ Ingestion::Event
38
41
  .where(id: rows.map(&:id), locked_by: identity)
39
42
  .update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
40
43
  rescue StandardError
@@ -48,16 +51,16 @@ module LlmCostTracker
48
51
  def claim
49
52
  now = Time.now.utc
50
53
  cutoff = now - LOCK_TIMEOUT_SECONDS
51
- model.transaction do
54
+ Ingestion::Event.transaction do
52
55
  rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
53
56
  ids = rows.map(&:id)
54
57
  next [] if ids.empty?
55
58
 
56
- updates = model.sanitize_sql_array(
59
+ updates = Ingestion::Event.sanitize_sql_array(
57
60
  ["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
58
61
  )
59
- model.where(id: ids).update_all(updates)
60
- model.where(id: ids, locked_by: identity).order(:id).to_a
62
+ Ingestion::Event.where(id: ids).update_all(updates)
63
+ Ingestion::Event.where(id: ids, locked_by: identity).order(:id).to_a
61
64
  end
62
65
  end
63
66
 
@@ -65,7 +68,7 @@ module LlmCostTracker
65
68
  valid_rows = []
66
69
  events = []
67
70
  rows.each do |row|
68
- events << ActiveRecordInbox.event_from_row(row)
71
+ events << Ingestion::Inbox.event_from_row(row)
69
72
  valid_rows << row
70
73
  rescue StandardError => e
71
74
  mark_failed([row], e)
@@ -74,19 +77,17 @@ module LlmCostTracker
74
77
  end
75
78
 
76
79
  def persist(rows, events)
77
- LlmCostTracker::LlmApiCall.transaction do
78
- ActiveRecordStore.insert_many(events)
79
- model.where(id: rows.map(&:id), locked_by: identity).delete_all
80
+ LlmCostTracker::Ledger::Call.transaction do
81
+ Ledger::Store.insert_many(events)
82
+ Ingestion::Event.where(id: rows.map(&:id), locked_by: identity).delete_all
80
83
  end
81
84
  end
82
85
 
83
86
  def claimable_scope(cutoff)
84
- model
85
- .where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS)
87
+ Ingestion::Event
88
+ .where("attempts < ?", Ingestion::Event::MAX_ATTEMPTS)
86
89
  .where("locked_at IS NULL OR locked_at < ?", cutoff)
87
90
  end
88
-
89
- def model = LlmCostTracker::InboxEvent
90
91
  end
91
92
  end
92
93
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ require_relative "../event"
7
+ require_relative "../pricing"
8
+
9
+ module LlmCostTracker
10
+ module Ingestion
11
+ class Inbox
12
+ PAYLOAD_SCHEMA_VERSION = 1
13
+
14
+ class << self
15
+ def save(event)
16
+ insert_row(row_for(event))
17
+ Ingestion::Worker.ensure_started
18
+ event
19
+ end
20
+
21
+ def event_from_row(row)
22
+ payload = JSON.parse(row.payload)
23
+ schema_version = payload.fetch("schema_version", 0)
24
+ unless [0, PAYLOAD_SCHEMA_VERSION].include?(schema_version)
25
+ raise LlmCostTracker::Error, "unsupported ledger inbox payload schema version #{schema_version.inspect}"
26
+ end
27
+
28
+ cost = payload["cost"] && Pricing.stored_cost_attributes(payload["cost"])
29
+ token_usage = payload["token_usage"] || payload
30
+
31
+ LlmCostTracker::Event.new(
32
+ event_id: payload.fetch("event_id"),
33
+ provider: payload.fetch("provider"),
34
+ model: payload.fetch("model"),
35
+ token_usage: TokenUsage.from_hash(token_usage),
36
+ pricing_mode: payload["pricing_mode"],
37
+ cost: cost,
38
+ tags: payload.fetch("tags"),
39
+ latency_ms: payload["latency_ms"],
40
+ stream: payload.fetch("stream"),
41
+ usage_source: payload["usage_source"],
42
+ provider_response_id: payload["provider_response_id"],
43
+ tracked_at: Time.iso8601(payload.fetch("tracked_at"))
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ def row_for(event)
50
+ now = Time.now.utc
51
+ {
52
+ event_id: event.event_id,
53
+ total_cost: event.total_cost,
54
+ tracked_at: event.tracked_at,
55
+ payload: JSON.generate(payload_for(event)),
56
+ attempts: 0,
57
+ created_at: now,
58
+ updated_at: now
59
+ }
60
+ end
61
+
62
+ def payload_for(event)
63
+ event.to_h.merge(
64
+ schema_version: PAYLOAD_SCHEMA_VERSION,
65
+ event_id: event.event_id,
66
+ provider: event.provider,
67
+ model: event.model,
68
+ tracked_at: event.tracked_at.iso8601(6)
69
+ )
70
+ end
71
+
72
+ def insert_row(row)
73
+ connection = LlmCostTracker::Ledger::Call.connection
74
+ if connection.transaction_open?
75
+ insert_with_separate_connection(row)
76
+ else
77
+ execute_insert(connection, row)
78
+ end
79
+ rescue ActiveRecord::ConnectionTimeoutError => e
80
+ raise LlmCostTracker::Error,
81
+ "ledger inbox could not checkout a separate database connection: #{e.message}"
82
+ end
83
+
84
+ def insert_with_separate_connection(row)
85
+ pool = LlmCostTracker::Ledger::Call.connection_pool
86
+ connection = pool.checkout
87
+ begin
88
+ connection.transaction(requires_new: true) { execute_insert(connection, row) }
89
+ ensure
90
+ pool.checkin(connection)
91
+ end
92
+ end
93
+
94
+ def execute_insert(connection, row)
95
+ columns = row.keys
96
+ quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
97
+ quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
98
+ table = connection.quote_table_name(Event.table_name)
99
+
100
+ connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../ingestor_lease"
4
-
5
3
  module LlmCostTracker
6
- module Storage
7
- class ActiveRecordIngestorLease
4
+ module Ingestion
5
+ class LeaseClaim
8
6
  LEASE_NAME = "default"
9
7
 
10
8
  def initialize(identity:, seconds:)
@@ -14,9 +12,9 @@ module LlmCostTracker
14
12
 
15
13
  def acquire
16
14
  now = Time.now.utc
17
- LlmCostTracker::IngestorLease.transaction do
18
- lease = LlmCostTracker::IngestorLease.lock.find_by(name: LEASE_NAME)
19
- lease ||= LlmCostTracker::IngestorLease.create!(name: LEASE_NAME)
15
+ LlmCostTracker::Ingestion::Lease.transaction do
16
+ lease = LlmCostTracker::Ingestion::Lease.lock.find_by(name: LEASE_NAME)
17
+ lease ||= LlmCostTracker::Ingestion::Lease.create!(name: LEASE_NAME)
20
18
  next false unless available?(lease, now)
21
19
 
22
20
  lease.update!(locked_by: identity, locked_until: now + seconds)
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/kernel/reporting"
3
4
  require "securerandom"
4
5
 
5
- require_relative "../inbox_event"
6
+ require_relative "inbox"
7
+ require_relative "batch"
8
+ require_relative "lease_claim"
6
9
  require_relative "../logging"
7
- require_relative "active_record_connection_cleanup"
8
- require_relative "active_record_inbox"
9
- require_relative "active_record_inbox_batch"
10
- require_relative "active_record_ingestor_lease"
11
10
 
12
11
  module LlmCostTracker
13
- module Storage
14
- class ActiveRecordIngestor
12
+ module Ingestion
13
+ class Worker
15
14
  INTERVAL_SECONDS = 0.25
16
15
  IDLE_INTERVAL_SECONDS = 1.0
17
16
  MAX_IDLE_INTERVAL_SECONDS = 5.0
@@ -19,13 +18,12 @@ module LlmCostTracker
19
18
  FLUSH_TIMEOUT_SECONDS = 10
20
19
  class << self
21
20
  def ensure_started
22
- return unless ActiveRecordInbox.enabled?
23
-
24
21
  thread = mutex.synchronize do
25
22
  reset_after_fork!
26
23
  unless @thread&.alive?
27
24
  @stop_requested = false
28
- generation = next_generation
25
+ @generation = @generation.to_i + 1
26
+ generation = @generation
29
27
  @thread = Thread.new { run(generation) }
30
28
  @thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
31
29
  @thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
@@ -36,23 +34,27 @@ module LlmCostTracker
36
34
  end
37
35
 
38
36
  def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
39
- return true unless ActiveRecordInbox.enabled?
37
+ Ingestion.ensure_current_schema!
40
38
 
41
39
  deadline = Time.now.utc + timeout
42
40
  loop do
43
- return true unless pending_events?
41
+ return true unless Ingestion::Batch.new(identity: identity).pending?
44
42
  return false if Time.now.utc >= deadline
45
43
 
46
44
  processed = ingest_once(require_lease: require_lease)
47
- return false if processed.zero? && !sleep_until_next_flush(deadline)
45
+ next unless processed.zero?
46
+
47
+ duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
48
+ return false unless duration.positive?
49
+
50
+ sleep(duration)
48
51
  end
49
52
  end
50
53
 
51
54
  def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
52
- ActiveRecordInbox.reset!
53
55
  thread = mutex.synchronize do
54
56
  @stop_requested = true
55
- next_generation
57
+ @generation = @generation.to_i + 1
56
58
  @thread
57
59
  end
58
60
  wake_thread(thread)
@@ -68,7 +70,7 @@ module LlmCostTracker
68
70
  def reset!
69
71
  thread = mutex.synchronize do
70
72
  @stop_requested = true
71
- next_generation
73
+ @generation = @generation.to_i + 1
72
74
  thread = @thread
73
75
  @thread = nil
74
76
  @pid = nil
@@ -79,11 +81,11 @@ module LlmCostTracker
79
81
  end
80
82
 
81
83
  def ingest_once(require_lease: true)
82
- return 0 unless ActiveRecordInbox.enabled?
83
- return 0 unless claimable_events?
84
- return 0 if require_lease && !acquire_lease
84
+ batch = Ingestion::Batch.new(identity: identity)
85
+ return 0 unless batch.claimable?
86
+ return 0 if require_lease && !Ingestion::LeaseClaim.new(identity: identity, seconds: LEASE_SECONDS).acquire
85
87
 
86
- inbox_batch.ingest
88
+ batch.ingest
87
89
  rescue StandardError => e
88
90
  handle_error(e)
89
91
  0
@@ -91,15 +93,17 @@ module LlmCostTracker
91
93
 
92
94
  private
93
95
 
94
- def mutex = @mutex ||= Mutex.new
96
+ def mutex
97
+ @mutex ||= Mutex.new
98
+ end
95
99
 
96
100
  def run(generation)
97
101
  idle_interval = IDLE_INTERVAL_SECONDS
98
102
  loop do
99
- break if stop_requested?(generation)
103
+ break if mutex.synchronize { @stop_requested || generation != @generation }
100
104
 
101
105
  processed = executor_wrap { ingest_once }
102
- ActiveRecordConnectionCleanup.release!
106
+ release_connection!
103
107
  if processed.zero?
104
108
  sleep(idle_interval)
105
109
  idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
@@ -108,21 +112,14 @@ module LlmCostTracker
108
112
  end
109
113
  rescue StandardError => e
110
114
  handle_error(e)
111
- ActiveRecordConnectionCleanup.release!
115
+ release_connection!
112
116
  sleep(idle_interval)
113
117
  end
114
118
  ensure
115
- ActiveRecordConnectionCleanup.release!
119
+ release_connection!
116
120
  mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
117
121
  end
118
122
 
119
- def sleep_until_next_flush(deadline)
120
- duration = [INTERVAL_SECONDS, deadline - Time.now.utc].min
121
- sleep(duration) if duration.positive?
122
- end
123
-
124
- def stop_requested?(generation) = mutex.synchronize { @stop_requested || generation != @generation }
125
-
126
123
  def reset_after_fork!
127
124
  return if @pid == Process.pid
128
125
 
@@ -131,8 +128,6 @@ module LlmCostTracker
131
128
  @identity = nil
132
129
  end
133
130
 
134
- def next_generation = (@generation = @generation.to_i + 1)
135
-
136
131
  def wake_thread(thread)
137
132
  thread&.wakeup if thread&.alive?
138
133
  rescue ThreadError
@@ -147,26 +142,21 @@ module LlmCostTracker
147
142
  end
148
143
 
149
144
  def rails_executor
150
- return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:executor)
151
-
152
- Rails.application.executor
145
+ Rails.application.try(:executor)
153
146
  rescue StandardError
154
147
  nil
155
148
  end
156
149
 
157
- def identity = @identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
158
-
159
- def acquire_lease = ActiveRecordIngestorLease.new(identity: identity, seconds: LEASE_SECONDS).acquire
160
-
161
- def pending_events? = inbox_batch.pending?
162
-
163
- def claimable_events? = inbox_batch.claimable?
164
-
165
- def inbox_batch = ActiveRecordInboxBatch.new(identity: identity)
150
+ def identity
151
+ @identity ||= "pid-#{Process.pid}-#{SecureRandom.hex(6)}"
152
+ end
166
153
 
167
154
  def handle_error(error)
168
- Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}") unless
169
- LlmCostTracker.configuration.storage_error_behavior == :ignore
155
+ Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}")
156
+ end
157
+
158
+ def release_connection!
159
+ suppress(StandardError) { ActiveRecord::Base.connection_handler.clear_active_connections! }
170
160
  end
171
161
  end
172
162
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "doctor/check"
6
+ require_relative "errors"
7
+ require_relative "ledger"
8
+ require_relative "ingestion/lease_claim"
9
+ require_relative "ingestion/inbox"
10
+ require_relative "ingestion/batch"
11
+ require_relative "ingestion/worker"
12
+
13
+ module LlmCostTracker
14
+ module Ingestion
15
+ VERIFY_TAG = "llm_cost_tracker_verify"
16
+
17
+ class << self
18
+ def ensure_current_schema!
19
+ unless Ledger::Call.table_exists?
20
+ raise Error, "llm_api_calls table is missing; run install generator and migrate"
21
+ end
22
+
23
+ schema_errors = Ledger::Schema::Calls.current_schema_errors
24
+ message = "llm_api_calls table is not on the current schema: #{schema_errors.join('; ')}"
25
+ raise Error, message if schema_errors.any?
26
+
27
+ period_total_errors = Ledger::Schema::PeriodTotals.current_schema_errors
28
+ return if period_total_errors.empty?
29
+
30
+ message = "llm_cost_tracker_period_totals table is not on the current schema: " \
31
+ "#{period_total_errors.join('; ')}; " \
32
+ "run bin/rails generate llm_cost_tracker:add_period_totals && bin/rails db:migrate"
33
+ raise Error, message
34
+ end
35
+
36
+ def verify
37
+ unless LlmCostTracker::Ledger::Call.table_exists?
38
+ return [
39
+ LlmCostTracker::Doctor::Check.new(
40
+ :error,
41
+ "active_record",
42
+ "llm_api_calls table is missing; run install generator and migrate"
43
+ )
44
+ ]
45
+ end
46
+
47
+ [capture_check]
48
+ rescue StandardError => e
49
+ [LlmCostTracker::Doctor::Check.new(:error, "active_record", "#{e.class}: #{e.message}")]
50
+ end
51
+
52
+ private
53
+
54
+ def capture_check
55
+ provider, model = sample_priced_identity
56
+ response_id = "lct_verify_#{SecureRandom.hex(8)}"
57
+ notifications = []
58
+ subscription = subscribe_to_verification(response_id, notifications)
59
+
60
+ event = LlmCostTracker.track(
61
+ provider: provider,
62
+ model: model,
63
+ input_tokens: 1,
64
+ output_tokens: 1,
65
+ provider_response_id: response_id,
66
+ feature: VERIFY_TAG
67
+ )
68
+ LlmCostTracker.flush!
69
+ persisted = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id).exists?
70
+
71
+ return capture_success if persisted && notifications.any?
72
+
73
+ LlmCostTracker::Doctor::Check.new(
74
+ :error,
75
+ "active_record capture",
76
+ capture_failure_message(persisted, notifications)
77
+ )
78
+ rescue LlmCostTracker::BudgetExceededError => e
79
+ LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "blocked by budget guardrail: #{e.message}")
80
+ rescue LlmCostTracker::Error => e
81
+ LlmCostTracker::Doctor::Check.new(:error, "active_record capture", e.message)
82
+ rescue StandardError => e
83
+ LlmCostTracker::Doctor::Check.new(:error, "active_record capture", "#{e.class}: #{e.message}")
84
+ ensure
85
+ cleanup_verification_call(response_id) if response_id
86
+ LlmCostTracker::Ingestion::Event.where(event_id: event.event_id).delete_all if event
87
+ ActiveSupport::Notifications.unsubscribe(subscription) if subscription
88
+ end
89
+
90
+ def subscribe_to_verification(response_id, notifications)
91
+ ActiveSupport::Notifications.subscribe(LlmCostTracker::Tracker::EVENT_NAME) do |*, payload|
92
+ notifications << payload if payload[:provider_response_id] == response_id
93
+ end
94
+ end
95
+
96
+ def capture_success
97
+ LlmCostTracker::Doctor::Check.new(
98
+ :ok,
99
+ "active_record capture",
100
+ "manual event emitted and persisted through durable inbox"
101
+ )
102
+ end
103
+
104
+ def capture_failure_message(persisted, notifications)
105
+ missing = []
106
+ missing << "notification" if notifications.empty?
107
+ missing << "persisted row" unless persisted
108
+ "missing #{missing.join(' and ')} for synthetic manual event"
109
+ end
110
+
111
+ def cleanup_verification_call(response_id)
112
+ relation = LlmCostTracker::Ledger::Call.where(provider_response_id: response_id)
113
+ rows = relation.pluck(:id, :tracked_at, :total_cost)
114
+ return if rows.empty?
115
+
116
+ relation.delete_all
117
+ LlmCostTracker::Ledger::Rollups.decrement!(rows)
118
+ end
119
+
120
+ def sample_priced_identity
121
+ key = LlmCostTracker::Pricing::Registry.builtin_prices.find do |model_id, prices|
122
+ model_id.include?("/") && prices[:input] && prices[:output]
123
+ end&.first
124
+ provider, model = key.to_s.split("/", 2)
125
+ [provider || "openai", model || "gpt-4o-mini"]
126
+ end
127
+ end
128
+ end
129
+ end