llm_cost_tracker 0.7.0 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +16 -0
  3. data/README.md +11 -9
  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 +182 -0
  45. data/lib/llm_cost_tracker/{integrations → capture}/stream_tracker.rb +40 -72
  46. data/lib/llm_cost_tracker/configuration/instrumentation.rb +3 -7
  47. data/lib/llm_cost_tracker/configuration.rb +28 -35
  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 +52 -34
  70. data/lib/llm_cost_tracker/integrations/base.rb +73 -34
  71. data/lib/llm_cost_tracker/integrations/openai.rb +45 -39
  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 +35 -36
  88. data/lib/llm_cost_tracker/parsers/anthropic.rb +38 -27
  89. data/lib/llm_cost_tracker/parsers/base.rb +10 -19
  90. data/lib/llm_cost_tracker/parsers/gemini.rb +15 -16
  91. data/lib/llm_cost_tracker/parsers/openai_usage.rb +24 -19
  92. data/lib/llm_cost_tracker/parsers/sse.rb +4 -7
  93. data/lib/llm_cost_tracker/parsers.rb +20 -0
  94. data/lib/llm_cost_tracker/prices.json +52 -11
  95. data/lib/llm_cost_tracker/pricing/components.rb +37 -0
  96. data/lib/llm_cost_tracker/pricing/effective_prices.rb +40 -50
  97. data/lib/llm_cost_tracker/pricing/explainer.rb +12 -23
  98. data/lib/llm_cost_tracker/pricing/lookup.rb +24 -25
  99. data/lib/llm_cost_tracker/pricing/registry.rb +156 -0
  100. data/lib/llm_cost_tracker/pricing/sync/fetcher.rb +107 -0
  101. data/lib/llm_cost_tracker/pricing/sync/registry_diff.rb +53 -0
  102. data/lib/llm_cost_tracker/pricing/sync/registry_loader.rb +63 -0
  103. data/lib/llm_cost_tracker/pricing/sync/registry_writer.rb +31 -0
  104. data/lib/llm_cost_tracker/pricing/sync.rb +143 -0
  105. data/lib/llm_cost_tracker/pricing/unknown.rb +46 -0
  106. data/lib/llm_cost_tracker/pricing.rb +33 -32
  107. data/lib/llm_cost_tracker/railtie.rb +7 -8
  108. data/lib/llm_cost_tracker/report/data.rb +72 -0
  109. data/lib/llm_cost_tracker/report/formatter.rb +69 -0
  110. data/lib/llm_cost_tracker/report.rb +8 -8
  111. data/lib/llm_cost_tracker/retention.rb +27 -10
  112. data/lib/llm_cost_tracker/tags/context.rb +35 -0
  113. data/lib/llm_cost_tracker/tags/key.rb +18 -0
  114. data/lib/llm_cost_tracker/tags/sanitizer.rb +68 -0
  115. data/lib/llm_cost_tracker/token_usage.rb +67 -0
  116. data/lib/llm_cost_tracker/tracker.rb +38 -70
  117. data/lib/llm_cost_tracker/usage_capture.rb +37 -0
  118. data/lib/llm_cost_tracker/version.rb +1 -1
  119. data/lib/llm_cost_tracker.rb +56 -78
  120. data/lib/tasks/llm_cost_tracker.rake +18 -13
  121. metadata +54 -58
  122. data/app/services/llm_cost_tracker/dashboard/data_quality_aggregate.rb +0 -81
  123. data/app/services/llm_cost_tracker/pagination.rb +0 -57
  124. data/lib/llm_cost_tracker/active_record_adapter.rb +0 -53
  125. data/lib/llm_cost_tracker/capture_verifier.rb +0 -64
  126. data/lib/llm_cost_tracker/cost.rb +0 -12
  127. data/lib/llm_cost_tracker/doctor/capture_check.rb +0 -39
  128. data/lib/llm_cost_tracker/event_metadata.rb +0 -52
  129. data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_usage_breakdown_generator.rb +0 -29
  130. data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_usage_breakdown_to_llm_api_calls.rb.erb +0 -29
  131. data/lib/llm_cost_tracker/inbox_event.rb +0 -9
  132. data/lib/llm_cost_tracker/ingestor_lease.rb +0 -9
  133. data/lib/llm_cost_tracker/integrations/object_reader.rb +0 -56
  134. data/lib/llm_cost_tracker/integrations/registry.rb +0 -71
  135. data/lib/llm_cost_tracker/llm_api_call.rb +0 -60
  136. data/lib/llm_cost_tracker/llm_api_call_metrics.rb +0 -63
  137. data/lib/llm_cost_tracker/parameter_hash.rb +0 -33
  138. data/lib/llm_cost_tracker/parsed_usage.rb +0 -72
  139. data/lib/llm_cost_tracker/parsers/registry.rb +0 -58
  140. data/lib/llm_cost_tracker/period_grouping.rb +0 -67
  141. data/lib/llm_cost_tracker/period_total.rb +0 -9
  142. data/lib/llm_cost_tracker/price_freshness.rb +0 -38
  143. data/lib/llm_cost_tracker/price_registry.rb +0 -144
  144. data/lib/llm_cost_tracker/price_sync/fetcher.rb +0 -104
  145. data/lib/llm_cost_tracker/price_sync/registry_diff.rb +0 -51
  146. data/lib/llm_cost_tracker/price_sync/registry_loader.rb +0 -61
  147. data/lib/llm_cost_tracker/price_sync/registry_writer.rb +0 -29
  148. data/lib/llm_cost_tracker/price_sync.rb +0 -144
  149. data/lib/llm_cost_tracker/report_data.rb +0 -94
  150. data/lib/llm_cost_tracker/report_formatter.rb +0 -67
  151. data/lib/llm_cost_tracker/request_url.rb +0 -20
  152. data/lib/llm_cost_tracker/storage/active_record_backend.rb +0 -167
  153. data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +0 -13
  154. data/lib/llm_cost_tracker/storage/active_record_inbox.rb +0 -160
  155. data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +0 -84
  156. data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +0 -41
  157. data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +0 -42
  158. data/lib/llm_cost_tracker/storage/active_record_rollups.rb +0 -146
  159. data/lib/llm_cost_tracker/storage/active_record_store.rb +0 -145
  160. data/lib/llm_cost_tracker/storage/writer.rb +0 -35
  161. data/lib/llm_cost_tracker/stream_capture.rb +0 -7
  162. data/lib/llm_cost_tracker/stream_collector.rb +0 -199
  163. data/lib/llm_cost_tracker/tag_accessors.rb +0 -15
  164. data/lib/llm_cost_tracker/tag_context.rb +0 -52
  165. data/lib/llm_cost_tracker/tag_key.rb +0 -16
  166. data/lib/llm_cost_tracker/tag_query.rb +0 -43
  167. data/lib/llm_cost_tracker/tag_sanitizer.rb +0 -81
  168. data/lib/llm_cost_tracker/tag_sql.rb +0 -34
  169. data/lib/llm_cost_tracker/tags_column.rb +0 -105
  170. data/lib/llm_cost_tracker/unknown_pricing.rb +0 -54
  171. data/lib/llm_cost_tracker/usage_breakdown.rb +0 -30
  172. 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,8 +1,7 @@
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
6
 
