llm_cost_tracker 0.5.2 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +8 -3
  4. data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
  5. data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
  6. data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
  7. data/docs/architecture.md +28 -0
  8. data/docs/budgets.md +45 -0
  9. data/docs/configuration.md +65 -0
  10. data/docs/cookbook.md +185 -0
  11. data/docs/dashboard-overview.png +0 -0
  12. data/docs/dashboard.md +38 -0
  13. data/docs/extending.md +32 -0
  14. data/docs/operations.md +44 -0
  15. data/docs/pricing.md +94 -0
  16. data/docs/querying.md +36 -0
  17. data/docs/streaming.md +70 -0
  18. data/docs/technical/README.md +10 -0
  19. data/docs/technical/data-flow.md +70 -0
  20. data/docs/technical/extension-points.md +111 -0
  21. data/docs/technical/module-map.md +197 -0
  22. data/docs/technical/operational-notes.md +97 -0
  23. data/docs/upgrading.md +47 -0
  24. data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
  25. data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
  26. data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
  27. data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
  28. data/lib/llm_cost_tracker/configuration.rb +2 -1
  29. data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
  30. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
  31. data/lib/llm_cost_tracker/doctor.rb +8 -1
  32. data/lib/llm_cost_tracker/event.rb +1 -0
  33. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
  34. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
  35. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
  36. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
  37. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
  38. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
  39. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
  40. data/lib/llm_cost_tracker/inbox_event.rb +9 -0
  41. data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
  42. data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
  43. data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
  44. data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
  45. data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
  46. data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
  47. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
  48. data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
  49. data/lib/llm_cost_tracker/period_grouping.rb +4 -3
  50. data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
  51. data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
  52. data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
  53. data/lib/llm_cost_tracker/pricing.rb +25 -108
  54. data/lib/llm_cost_tracker/railtie.rb +1 -0
  55. data/lib/llm_cost_tracker/retention.rb +3 -9
  56. data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
  57. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
  58. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
  59. data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
  60. data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
  61. data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
  62. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
  63. data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
  64. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
  65. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
  66. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
  67. data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
  68. data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
  69. data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
  70. data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
  71. data/lib/llm_cost_tracker/storage/registry.rb +63 -0
  72. data/lib/llm_cost_tracker/stream_collector.rb +18 -7
  73. data/lib/llm_cost_tracker/tag_sql.rb +34 -0
  74. data/lib/llm_cost_tracker/tags_column.rb +7 -1
  75. data/lib/llm_cost_tracker/tracker.rb +3 -0
  76. data/lib/llm_cost_tracker/version.rb +1 -1
  77. data/lib/llm_cost_tracker.rb +39 -1
  78. data/lib/tasks/llm_cost_tracker.rake +49 -0
  79. metadata +47 -2
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+
5
+ require_relative "active_record_periods"
6
+ require_relative "active_record_rollup_batch"
7
+ require_relative "active_record_rollup_upsert_sql"
8
+
3
9
  module LlmCostTracker
4
10
  module Storage
5
11
  class ActiveRecordRollups
6
- PERIODS = {
7
- monthly: "month",
8
- daily: "day"
9
- }.freeze
10
-
11
12
  class << self
12
13
  def reset!
13
14
  remove_instance_variable(:@period_totals_enabled) if instance_variable_defined?(:@period_totals_enabled)
@@ -20,22 +21,37 @@ module LlmCostTracker
20
21
  model = period_total_model
21
22
  model.upsert_all(
22
23
  period_rows(event),
23
- on_duplicate: total_upsert_sql(model),
24
+ on_duplicate: ActiveRecordRollupUpsertSql.call(model),
24
25
  record_timestamps: true,
25
26
  unique_by: unique_by(model, %i[period period_start])
26
27
  )
27
28
  end
28
29
 
