llm_cost_tracker 0.6.1 → 0.7.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 (180) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +13 -12
  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 -37
  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/config/routes.rb +1 -1
  43. data/lib/llm_cost_tracker/assets.rb +0 -6
  44. data/lib/llm_cost_tracker/budget.rb +10 -24
  45. data/lib/llm_cost_tracker/capture/stream.rb +9 -0
  46. data/lib/llm_cost_tracker/capture/stream_collector.rb +182 -0
  47. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  48. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  49. data/lib/llm_cost_tracker/configuration.rb +30 -45
  50. data/lib/llm_cost_tracker/doctor/capture_verifier.rb +61 -0
  51. data/lib/llm_cost_tracker/doctor/check.rb +7 -0
  52. data/lib/llm_cost_tracker/doctor/ingestion_check.rb +22 -61
  53. data/lib/llm_cost_tracker/doctor/price_check.rb +60 -0
  54. data/lib/llm_cost_tracker/doctor.rb +66 -79
  55. data/lib/llm_cost_tracker/engine.rb +0 -3
  56. data/lib/llm_cost_tracker/errors.rb +4 -15
  57. data/lib/llm_cost_tracker/event.rb +6 -6
  58. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_token_usage_generator.rb +42 -0
  59. data/lib/llm_cost_tracker/generators/llm_cost_tracker/install_generator.rb +2 -0
  60. data/lib/llm_cost_tracker/generators/llm_cost_tracker/prices_generator.rb +7 -7
  61. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +5 -5
  62. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_token_usage_to_llm_api_calls.rb.erb +22 -0
  63. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +15 -14
  64. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -21
  65. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_cost_precision.rb.erb +12 -1
  66. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +2 -2
  67. data/lib/llm_cost_tracker/{storage/active_record_inbox_batch.rb → ingestion/batch.rb} +21 -20
  68. data/lib/llm_cost_tracker/ingestion/inbox.rb +105 -0
  69. data/lib/llm_cost_tracker/{storage/active_record_ingestor_lease.rb → ingestion/lease_claim.rb} +5 -7
  70. data/lib/llm_cost_tracker/{storage/active_record_ingestor.rb → ingestion/worker.rb} +38 -48
  71. data/lib/llm_cost_tracker/ingestion.rb +129 -0
  72. data/lib/llm_cost_tracker/integrations/anthropic.rb +52 -34
  73. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  74. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  75. data/lib/llm_cost_tracker/integrations/ruby_llm.rb +40 -30
  76. data/lib/llm_cost_tracker/integrations.rb +43 -0
  77. data/lib/llm_cost_tracker/ledger/period/totals.rb +66 -0
  78. data/lib/llm_cost_tracker/{storage/active_record_periods.rb → ledger/period.rb} +2 -2
  79. data/lib/llm_cost_tracker/ledger/rollups/batch.rb +43 -0
  80. data/lib/llm_cost_tracker/ledger/rollups/upsert_sql.rb +46 -0
  81. data/lib/llm_cost_tracker/ledger/rollups.rb +87 -0
  82. data/lib/llm_cost_tracker/ledger/schema/adapter.rb +51 -0
  83. data/lib/llm_cost_tracker/ledger/schema/calls.rb +101 -0
  84. data/lib/llm_cost_tracker/ledger/schema/period_totals.rb +32 -0
  85. data/lib/llm_cost_tracker/ledger/store.rb +60 -0
  86. data/lib/llm_cost_tracker/ledger/tags/query.rb +29 -0
  87. data/lib/llm_cost_tracker/ledger/tags/sql.rb +33 -0
  88. data/lib/llm_cost_tracker/ledger.rb +13 -0
  89. data/lib/llm_cost_tracker/logging.rb +3 -6
  90. data/lib/llm_cost_tracker/middleware/faraday.rb +35 -36
  91. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  92. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  93. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  94. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  95. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  96. data/lib/llm_cost_tracker/parsers.rb +20 -0
  97. data/lib/llm_cost_tracker/prices.json +52 -11
  98. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  99. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  100. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  101. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  102. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  103. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  104. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  105. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  106. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  107. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  108. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  109. data/lib/llm_cost_tracker/pricing.rb +33 -32
  110. data/lib/llm_cost_tracker/railtie.rb +7 -10
  111. data/lib/llm_cost_tracker/report/data.rb +72 -0
  112. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  113. data/lib/llm_cost_tracker/report.rb +8 -10
  114. data/lib/llm_cost_tracker/retention.rb +27 -10
  115. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  116. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  117. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  118. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  119. data/lib/llm_cost_tracker/tracker.rb +38 -70
  120. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  121. data/lib/llm_cost_tracker/version.rb +1 -1
  122. data/lib/llm_cost_tracker.rb +56 -90
  123. data/lib/tasks/llm_cost_tracker.rake +18 -13
  124. metadata +85 -99
  125. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  126. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  127. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -49
  128. data/lib/llm_cost_tracker/capture_verifier.rb +0 -71
  129. data/lib/llm_cost_tracker/configuration/storage_backend.rb +0 -26
  130. data/lib/llm_cost_tracker/cost.rb +0 -12
  131. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  132. data/lib/llm_cost_tracker/engine_compatibility.rb +0 -15
  133. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  134. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  135. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  136. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  137. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  138. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  139. data/lib/llm_cost_tracker/integrations/registry.rb +0 -73
  140. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  141. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  142. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  143. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  144. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  145. data/lib/llm_cost_tracker/period_grouping.rb +0 -69
  146. data/lib/llm_cost_tracker/period_total.rb +0 -9
  147. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  148. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  149. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  150. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  151. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  152. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  153. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  154. data/lib/llm_cost_tracker/report_data.rb +0 -94
  155. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  156. data/lib/llm_cost_tracker/request_url.rb +0 -20
  157. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -166
  158. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  159. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -165
  160. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  161. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  162. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  163. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  164. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  165. data/lib/llm_cost_tracker/storage/custom_backend.rb +0 -32
  166. data/lib/llm_cost_tracker/storage/dispatcher.rb +0 -45
  167. data/lib/llm_cost_tracker/storage/log_backend.rb +0 -38
  168. data/lib/llm_cost_tracker/storage/registry.rb +0 -63
  169. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  170. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  171. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  172. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  173. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  174. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  175. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  176. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  177. data/lib/llm_cost_tracker/tags_column.rb +0 -103
  178. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  179. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  180. data/lib/llm_cost_tracker/value_helpers.rb +0 -40
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/object/deep_dup"
5
+
6
+ require_relative "stream"
7
+
8
+ module LlmCostTracker
9
+ module Capture
10
+ class StreamCollector
11
+ attr_reader :provider
12
+
13
+ def initialize(provider:, model:, latency_ms: nil, provider_response_id: nil, pricing_mode: nil, metadata: {})
14
+ @provider = provider.to_s
15
+ @model = model
16
+ @latency_ms = latency_ms
17
+ @provider_response_id = provider_response_id
18
+ @pricing_mode = pricing_mode
19
+ @metadata = (metadata || {}).deep_dup
20
+ @events = []
21
+ @captured_bytes = 0
22
+ @overflowed = false
23
+ @explicit_usage = nil
24
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ @finished = false
26
+ @mutex = Mutex.new
27
+ end
28
+
29
+ def model
30
+ @mutex.synchronize { @model }
31
+ end
32
+
33
+ def metadata
34
+ @mutex.synchronize { @metadata.deep_dup }
35
+ end
36
+
37
+ def provider_response_id
38
+ @mutex.synchronize { @provider_response_id }
39
+ end
40
+
41
+ def model=(value)
42
+ @mutex.synchronize do
43
+ ensure_open!
44
+ @model = value
45
+ end
46
+ end
47
+
48
+ def provider_response_id=(value)
49
+ @mutex.synchronize do
50
+ ensure_open!
51
+ @provider_response_id = value
52
+ end
53
+ end
54
+
55
+ def event(data, type: nil)
56
+ @mutex.synchronize do
57
+ ensure_open!
58
+ capture_event(data, type: type) unless data.nil?
59
+ end
60
+ self
61
+ end
62
+
63
+ def usage(input_tokens:, output_tokens:, **extra)
64
+ @mutex.synchronize do
65
+ ensure_open!
66
+ @provider_response_id = extra.delete(:provider_response_id) || @provider_response_id
67
+ @explicit_usage = TokenUsage.from_hash(extra.merge(
68
+ input_tokens: input_tokens.to_i,
69
+ output_tokens: output_tokens.to_i
70
+ ))
71
+ end
72
+ self
73
+ end
74
+
75
+ def finish!(errored: false)
76
+ snapshot = @mutex.synchronize do
77
+ return if @finished
78
+
79
+ @finished = true
80
+ {
81
+ events: @events.dup,
82
+ overflowed: @overflowed,
83
+ explicit_usage: @explicit_usage,
84
+ model: @model,
85
+ latency_ms: @latency_ms,
86
+ provider_response_id: @provider_response_id,
87
+ pricing_mode: @pricing_mode,
88
+ metadata: @metadata.deep_dup
89
+ }
90
+ end
91
+
92
+ capture = build_usage_capture(snapshot)
93
+ provider_response_id = capture.provider_response_id || snapshot[:provider_response_id]
94
+ capture = capture.with(provider_response_id: provider_response_id)
95
+
96
+ Tracker.record(
97
+ capture: capture,
98
+ latency_ms: snapshot[:latency_ms] ||
99
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at) * 1000).round,
100
+ pricing_mode: snapshot[:pricing_mode],
101
+ metadata: (errored ? { stream_errored: true } : {}).merge(snapshot[:metadata])
102
+ )
103
+ end
104
+
105
+ private
106
+
107
+ def ensure_open!
108
+ return unless @finished
109
+
110
+ raise FrozenError, "can't modify finished LlmCostTracker::Capture::StreamCollector"
111
+ end
112
+
113
+ def build_usage_capture(snapshot)
114
+ return build_from_explicit_usage(snapshot) if snapshot[:explicit_usage]
115
+ return build_unknown_usage(snapshot) if snapshot[:overflowed]
116
+
117
+ capture = Parsers.find_for_provider(@provider)&.parse_stream(nil, nil, 200, snapshot[:events])
118
+ if capture
119
+ model = present_model(capture.model) || present_model(snapshot[:model]) || UsageCapture::UNKNOWN_MODEL
120
+ return capture.with(provider: @provider, model: model)
121
+ end
122
+
123
+ build_unknown_usage(snapshot)
124
+ end
125
+
126
+ def present_model(value)
127
+ return nil if value.nil?
128
+
129
+ string = value.to_s.presence
130
+ return nil if string.nil? || string == "unknown"
131
+
132
+ string
133
+ end
134
+
135
+ def build_from_explicit_usage(snapshot)
136
+ UsageCapture.build(
137
+ provider: @provider,
138
+ model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
139
+ token_usage: snapshot[:explicit_usage],
140
+ stream: true,
141
+ usage_source: :manual
142
+ )
143
+ end
144
+
145
+ def build_unknown_usage(snapshot)
146
+ UsageCapture.build(
147
+ provider: @provider,
148
+ model: snapshot[:model] || UsageCapture::UNKNOWN_MODEL,
149
+ token_usage: TokenUsage.build(input_tokens: 0, output_tokens: 0, total_tokens: 0),
150
+ stream: true,
151
+ usage_source: :unknown
152
+ )
153
+ end
154
+
155
+ def capture_event(data, type:)
156
+ size = type.to_s.bytesize + estimated_bytes(data) + 32
157
+ if @captured_bytes + size <= Capture::Stream::LIMIT_BYTES
158
+ @events << { event: type, data: data.deep_dup }
159
+ @captured_bytes += size
160
+ else
161
+ @overflowed = true
162
+ @events.clear
163
+ end
164
+ end
165
+
166
+ def estimated_bytes(value)
167
+ case value
168
+ when Hash
169
+ value.sum { |key, nested| estimated_bytes(key) + estimated_bytes(nested) + 4 }
170
+ when Array
171
+ value.sum { |nested| estimated_bytes(nested) + 2 }
172
+ when String
173
+ value.bytesize + 2
174
+ when Numeric, true, false, nil
175
+ value.to_s.bytesize
176
+ else
177
+ value.to_s.bytesize + 2
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -1,17 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "monitor"
3
+ require "active_support/core_ext/object/deep_dup"
4
+ require "active_support/core_ext/object/try"
4
5
 