8
7
  module LlmCostTracker
@@ -12,13 +11,11 @@ module LlmCostTracker
12
11
  OPENAI_COMPATIBLE_PROVIDERS = { "openrouter.ai" => "openrouter", "api.deepseek.com" => "deepseek" }.freeze
13
12
 
14
13
  BUDGET_EXCEEDED_BEHAVIORS = %i[notify raise block_requests].freeze
15
- STORAGE_ERROR_BEHAVIORS = %i[ignore warn raise].freeze
16
14
  UNKNOWN_PRICING_BEHAVIORS = %i[ignore warn raise].freeze
17
15
  SHARED_SCALAR_ATTRIBUTES = %i[enabled on_budget_exceeded monthly_budget daily_budget per_call_budget log_level
18
16
  prices_file max_tag_count max_tag_value_bytesize].freeze
19
17
  SHARED_ENUM_ATTRIBUTES = {
20
18
  budget_exceeded_behavior: [BUDGET_EXCEEDED_BEHAVIORS, :notify],
21
- storage_error_behavior: [STORAGE_ERROR_BEHAVIORS, :warn],
22
19
  unknown_pricing_behavior: [UNKNOWN_PRICING_BEHAVIORS, :warn]
23
20
  }.freeze
24
21
  DEFAULT_REDACTED_TAG_KEYS = %w[api_key access_token authorization credential password refresh_token secret].freeze