29
- def monthly_total(time: Time.now.utc)
30
- period_totals(%i[monthly], time: time).fetch(:monthly)
30
+ def increment_many!(events)
31
+ events = Array(events).select { |event| event.cost&.total_cost }
32
+ return if events.empty?
33
+ return unless period_totals_enabled?
34
+
35
+ model = period_total_model
36
+ model.upsert_all(
37
+ ActiveRecordRollupBatch.rows(events),
38
+ on_duplicate: ActiveRecordRollupUpsertSql.call(model),
39
+ record_timestamps: true,
40
+ unique_by: unique_by(model, %i[period period_start])
41
+ )
31
42
  end
32
43
 
33
- def daily_total(time: Time.now.utc)
34
- period_totals(%i[daily], time: time).fetch(:daily)
44
+ def decrement!(call_rows)
45
+ return unless period_totals_enabled?
46
+
47
+ totals = period_decrement_totals(call_rows)
48
+ return if totals.empty?
49
+
50
+ apply_decrements(totals)
35
51
  end
36
52
 
37
53
  def period_totals(periods, time: Time.now.utc)
38
- periods = periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
54
+ periods = ActiveRecordPeriods.valid_keys(periods)
39
55
  return {} if periods.empty?
40
56
 
41
57
  if period_totals_enabled?
@@ -48,22 +64,48 @@ module LlmCostTracker
48
64
  private
49
65
 
50
66
  def period_rows(event)
51
- PERIODS.map do |period, name|
67
+ ActiveRecordPeriods::PERIODS.map do |period, name|
52
68
  {
53
69
  period: name,
54
- period_start: bucket_for(period, event.tracked_at),
70
+ period_start: ActiveRecordPeriods.bucket(period, event.tracked_at),
55
71
  total_cost: event.cost.total_cost
56
72
  }
57
73
  end
58
74
  end
59
75
 
76
+ def period_decrement_totals(call_rows)
77
+ call_rows.each_with_object(Hash.new { |totals, key| totals[key] = BigDecimal("0") }) do |row, totals|
78
+ _id, tracked_at, total_cost = row
79
+ next unless total_cost
80
+
81
+ ActiveRecordPeriods::PERIODS.each_key do |period|
82
+ totals[[period, ActiveRecordPeriods.bucket(period, tracked_at)]] += BigDecimal(total_cost.to_s)
83
+ end
84
+ end
85
+ end
86
+
87
+ def apply_decrements(totals)
88
+ model = period_total_model
89
+ now = Time.now.utc
90
+
91
+ totals.each do |(period, period_start), amount|
92
+ row = model.lock.find_by(period: ActiveRecordPeriods::PERIODS.fetch(period), period_start: period_start)
93
+ next unless row
94
+
95
+ row.update_columns(total_cost: decremented_total(row.total_cost, amount), updated_at: now)
96
+ end
97
+ end
98
+
99
+ def decremented_total(current, amount) = [BigDecimal(current.to_s) - amount, BigDecimal("0")].max
100
+
60
101
  def rollup_period_totals(periods, time)
61
- buckets = periods.to_h { |period| [period, bucket_for(period, time)] }
62
- index = buckets.to_h { |period, bucket| [[PERIODS.fetch(period), bucket], period] }
102
+ buckets = periods.to_h { |period| [period, ActiveRecordPeriods.bucket(period, time)] }
103
+ index = buckets.to_h { |period, bucket| [[ActiveRecordPeriods::PERIODS.fetch(period), bucket], period] }
63
104
  totals = periods.to_h { |period| [period, 0.0] }
64
105
 
65
106
  period_total_model
66
- .where(period: periods.map { |period| PERIODS.fetch(period) }, period_start: buckets.values)
107
+ .where(period: periods.map { |period| ActiveRecordPeriods::PERIODS.fetch(period) },
108
+ period_start: buckets.values)
67
109
  .pluck(:period, :period_start, :total_cost)
68
110
  .each do |name, start, total|
69
111
  period = index[[name, start.to_date]]
@@ -75,7 +117,7 @@ module LlmCostTracker
75
117
 
76
118
  def fallback_period_total(period, time)
77
119
  LlmCostTracker::LlmApiCall
78
- .where(tracked_at: range_start_for(period, time)..time)
120
+ .where(tracked_at: ActiveRecordPeriods.range_start(period, time)..time)
79
121
  .sum(:total_cost)