5
6
  require_relative "../logging"
6
- require_relative "../stream_collector"
7
- require_relative "../value_helpers"
8
- require_relative "object_reader"
9
7
 
10
8
  module LlmCostTracker
11
- module Integrations
9
+ module Capture
12
10
  class StreamTracker
13
- def self.wrap(stream, collector:, active:, finish: nil) = new(stream, collector, active, finish).wrap
14
-
15
11
  def initialize(stream, collector, active, finish)
16
12
  @stream = stream
17
13
  @collector = collector
@@ -19,13 +15,22 @@ module LlmCostTracker
19
15
  @finish = finish || proc { |errored:| @collector.finish!(errored: errored) }
20
16
  @finished = false
21
17
  @capture_failed = false
22
- @monitor = Monitor.new
18
+ @mutex = Mutex.new
23
19
  end
24
20
 
25
21
  def wrap
26
22
  return @stream unless @stream
27
23
 
28
- iterator_wrapped = @stream.instance_variable_defined?(:@iterator) && wrap_iterator?
24
+ iterator_wrapped = false
25
+ if @stream.instance_variable_defined?(:@iterator)
26
+ iterator = @stream.instance_variable_get(:@iterator)
27
+ if iterator.respond_to?(:each)
28
+ @stream.instance_variable_set(:@iterator, Enumerator.new do |yielder|
29
+ each_from(iterator) { |event| yielder << event }
30
+ end)
31
+ iterator_wrapped = true
32
+ end
33
+ end
29
34
  wrap_each if !iterator_wrapped && @stream.respond_to?(:each)