@@ -31,7 +28,6 @@ module LlmCostTracker
31
28
  :instrumented_integrations,
32
29
  :report_tag_breakdowns,
33
30
  :redacted_tag_keys,
34
- :storage_error_behavior,
35
31
  :unknown_pricing_behavior,
36
32
  :openai_compatible_providers
37
33
  )
@@ -44,7 +40,6 @@ module LlmCostTracker
44
40
  @daily_budget = nil
45
41
  @per_call_budget = nil
46
42
  self.budget_exceeded_behavior = :notify
47
- self.storage_error_behavior = :warn
48
43
  self.unknown_pricing_behavior = :warn
49
44
  @log_level = :info
50
45
  @prices_file = nil
@@ -75,7 +70,7 @@ module LlmCostTracker
75
70
 
76
71
  def report_tag_breakdowns=(value)
77
72
  ensure_shared_configuration_mutable!
78
- @report_tag_breakdowns = normalize_report_tag_breakdowns(value)
73
+ @report_tag_breakdowns = Array(value).map { |key| Tags::Key.validate!(key, error_class: Error) }
79
74
  end
80
75
 
81
76
  def redacted_tag_keys=(value)
@@ -98,34 +93,18 @@ module LlmCostTracker
98
93
  end
99
94
 
100
95
  def finalize!
101
- @default_tags = ValueHelpers.deep_freeze(@default_tags || {})
102
- @pricing_overrides = ValueHelpers.deep_freeze(@pricing_overrides || {})
103
- @instrumented_integrations = ValueHelpers.deep_freeze(@instrumented_integrations || [])
104
- @report_tag_breakdowns = ValueHelpers.deep_freeze(Array(@report_tag_breakdowns))
105
- @redacted_tag_keys = ValueHelpers.deep_freeze(Array(@redacted_tag_keys))
106
- @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 || {})
107
102
  @finalized = true
108
103
  self
109
104
  end
110
105
 
111
- def finalized? = @finalized
112
-
113
- def dup_for_configuration
114
- copy = dup
115
- copy.instance_variable_set(:@default_tags, ValueHelpers.deep_dup(@default_tags || {}))
116
- copy.instance_variable_set(:@pricing_overrides, ValueHelpers.deep_dup(@pricing_overrides || {}))
117
- copy.instance_variable_set(
118
- :@instrumented_integrations,
119
- ValueHelpers.deep_dup(@instrumented_integrations || [])
120
- )
121
- copy.instance_variable_set(:@report_tag_breakdowns, ValueHelpers.deep_dup(@report_tag_breakdowns || []))
122
- copy.instance_variable_set(:@redacted_tag_keys, ValueHelpers.deep_dup(@redacted_tag_keys || []))
123
- copy.instance_variable_set(
124
- :@openai_compatible_providers,
125
- ValueHelpers.deep_dup(@openai_compatible_providers || {})
126
- )
127
- copy.instance_variable_set(:@finalized, false)
128
- copy
106
+ def finalized?
107
+ @finalized
129
108
  end
130
109
 
131
110
  private
@@ -144,14 +123,28 @@ module LlmCostTracker
144
123
  end
145
124
  end
146
125
 
147
- def normalize_report_tag_breakdowns(value)
148
- Array(value).map { |key| TagKey.validate!(key, error_class: Error) }
149
- end
150
-
151
126
  def ensure_shared_configuration_mutable!
152
127
  return unless finalized?
153
128
 
154
129
  raise FrozenError, "can't modify frozen LlmCostTracker::Configuration"
155
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
156
149
  end
157
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