80
122
  .to_f
81
123
  end
@@ -93,49 +135,11 @@ module LlmCostTracker
93
135
  LlmCostTracker::PeriodTotal
94
136
  end
95
137
 
96
- def range_start_for(period, time)
97
- utc_time = time.to_time.utc
98
-
99
- case period
100
- when :monthly then utc_time.beginning_of_month
101
- when :daily then utc_time.beginning_of_day
102
- end
103
- end
104
-
105
- def bucket_for(period, time)
106
- utc_time = time.to_time.utc
107
-
108
- case period
109
- when :monthly then utc_time.beginning_of_month.to_date
110
- when :daily then utc_time.to_date
111
- end
112
- end
113
-
114
138
  def unique_by(model, column)
115
139
  return unless model.connection.supports_insert_conflict_target?
116
140
 
117
141
  column
118
142
  end
119
-
120
- def total_upsert_sql(model)
121
- Arel.sql(case model.connection.adapter_name
122
- when /mysql/i
123
- mysql_upsert_sql(model)
124
- else
125
- "total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at"
126
- end)
127
- end
128
-
129
- def mysql_upsert_sql(model)
130
- connection = model.connection
131
- if connection.respond_to?(:supports_insert_raw_alias_syntax?, true) &&
132
- connection.send(:supports_insert_raw_alias_syntax?)
133
- values_reference = connection.quote_table_name("#{model.table_name}_values")
134
- "total_cost = total_cost + #{values_reference}.total_cost, updated_at = #{values_reference}.updated_at"
135
- else
136
- "total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
137
- end
138
- end
139
143
  end
140
144
  end
141
145
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "active_record_inbox"
4
+ require_relative "active_record_period_totals"
3
5
  require_relative "active_record_rollups"
4
6
 
5
7
  module LlmCostTracker
@@ -11,8 +13,33 @@ module LlmCostTracker
11
13
  end
12
14
 
13
15
  def save(event)
14
- tags = stringify_tags(event.tags || {})
15
16
  model = LlmCostTracker::LlmApiCall
17
+ attributes = attributes_for(event, model)
18
+
19
+ model.transaction do
20
+ call = model.create!(attributes)
21
+ ActiveRecordRollups.increment!(event)
22
+ call
23
+ end
24
+ end
25
+
26
+ def insert_many(events)
27
+ events = Array(events)
28
+ return [] if events.empty?
29
+
30
+ model = LlmCostTracker::LlmApiCall
31
+ insertable = new_events(model, events)
32
+
33
+ if insertable.any?
34
+ rows = insertable.map { |event| attributes_for(event, model) }
35
+ model.insert_all!(rows, **insert_options)
36
+ ActiveRecordRollups.increment_many!(insertable)
37
+ end
38
+ events
39
+ end
40
+
41
+ def attributes_for(event, model = LlmCostTracker::LlmApiCall)
42
+ tags = stringify_tags(event.tags || {})
16
43
  columns = model.columns_hash
17
44
 
18
45
  attributes = {
@@ -27,6 +54,7 @@ module LlmCostTracker
27
54
  tags: tags_for_storage(tags, model),
28
55
  tracked_at: event.tracked_at
29
56
  }
57
+ attributes[:event_id] = event.event_id if columns.key?("event_id")
30
58
  optional_attributes(event).each do |name, value|
31
59
  attributes[name] = value if columns.key?(name.to_s)
32
60
  end
@@ -35,27 +63,58 @@ module LlmCostTracker
35
63
  attributes[:usage_source] = event.usage_source if columns.key?("usage_source")
36
64
  attributes[:provider_response_id] = event.provider_response_id if columns.key?("provider_response_id")
37
65
 
38
- model.transaction do
39
- call = model.create!(attributes)
40
- ActiveRecordRollups.increment!(event)
41
- call
42
- end
66
+ attributes
43
67
  end
44
68
 
45
69
  def monthly_total(time: Time.now.utc)
46
- ActiveRecordRollups.monthly_total(time: time)
70
+ period_totals(%i[monthly], time: time).fetch(:monthly)
47
71
  end
48
72
 