30
35
 
31
36
  @stream
@@ -36,14 +41,6 @@ module LlmCostTracker
36
41
 
37
42
  private
38
43
 
39
- def wrap_iterator?
40
- iterator = @stream.instance_variable_get(:@iterator)
41
- return false unless iterator.respond_to?(:each)
42
-
43
- @stream.instance_variable_set(:@iterator, tracked_iterator(iterator))
44
- true
45
- end
46
-
47
44
  def wrap_each
48
45
  tracker = self
49
46
  original_each = @stream.method(:each)
@@ -54,17 +51,18 @@ module LlmCostTracker
54
51
  end
55
52
  end
56
53
 
57
- def tracked_iterator(iterator)
58
- Enumerator.new do |yielder|
59
- each_from(iterator) { |event| yielder << event }
60
- end
61
- end
62
-
63
54
  def each_from(iterable)
64
55
  errored = false
65
- iterate(iterable) do |event|
66
- capture(event)
67
- yield event
56
+ if iterable.respond_to?(:each)
57
+ iterable.each do |event|
58
+ capture(event)
59
+ yield event
60
+ end
61
+ else
62
+ iterable.call do |event|
63
+ capture(event)
64
+ yield event
65
+ end
68
66
  end
69
67
  rescue StandardError
70
68
  errored = true
@@ -73,41 +71,17 @@ module LlmCostTracker
73
71
  finish!(errored: errored)
74
72
  end
75
73
 
76
- def iterate(iterable, &)
77
- if iterable.respond_to?(:each)
78
- iterable.each(&)
79
- else
80
- iterable.call(&)
81
- end
82
- end
83
-
84
74
  def capture(event)
85
- payload = normalize(event_payload(event))
86
- @collector.event(payload, type: event_type(event, payload))
87
- rescue StandardError => e
88
- warn_capture_failure(e)
89
- end
90
-
91
- def event_payload(event)
92
- if event.respond_to?(:deep_to_h)
93
- event.deep_to_h
94
- elsif event.respond_to?(:to_h)
95
- event.to_h
96
- else
97
- event_attributes(event)
98
- end
99
- end
100
-
101
- def event_attributes(event)
102
- %i[type id model usage response message].each_with_object({}) do |key, attributes|
103
- value = ObjectReader.read(event, key)
75
+ raw_payload = event.try(:deep_to_h) || event.try(:to_h)
76
+ raw_payload ||= %i[type id model usage response message].each_with_object({}) do |key, attributes|
77
+ value = event.try(key)
104
78
  attributes[key] = value unless value.nil?
105
79
  end
106
- end
107
-
108
- def event_type(event, payload)
109
- value = ObjectReader.first(event, :type) || payload["type"]
110
- value&.to_s
80
+ payload = normalize(raw_payload)
81
+ type = event.try(:type) || payload["type"]
82
+ @collector.event(payload, type: type&.to_s)
83
+ rescue StandardError => e
84
+ warn_capture_failure(e)
111
85
  end
112
86
 
113
87
  def normalize(value)
@@ -123,23 +97,17 @@ module LlmCostTracker
123
97
  when NilClass
124
98
  nil
125
99
  else
126
- converted = object_hash(value)
127
- converted ? normalize(converted) : ValueHelpers.deep_dup(value)
128
- end
129
- end
130
-
131
- def object_hash(value)
132
- if value.respond_to?(:deep_to_h)
133
- value.deep_to_h
134
- elsif value.respond_to?(:to_h)
135
- value.to_h
100
+ converted = begin
101
+ value.try(:deep_to_h) || value.try(:to_h)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+ converted ? normalize(converted) : value.deep_dup
136
106
  end