49
73
  def daily_total(time: Time.now.utc)
50
- ActiveRecordRollups.daily_total(time: time)
74
+ period_totals(%i[daily], time: time).fetch(:daily)
51
75
  end
52
76
 
53
77
  def period_totals(periods, time: Time.now.utc)
54
- ActiveRecordRollups.period_totals(periods, time: time)
78
+ ActiveRecordPeriodTotals.call(periods, time: time)
79
+ end
80
+
81
+ def prune(cutoff:, batch_size:)
82
+ deleted = 0
83
+ loop do
84
+ batch = prune_batch(cutoff, batch_size)
85
+ deleted += batch
86
+ break if batch < batch_size
87
+ end
88
+ deleted
55
89
  end
56
90
 
57
91
  private
58
92
 
93
+ def new_events(model, events)
94
+ return events unless model.columns_hash.key?("event_id")
95
+
96
+ existing_ids = model.where(event_id: events.map(&:event_id)).pluck(:event_id).to_set
97
+ events.reject { |event| existing_ids.include?(event.event_id) }
98
+ end
99
+
100
+ def insert_options = { record_timestamps: true, returning: false }
101
+
102
+ def prune_batch(cutoff, batch_size)
103
+ LlmCostTracker::LlmApiCall.transaction do
104
+ rows = LlmCostTracker::LlmApiCall
105
+ .where(tracked_at: ...cutoff)
106
+ .order(:id)
107
+ .limit(batch_size)
108
+ .lock
109
+ .pluck(:id, :tracked_at, :total_cost)
110
+ next 0 if rows.empty?
111
+
112
+ deleted = LlmCostTracker::LlmApiCall.where(id: rows.map(&:first)).delete_all
113
+ ActiveRecordRollups.decrement!(rows) if deleted.positive?
114
+ deleted
115
+ end
116
+ end
117
+
59
118
  def stringify_tags(tags)
60
119
  tags.transform_keys(&:to_s).transform_values { |value| stringify_tag_value(value) }
61
120
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+
5
+ module LlmCostTracker
6
+ module Storage
7
+ class CustomBackend
8
+ class << self
9
+ def save(event)
10
+ result = LlmCostTracker.configuration.custom_storage&.call(event)
11
+ result == false ? false : event
12
+ end
13
+
14
+ def verify
15
+ if LlmCostTracker.configuration.custom_storage.respond_to?(:call)
16
+ return [
17
+ VerificationResult.new(
18
+ :ok,
19
+ "storage",
20
+ "custom storage callable configured; external sink was not invoked"
21
+ )
22
+ ]
23
+ end
24
+
25
+ [
26
+ VerificationResult.new(:error, "storage", "custom storage backend requires config.custom_storage")
27
+ ]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,18 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../logging"
4
+ require_relative "registry"
5
+ require_relative "active_record_backend"
6
+ require_relative "custom_backend"
7
+ require_relative "log_backend"
4
8
 
5
9
  module LlmCostTracker
6
10
  module Storage
7
11
  class Dispatcher
8
12
  class << self
9
13
  def save(event)
10
- config = LlmCostTracker.configuration
11
- case config.storage_backend
12
- when :log then log_event(event, config)
13
- when :active_record then active_record_save(event)
14
- when :custom then custom_save(event, config)
15
- end
14
+ backend.save(event)
16
15
  rescue LlmCostTracker::BudgetExceededError, LlmCostTracker::UnknownPricingError
17
16
  raise
18
17
  rescue StandardError => e
@@ -22,34 +21,8 @@ module LlmCostTracker
22
21
 
23
22
  private
24
23
 