137
- rescue StandardError
138
- nil
139
107
  end
140
108
 
141
109
  def warn_capture_failure(error)
142
- should_warn = @monitor.synchronize do
110
+ should_warn = @mutex.synchronize do
143
111
  next false if @capture_failed
144
112
 
145
113
  @capture_failed = true
@@ -151,7 +119,7 @@ module LlmCostTracker
151
119
  end
152
120
 
153
121
  def finish!(errored:)
154
- should_finish = @monitor.synchronize do
122
+ should_finish = @mutex.synchronize do
155
123
  next false if @finished
156
124
 
157
125
  @finished = true
@@ -16,7 +16,7 @@ module LlmCostTracker
16
16
  def normalize_instrumentation_names(names)
17
17
  names.flatten.flat_map do |name|
18
18
  key = name.to_sym
19
- next available_instrumentation_names if key == :all
19
+ next Integrations.names if key == :all
20
20
 
21
21
  validate_instrumentation_name!(key)
22
22
  key
@@ -24,14 +24,10 @@ module LlmCostTracker
24
24
  end
25
25
 
26
26
  def validate_instrumentation_name!(name)
27
- return if available_instrumentation_names.include?(name)
27
+ return if Integrations.names.include?(name)
28
28
 
29
29
  raise Error, "Unknown integration: #{name.inspect}. " \
30
- "Use one of: #{available_instrumentation_names.join(', ')}"
31
- end
32
-
33
- def available_instrumentation_names
34
- Integrations::Registry.names
30
+ "Use one of: #{Integrations.names.join(', ')}"
35
31
  end
36
32
  end
37
33
  end
@@ -1,27 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "errors"
4
- require_relative "tag_key"
5
- require_relative "value_helpers"
4
+ require_relative "tags/key"
6
5
  require_relative "configuration/instrumentation"
7
- require_relative "configuration/storage_backend"
8
6
 
9
7
  module LlmCostTracker
10
8
  class Configuration
11
9
  include ConfigurationInstrumentation
12
- include ConfigurationStorageBackend
13
10
 
14
11
  OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
15
12
 
16
13
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
17
- STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
18
- STORAGE_BACKENDS = %i[log active_record custom].freeze
19
14
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
20
- SHARED_SCALAR_ATTRIBUTES = %i[enabled custom_storage on_budget_exceeded monthly_budget daily_budget per_call_budget
21
- log_level prices_file max_tag_count max_tag_value_bytesize].freeze
15
+ SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
16
+ prices_file max_tag_count max_tag_value_bytesize].freeze
22
17
  SHARED_ENUM_ATTRIBUTES = {
23
18
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
24
- storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
25
19
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
26
20
  }.freeze
27
21
  DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
@@ -34,23 +28,18 @@ module LlmCostTracker
34
28
  :instrumented_integrations,
35
29
  :report_tag_breakdowns,
36
30
  :redacted_tag_keys,
37
- :storage_backend,
38
- :storage_error_behavior,
39
31
  :unknown_pricing_behavior,
40
32
  :openai_compatible_providers
41
33
  )
42
34
 
43
35
  def initialize
44
36
  @enabled = true
45
- self.storage_backend = :log
46
- @custom_storage = nil
47
37
  @default_tags = {}
48
38
  @on_budget_exceeded = nil
49
39
  @monthly_budget = nil
50
40
  @daily_budget = nil
51
41
  @per_call_budget = nil
52
42
  self.budget_exceeded_behavior = :notify
53
- self.storage_error_behavior = :warn
54
43
  self.unknown_pricing_behavior = :warn
55
44
  @log_level = :info
56
45
  @prices_file = nil
@@ -81,7 +70,7 @@ module LlmCostTracker
81
70
 
82
71
  def report_tag_breakdowns=(value)
83
72
  ensure_shared_configuration_mutable!
84
- @report_tag_breakdowns = normalize_report_tag_breakdowns(value)
73
+ @report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
85
74
  end
86
75
 
87
76
  def redacted_tag_keys=(value)
@@ -104,38 +93,20 @@ module LlmCostTracker
104
93
  end
105
94
 
106
95
  def finalize!