25
- def log_event(event, config)
26
- message = "#{event.provider}/#{event.model} " \
27
- "tokens=#{event.total_tokens} " \
28
- "cost=#{log_cost_label(event)}"
29
- message += " latency=#{event.latency_ms}ms" if event.latency_ms
30
- message += " stream=#{event.stream}" if event.stream
31
- message += " source=#{event.usage_source}" if event.usage_source
32
- message += " tags=#{event.tags}" unless event.tags.empty?
33
-
34
- Logging.log(config.log_level, message)
35
- event
36
- end
37
-
38
- def log_cost_label(event) = event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
39
-
40
- def active_record_save(event)
41
- require_relative "../llm_api_call" unless defined?(LlmCostTracker::LlmApiCall)
42
- require_relative "active_record_store" unless defined?(LlmCostTracker::Storage::ActiveRecordStore)
43
-
44
- ActiveRecordStore.save(event)
45
- event
46
- rescue LoadError => e
47
- raise Error, "ActiveRecord storage requires the active_record gem: #{e.message}"
48
- end
49
-
50
- def custom_save(event, config)
51
- result = config.custom_storage&.call(event)
52
- result == false ? false : event
24
+ def backend
25
+ Registry.fetch(LlmCostTracker.configuration.storage_backend)
53
26
  end
54
27
 
55
28
  def handle_error(error)
@@ -64,5 +37,9 @@ module LlmCostTracker
64
37
  end
65
38
  end
66
39
  end
40
+
41
+ Registry.register(:log, LogBackend)
42
+ Registry.register(:active_record, ActiveRecordBackend)
43
+ Registry.register(:custom, CustomBackend)
67
44
  end
68
45
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../logging"
4
+ require_relative "registry"
5
+
6
+ module LlmCostTracker
7
+ module Storage
8
+ class LogBackend
9
+ class << self
10
+ def save(event)
11
+ config = LlmCostTracker.configuration
12
+ message = "#{event.provider}/#{event.model} " \
13
+ "tokens=#{event.total_tokens} " \
14
+ "cost=#{cost_label(event)}"
15
+ message += " latency=#{event.latency_ms}ms" if event.latency_ms
16
+ message += " stream=#{event.stream}" if event.stream
17
+ message += " source=#{event.usage_source}" if event.usage_source
18
+ message += " tags=#{event.tags}" unless event.tags.empty?
19
+
20
+ Logging.log(config.log_level, message)
21
+ event
22
+ end
23
+
24
+ def verify
25
+ [
26
+ VerificationResult.new(:ok, "storage", "log backend configured; capture writes to logs only")
27
+ ]
28
+ end
29
+
30
+ private
31
+
32
+ def cost_label(event)
33
+ event.cost ? "$#{format('%.6f', event.cost.total_cost)}" : "unknown"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ require_relative "../errors"
6
+
7
+ module LlmCostTracker
8
+ module Storage
9
+ VerificationResult = Data.define(:status, :name, :message)
10
+
11
+ module Registry
12
+ MUTEX = Monitor.new
13
+
14
+ class << self
15
+ def register(name, backend)
16
+ name = normalize_name(name)
17
+ validate_backend!(backend)
18
+ MUTEX.synchronize { @backends = backends.merge(name => backend).freeze }
19
+ backend
20
+ end
21
+
22
+ def fetch(name)
23
+ key = normalize_name(name)
24
+ backends.fetch(key) do
25
+ raise Error, "Unknown storage_backend: #{key.inspect}. Use one of: #{names.join(', ')}"
26
+ end
27
+ end
28
+
29
+ def registered?(name)
30
+ backends.key?(normalize_name(name))
31
+ end
32
+
33
+ def names
34
+ backends.keys
35
+ end
36
+
37
+ private
38
+
39
+ def backends
40
+ @backends || MUTEX.synchronize { @backends ||= {}.freeze }
41
+ end
42
+
43
+ def normalize_name(name)
44
+ name.to_sym
45
+ end
46
+
47
+ def validate_backend!(backend)
48
+ return if backend.respond_to?(:save)
49
+
50
+ raise ArgumentError, "storage backend must respond to save"
51
+ end
52
+ end
53
+ end
54
+
55
+ def self.register(name, backend)
56
+ Registry.register(name, backend)
57
+ end
58
+
59
+ def self.backends
60
+ Registry.names
61
+ end
62
+ end
63
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "monitor"
5
4
 
6
5
  require_relative "stream_capture"
@@ -164,10 +163,9 @@ module LlmCostTracker
164
163
  end
165
164
 
166
165
  def capture_event(data, type:)