107
- @default_tags = ValueHelpers.deep_freeze(@default_tags || {})
108
- @pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
109
- @instrumented_integrations = ValueHelpers.deep_freeze(@instrumented_integrations || [])
110
- @report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
111
- @redacted_tag_keys = ValueHelpers.deep_freeze(Array(@redacted_tag_keys))
112
- @openai_compatible_providers = ValueHelpers.deep_freeze(@openai_compatible_providers || {})
96
+ @default_tags = deep_freeze(@default_tags || {})
97
+ @pricing_overrides = deep_freeze(@pricing_overrides || {})
98
+ @instrumented_integrations = deep_freeze(@instrumented_integrations || [])
99
+ @report_tag_breakdowns = deep_freeze(Array(@report_tag_breakdowns))
100
+ @redacted_tag_keys = deep_freeze(Array(@redacted_tag_keys))
101
+ @openai_compatible_providers = deep_freeze(@openai_compatible_providers || {})
113
102
  @finalized = true
114
103
  self
115
104
  end
116
105
 
117
- def finalized? = @finalized
118
-
119
- def dup_for_configuration
120
- copy = dup
121
- copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
122
- copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
123
- copy.instance_variable_set(
124
- :@instrumented_integrations,
125
- ValueHelpers.deep_dup(@instrumented_integrations || [])
126
- )
127
- copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
128
- copy.instance_variable_set(:@redacted_tag_keys, ValueHelpers.deep_dup(@redacted_tag_keys || []))
129
- copy.instance_variable_set(
130
- :@openai_compatible_providers,
131
- ValueHelpers.deep_dup(@openai_compatible_providers || {})
132
- )
133
- copy.instance_variable_set(:@finalized, false)
134
- copy
106
+ def finalized?
107
+ @finalized
135
108
  end
136
109
 
137
- def active_record? = storage_backend == :active_record
138
-
139
110
  private
140
111
 
141
112
  def normalize_enum(name, value, allowed, default:)
@@ -152,14 +123,28 @@ module LlmCostTracker
152
123
  end
153
124
  end
154
125
 
155
- def normalize_report_tag_breakdowns(value)
156
- Array(value).map { |key| TagKey.validate!(key, error_class: Error) }
157
- end
158
-
159
126
  def ensure_shared_configuration_mutable!
160
127
  return unless finalized?
161
128
 
162
129
  raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
163
130
  end
131
+
132
+ def deep_freeze(value)
133
+ case value
134
+ when Hash
135
+ value.each do |key, nested_value|
136
+ deep_freeze(key)
137
+ deep_freeze(nested_value)
138
+ end
139
+ value.frozen? ? value : value.freeze
140
+ when Array
141
+ value.each { |nested_value| deep_freeze(nested_value) }
142
+ value.frozen? ? value : value.freeze
143
+ when String
144
+ value.frozen? ? value : value.freeze
145
+ else
146
+ value
147
+ end
148
+ end
164
149
  end
165
150
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "check"
4
+ require_relative "../ingestion"
5
+
6
+ module LlmCostTracker
7
+ class Doctor
8
+ class CaptureVerifier
9
+ class << self
10
+ def call
11
+ new.checks
12
+ end
13
+
14
+ def report(checks = call)
15
+ (["LLM Cost Tracker capture verification"] + checks.map do |check|
16
+ "[#{check.status}] #{check.name}: #{check.message}"
17
+ end).join("\n")
18
+ end
19
+
20
+ def healthy?(checks = call)
21
+ checks.none? { |check| check.status == :error }
22
+ end
23
+ end
24
+
25
+ def checks
26
+ [
27
+ enabled_check,
28
+ *integration_checks,
29
+ *storage_checks
30
+ ].compact
31
+ end
32
+
33
+ private
34
+
35
+ def enabled_check
36
+ return Check.new(:ok, "tracking", "enabled") if LlmCostTracker.configuration.enabled
37
+
38
+ Check.new(:error, "tracking", "disabled; set config.enabled = true before verifying capture")
39
+ end
40
+
41
+ def integration_checks
42
+ enabled = LlmCostTracker.configuration.instrumented_integrations
43
+ if enabled.empty?
44
+ return [
45
+ Check.new(:ok, "sdk integrations", "none enabled; Faraday middleware and manual capture remain available")
46
+ ]
47
+ end
48
+
49
+ LlmCostTracker::Integrations.checks.map do |check|
50
+ Check.new(check.status, "sdk integration #{check.name}", check.message)
51
+ end
52
+ end
53
+
54
+ def storage_checks
55
+ LlmCostTracker::Ingestion.verify
56
+ rescue LlmCostTracker::Error => e
57
+ [Check.new(:error, "storage", e.message)]
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmCostTracker
4
+ class Doctor
5
+ Check = Data.define(:status, :name, :message)
6
+ end
7
+ end