167
- copied = ValueHelpers.deep_dup(data)
168
- size = event_bytes(copied, type)
166
+ size = event_bytes(data, type)
169
167
  if @captured_bytes + size <= StreamCapture::LIMIT_BYTES
170
- @events << { event: type, data: copied }
168
+ @events << { event: type, data: ValueHelpers.deep_dup(data) }
171
169
  @captured_bytes += size
172
170
  else
173
171
  @overflowed = true
@@ -176,9 +174,22 @@ module LlmCostTracker
176
174
  end
177
175
 
178
176
  def event_bytes(data, type)
179
- JSON.generate(event: type, data: data).bytesize
180
- rescue JSON::GeneratorError, TypeError
181
- type.to_s.bytesize + data.to_s.bytesize
177
+ type.to_s.bytesize + estimated_bytes(data) + 32
178
+ end
179
+
180
+ def estimated_bytes(value)
181
+ case value
182
+ when Hash
183
+ value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
184
+ when Array
185
+ value.sum { |nested| estimated_bytes(nested) + 2 }
186
+ when String
187
+ value.bytesize + 2
188
+ when Numeric, true, false, nil
189
+ value.to_s.bytesize
190
+ else
191
+ value.to_s.bytesize + 2
192
+ end
182
193
  end
183
194
 
184
195
  def error_metadata(errored) = errored ? { stream_errored: true } : {}
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "active_record_adapter"
4
+ require_relative "tag_key"
5
+
6
+ module LlmCostTracker
7
+ module TagSql
8
+ class << self
9
+ def value_expression(model, key, table_name:)
10
+ key = TagKey.validate!(key)
11
+ column = "#{table_name}.#{model.connection.quote_column_name('tags')}"
12
+
13
+ if ActiveRecordAdapter.postgresql?(model.connection)
14
+ json_column = model.tags_jsonb_column? ? column : "(#{column})::jsonb"
15
+ "#{json_column}->>#{model.connection.quote(key)}"
16
+ elsif ActiveRecordAdapter.mysql?(model.connection)
17
+ "JSON_UNQUOTE(JSON_EXTRACT(#{column}, #{model.connection.quote(json_path(key))}))"
18
+ else
19
+ "json_extract(#{column}, #{model.connection.quote(json_path(key))})"
20
+ end
21
+ end
22
+
23
+ def value_label(value)
24
+ value.nil? || value == "" ? "(untagged)" : value.to_s
25
+ end
26
+
27
+ private
28
+
29
+ def json_path(key)
30
+ "$.\"#{key}\""
31
+ end
32
+ end
33
+ end
34
+ 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 TagsColumn
5
7
  USAGE_BREAKDOWN_COLUMNS = %w[
@@ -79,7 +81,11 @@ module LlmCostTracker
79
81
  def build_lct_schema_capabilities(columns, adapter_name)
80
82
  tag_column = columns["tags"]
81
83
  tags_jsonb = tag_column && (tag_column.type == :jsonb || tag_column.sql_type.to_s.downcase == "jsonb")
82
- tags_mysql_json = tag_column && !tags_jsonb && tag_column.type == :json && adapter_name.match?(/mysql/i)
84
+ tags_mysql_json =
85
+ tag_column &&
86
+ !tags_jsonb &&
87
+ tag_column.type == :json &&
88
+ ActiveRecordAdapter.mysql?(adapter_name)
83
89
 
84
90
  {
85
91
  tags_jsonb: tags_jsonb ? true : false,
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  require_relative "storage/dispatcher"
4
6
 
5
7
  module LlmCostTracker
@@ -74,6 +76,7 @@ module LlmCostTracker
74
76
  def build_event(provider:, model:, usage:, cost_data:, metadata:, latency_ms:, stream:, usage_source:,
75
77
  provider_response_id:)
76
78
  Event.new(
79
+ event_id: SecureRandom.uuid,
77
80
  provider: provider,
78
81
  model: model,
79
82
  input_tokens: usage[:input_tokens],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmCostTracker
4
- VERSION = "0.5.2"
4
+ VERSION = "0.6.0"
5
5
  end