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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +8 -3
- data/app/controllers/llm_cost_tracker/calls_controller.rb +35 -21
- data/app/services/llm_cost_tracker/dashboard/overview_stats.rb +3 -1
- data/app/services/llm_cost_tracker/dashboard/tag_key_explorer.rb +4 -5
- data/docs/architecture.md +28 -0
- data/docs/budgets.md +45 -0
- data/docs/configuration.md +65 -0
- data/docs/cookbook.md +185 -0
- data/docs/dashboard-overview.png +0 -0
- data/docs/dashboard.md +38 -0
- data/docs/extending.md +32 -0
- data/docs/operations.md +44 -0
- data/docs/pricing.md +94 -0
- data/docs/querying.md +36 -0
- data/docs/streaming.md +70 -0
- data/docs/technical/README.md +10 -0
- data/docs/technical/data-flow.md +70 -0
- data/docs/technical/extension-points.md +111 -0
- data/docs/technical/module-map.md +197 -0
- data/docs/technical/operational-notes.md +97 -0
- data/docs/upgrading.md +47 -0
- data/lib/llm_cost_tracker/active_record_adapter.rb +49 -0
- data/lib/llm_cost_tracker/capture_verifier.rb +71 -0
- data/lib/llm_cost_tracker/configuration/instrumentation.rb +1 -1
- data/lib/llm_cost_tracker/configuration/storage_backend.rb +26 -0
- data/lib/llm_cost_tracker/configuration.rb +2 -1
- data/lib/llm_cost_tracker/doctor/capture_check.rb +39 -0
- data/lib/llm_cost_tracker/doctor/ingestion_check.rb +117 -0
- data/lib/llm_cost_tracker/doctor.rb +8 -1
- data/lib/llm_cost_tracker/event.rb +1 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/add_ingestion_generator.rb +29 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_ingestion_to_llm_cost_tracker.rb.erb +33 -0
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_period_totals_to_llm_cost_tracker.rb.erb +14 -6
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/add_streaming_to_llm_api_calls.rb.erb +0 -4
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/create_llm_api_calls.rb.erb +30 -3
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/initializer.rb.erb +1 -1
- data/lib/llm_cost_tracker/generators/llm_cost_tracker/templates/upgrade_llm_api_call_tags_to_jsonb.rb.erb +3 -1
- data/lib/llm_cost_tracker/inbox_event.rb +9 -0
- data/lib/llm_cost_tracker/ingestor_lease.rb +9 -0
- data/lib/llm_cost_tracker/integrations/anthropic.rb +41 -2
- data/lib/llm_cost_tracker/integrations/openai.rb +66 -2
- data/lib/llm_cost_tracker/integrations/registry.rb +33 -3
- data/lib/llm_cost_tracker/integrations/stream_tracker.rb +166 -0
- data/lib/llm_cost_tracker/llm_api_call.rb +2 -78
- data/lib/llm_cost_tracker/llm_api_call_metrics.rb +63 -0
- data/lib/llm_cost_tracker/parsers/openai_usage.rb +1 -1
- data/lib/llm_cost_tracker/period_grouping.rb +4 -3
- data/lib/llm_cost_tracker/pricing/effective_prices.rb +75 -0
- data/lib/llm_cost_tracker/pricing/explainer.rb +77 -0
- data/lib/llm_cost_tracker/pricing/lookup.rb +143 -0
- data/lib/llm_cost_tracker/pricing.rb +25 -108
- data/lib/llm_cost_tracker/railtie.rb +1 -0
- data/lib/llm_cost_tracker/retention.rb +3 -9
- data/lib/llm_cost_tracker/storage/active_record_backend.rb +166 -0
- data/lib/llm_cost_tracker/storage/active_record_connection_cleanup.rb +13 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox.rb +165 -0
- data/lib/llm_cost_tracker/storage/active_record_inbox_batch.rb +92 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor.rb +174 -0
- data/lib/llm_cost_tracker/storage/active_record_ingestor_lease.rb +38 -0
- data/lib/llm_cost_tracker/storage/active_record_period_totals.rb +84 -0
- data/lib/llm_cost_tracker/storage/active_record_periods.rb +31 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_batch.rb +41 -0
- data/lib/llm_cost_tracker/storage/active_record_rollup_upsert_sql.rb +42 -0
- data/lib/llm_cost_tracker/storage/active_record_rollups.rb +59 -55
- data/lib/llm_cost_tracker/storage/active_record_store.rb +68 -9
- data/lib/llm_cost_tracker/storage/custom_backend.rb +32 -0
- data/lib/llm_cost_tracker/storage/dispatcher.rb +11 -34
- data/lib/llm_cost_tracker/storage/log_backend.rb +38 -0
- data/lib/llm_cost_tracker/storage/registry.rb +63 -0
- data/lib/llm_cost_tracker/stream_collector.rb +18 -7
- data/lib/llm_cost_tracker/tag_sql.rb +34 -0
- data/lib/llm_cost_tracker/tags_column.rb +7 -1
- data/lib/llm_cost_tracker/tracker.rb +3 -0
- data/lib/llm_cost_tracker/version.rb +1 -1
- data/lib/llm_cost_tracker.rb +39 -1
- data/lib/tasks/llm_cost_tracker.rake +49 -0
- metadata +47 -2
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
require_relative "../active_record_adapter"
|
|
7
|
+
require_relative "../cost"
|
|
8
|
+
require_relative "../event"
|
|
9
|
+
require_relative "../inbox_event"
|
|
10
|
+
require_relative "active_record_periods"
|
|
11
|
+
|
|
12
|
+
module LlmCostTracker
|
|
13
|
+
module Storage
|
|
14
|
+
class ActiveRecordInbox
|
|
15
|
+
TABLE_NAME = "llm_cost_tracker_inbox_events"
|
|
16
|
+
LEASE_TABLE_NAME = "llm_cost_tracker_ingestor_leases"
|
|
17
|
+
MAX_ATTEMPTS = 5
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def reset!
|
|
21
|
+
remove_instance_variable(:@enabled) if instance_variable_defined?(:@enabled)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def enabled?
|
|
25
|
+
return @enabled unless @enabled.nil?
|
|
26
|
+
|
|
27
|
+
model = LlmCostTracker::LlmApiCall
|
|
28
|
+
@enabled = model.columns_hash.key?("event_id") &&
|
|
29
|
+
model.connection.data_source_exists?(TABLE_NAME) &&
|
|
30
|
+
model.connection.data_source_exists?(LEASE_TABLE_NAME)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
@enabled = false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def save(event)
|
|
36
|
+
insert_row(row_for(event))
|
|
37
|
+
ActiveRecordIngestor.ensure_started
|
|
38
|
+
event
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pending_period_totals(periods, time:)
|
|
42
|
+
return periods.to_h { |period| [period, 0.0] } unless enabled?
|
|
43
|
+
|
|
44
|
+
periods.to_h do |period|
|
|
45
|
+
[period, pending_period_total(period, time)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def event_from_row(row)
|
|
50
|
+
payload = JSON.parse(row.payload)
|
|
51
|
+
cost = payload["cost"] && LlmCostTracker::Cost.new(**symbolize_keys(payload["cost"]))
|
|
52
|
+
|
|
53
|
+
LlmCostTracker::Event.new(
|
|
54
|
+
event_id: payload.fetch("event_id"),
|
|
55
|
+
provider: payload.fetch("provider"),
|
|
56
|
+
model: payload.fetch("model"),
|
|
57
|
+
input_tokens: payload.fetch("input_tokens"),
|
|
58
|
+
output_tokens: payload.fetch("output_tokens"),
|
|
59
|
+
total_tokens: payload.fetch("total_tokens"),
|
|
60
|
+
cache_read_input_tokens: payload.fetch("cache_read_input_tokens"),
|
|
61
|
+
cache_write_input_tokens: payload.fetch("cache_write_input_tokens"),
|
|
62
|
+
hidden_output_tokens: payload.fetch("hidden_output_tokens"),
|
|
63
|
+
pricing_mode: payload["pricing_mode"],
|
|
64
|
+
cost: cost,
|
|
65
|
+
tags: payload.fetch("tags"),
|
|
66
|
+
latency_ms: payload["latency_ms"],
|
|
67
|
+
stream: payload.fetch("stream"),
|
|
68
|
+
usage_source: payload["usage_source"],
|
|
69
|
+
provider_response_id: payload["provider_response_id"],
|
|
70
|
+
tracked_at: Time.iso8601(payload.fetch("tracked_at"))
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def row_for(event)
|
|
77
|
+
now = Time.now.utc
|
|
78
|
+
{
|
|
79
|
+
event_id: event.event_id,
|
|
80
|
+
total_cost: event.cost&.total_cost,
|
|
81
|
+
tracked_at: event.tracked_at,
|
|
82
|
+
payload: JSON.generate(payload_for(event)),
|
|
83
|
+
attempts: 0,
|
|
84
|
+
created_at: now,
|
|
85
|
+
updated_at: now
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def payload_for(event)
|
|
90
|
+
{
|
|
91
|
+
event_id: event.event_id,
|
|
92
|
+
provider: event.provider,
|
|
93
|
+
model: event.model,
|
|
94
|
+
input_tokens: event.input_tokens,
|
|
95
|
+
output_tokens: event.output_tokens,
|
|
96
|
+
total_tokens: event.total_tokens,
|
|
97
|
+
cache_read_input_tokens: event.cache_read_input_tokens,
|
|
98
|
+
cache_write_input_tokens: event.cache_write_input_tokens,
|
|
99
|
+
hidden_output_tokens: event.hidden_output_tokens,
|
|
100
|
+
pricing_mode: event.pricing_mode,
|
|
101
|
+
cost: event.cost&.to_h,
|
|
102
|
+
tags: event.tags || {},
|
|
103
|
+
latency_ms: event.latency_ms,
|
|
104
|
+
stream: event.stream,
|
|
105
|
+
usage_source: event.usage_source,
|
|
106
|
+
provider_response_id: event.provider_response_id,
|
|
107
|
+
tracked_at: event.tracked_at.iso8601(6)
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def insert_row(row)
|
|
112
|
+
connection = LlmCostTracker::LlmApiCall.connection
|
|
113
|
+
if connection.transaction_open? && !sqlite_database?(connection)
|
|
114
|
+
insert_with_separate_connection(row)
|
|
115
|
+
else
|
|
116
|
+
execute_insert(connection, row)
|
|
117
|
+
end
|
|
118
|
+
rescue ActiveRecord::ConnectionTimeoutError => e
|
|
119
|
+
raise LlmCostTracker::Error,
|
|
120
|
+
"ActiveRecord inbox could not checkout a separate database connection: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def insert_with_separate_connection(row)
|
|
124
|
+
pool = LlmCostTracker::LlmApiCall.connection_pool
|
|
125
|
+
connection = pool.checkout
|
|
126
|
+
begin
|
|
127
|
+
connection.transaction(requires_new: true) { execute_insert(connection, row) }
|
|
128
|
+
ensure
|
|
129
|
+
pool.checkin(connection)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def execute_insert(connection, row)
|
|
134
|
+
columns = row.keys
|
|
135
|
+
quoted_columns = columns.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
136
|
+
quoted_values = columns.map { |column| connection.quote(row.fetch(column)) }.join(", ")
|
|
137
|
+
table = connection.quote_table_name(TABLE_NAME)
|
|
138
|
+
|
|
139
|
+
connection.execute("INSERT INTO #{table} (#{quoted_columns}) VALUES (#{quoted_values})")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def pending_period_total(period, time)
|
|
143
|
+
LlmCostTracker::InboxEvent
|
|
144
|
+
.where("attempts < ?", MAX_ATTEMPTS)
|
|
145
|
+
.where(tracked_at: period_range(period, time))
|
|
146
|
+
.sum(:total_cost)
|
|
147
|
+
.to_f
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def period_range(period, time)
|
|
151
|
+
utc_time = time.to_time.utc
|
|
152
|
+
ActiveRecordPeriods.range_start(period, utc_time)..utc_time
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def symbolize_keys(hash)
|
|
156
|
+
hash.transform_keys(&:to_sym)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def sqlite_database?(connection)
|
|
160
|
+
ActiveRecordAdapter.sqlite?(connection)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../inbox_event"
|
|
4
|
+
require_relative "active_record_inbox"
|
|
5
|
+
require_relative "active_record_store"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Storage
|
|
9
|
+
class ActiveRecordInboxBatch
|
|
10
|
+
BATCH_SIZE = 100
|
|
11
|
+
LOCK_TIMEOUT_SECONDS = 30
|
|
12
|
+
|
|
13
|
+
def initialize(identity:)
|
|
14
|
+
@identity = identity
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ingest
|
|
18
|
+
rows = claim
|
|
19
|
+
return 0 if rows.empty?
|
|
20
|
+
|
|
21
|
+
valid_rows, events = decode(rows)
|
|
22
|
+
persist(valid_rows, events) if events.any?
|
|
23
|
+
rows.size
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
rows_to_mark = valid_rows&.any? ? valid_rows : rows
|
|
26
|
+
mark_failed(rows_to_mark, e) if rows_to_mark&.any?
|
|
27
|
+
raise
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def pending? = model.where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS).exists?
|
|
31
|
+
|
|
32
|
+
def claimable? = claimable_scope(Time.now.utc - LOCK_TIMEOUT_SECONDS).exists?
|
|
33
|
+
|
|
34
|
+
def mark_failed(rows, error)
|
|
35
|
+
message = "#{error.class}: #{error.message}".byteslice(0, 1_000)
|
|
36
|
+
now = Time.now.utc
|
|
37
|
+
model
|
|
38
|
+
.where(id: rows.map(&:id), locked_by: identity)
|
|
39
|
+
.update_all(last_error: message, locked_at: now, locked_by: nil, updated_at: now)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :identity
|
|
47
|
+
|
|
48
|
+
def claim
|
|
49
|
+
now = Time.now.utc
|
|
50
|
+
cutoff = now - LOCK_TIMEOUT_SECONDS
|
|
51
|
+
model.transaction do
|
|
52
|
+
rows = claimable_scope(cutoff).order(:id).limit(BATCH_SIZE).lock.to_a
|
|
53
|
+
ids = rows.map(&:id)
|
|
54
|
+
next [] if ids.empty?
|
|
55
|
+
|
|
56
|
+
updates = model.sanitize_sql_array(
|
|
57
|
+
["locked_at = ?, locked_by = ?, attempts = attempts + 1, updated_at = ?", now, identity, now]
|
|
58
|
+
)
|
|
59
|
+
model.where(id: ids).update_all(updates)
|
|
60
|
+
model.where(id: ids, locked_by: identity).order(:id).to_a
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def decode(rows)
|
|
65
|
+
valid_rows = []
|
|
66
|
+
events = []
|
|
67
|
+
rows.each do |row|
|
|
68
|
+
events << ActiveRecordInbox.event_from_row(row)
|
|
69
|
+
valid_rows << row
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
mark_failed([row], e)
|
|
72
|
+
end
|
|
73
|
+
[valid_rows, events]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
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
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def claimable_scope(cutoff)
|
|
84
|
+
model
|
|
85
|
+
.where("attempts < ?", ActiveRecordInbox::MAX_ATTEMPTS)
|
|
86
|
+
.where("locked_at IS NULL OR locked_at < ?", cutoff)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def model = LlmCostTracker::InboxEvent
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "../inbox_event"
|
|
6
|
+
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
|
+
|
|
12
|
+
module LlmCostTracker
|
|
13
|
+
module Storage
|
|
14
|
+
class ActiveRecordIngestor
|
|
15
|
+
INTERVAL_SECONDS = 0.25
|
|
16
|
+
IDLE_INTERVAL_SECONDS = 1.0
|
|
17
|
+
MAX_IDLE_INTERVAL_SECONDS = 5.0
|
|
18
|
+
LEASE_SECONDS = 10
|
|
19
|
+
FLUSH_TIMEOUT_SECONDS = 10
|
|
20
|
+
class << self
|
|
21
|
+
def ensure_started
|
|
22
|
+
return unless ActiveRecordInbox.enabled?
|
|
23
|
+
|
|
24
|
+
thread = mutex.synchronize do
|
|
25
|
+
reset_after_fork!
|
|
26
|
+
unless @thread&.alive?
|
|
27
|
+
@stop_requested = false
|
|
28
|
+
generation = next_generation
|
|
29
|
+
@thread = Thread.new { run(generation) }
|
|
30
|
+
@thread.name = "llm_cost_tracker_ingestor" if @thread.respond_to?(:name=)
|
|
31
|
+
@thread.report_on_exception = false if @thread.respond_to?(:report_on_exception=)
|
|
32
|
+
end
|
|
33
|
+
@thread
|
|
34
|
+
end
|
|
35
|
+
wake_thread(thread)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def flush!(timeout: FLUSH_TIMEOUT_SECONDS, require_lease: false)
|
|
39
|
+
return true unless ActiveRecordInbox.enabled?
|
|
40
|
+
|
|
41
|
+
deadline = Time.now.utc + timeout
|
|
42
|
+
loop do
|
|
43
|
+
return true unless pending_events?
|
|
44
|
+
return false if Time.now.utc >= deadline
|
|
45
|
+
|
|
46
|
+
processed = ingest_once(require_lease: require_lease)
|
|
47
|
+
return false if processed.zero? && !sleep_until_next_flush(deadline)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def shutdown!(timeout: FLUSH_TIMEOUT_SECONDS, drain: true)
|
|
52
|
+
ActiveRecordInbox.reset!
|
|
53
|
+
thread = mutex.synchronize do
|
|
54
|
+
@stop_requested = true
|
|
55
|
+
next_generation
|
|
56
|
+
@thread
|
|
57
|
+
end
|
|
58
|
+
wake_thread(thread)
|
|
59
|
+
thread&.join([timeout, 1].min)
|
|
60
|
+
drain ? flush!(timeout: timeout, require_lease: true) : true
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
handle_error(e)
|
|
63
|
+
false
|
|
64
|
+
ensure
|
|
65
|
+
mutex.synchronize { @thread = nil if @thread.equal?(thread) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset!
|
|
69
|
+
thread = mutex.synchronize do
|
|
70
|
+
@stop_requested = true
|
|
71
|
+
next_generation
|
|
72
|
+
thread = @thread
|
|
73
|
+
@thread = nil
|
|
74
|
+
@pid = nil
|
|
75
|
+
@identity = nil
|
|
76
|
+
thread
|
|
77
|
+
end
|
|
78
|
+
wake_thread(thread)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
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
|
|
85
|
+
|
|
86
|
+
inbox_batch.ingest
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
handle_error(e)
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def mutex = @mutex ||= Mutex.new
|
|
95
|
+
|
|
96
|
+
def run(generation)
|
|
97
|
+
idle_interval = IDLE_INTERVAL_SECONDS
|
|
98
|
+
loop do
|
|
99
|
+
break if stop_requested?(generation)
|
|
100
|
+
|
|
101
|
+
processed = executor_wrap { ingest_once }
|
|
102
|
+
ActiveRecordConnectionCleanup.release!
|
|
103
|
+
if processed.zero?
|
|
104
|
+
sleep(idle_interval)
|
|
105
|
+
idle_interval = [idle_interval * 2, MAX_IDLE_INTERVAL_SECONDS].min
|
|
106
|
+
else
|
|
107
|
+
idle_interval = IDLE_INTERVAL_SECONDS
|
|
108
|
+
end
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
handle_error(e)
|
|
111
|
+
ActiveRecordConnectionCleanup.release!
|
|
112
|
+
sleep(idle_interval)
|
|
113
|
+
end
|
|
114
|
+
ensure
|
|
115
|
+
ActiveRecordConnectionCleanup.release!
|
|
116
|
+
mutex.synchronize { @thread = nil if @thread.equal?(Thread.current) }
|
|
117
|
+
end
|
|
118
|
+
|
|
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
|
+
def reset_after_fork!
|
|
127
|
+
return if @pid == Process.pid
|
|
128
|
+
|
|
129
|
+
@pid = Process.pid
|
|
130
|
+
@thread = nil
|
|
131
|
+
@identity = nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def next_generation = (@generation = @generation.to_i + 1)
|
|
135
|
+
|
|
136
|
+
def wake_thread(thread)
|
|
137
|
+
thread&.wakeup if thread&.alive?
|
|
138
|
+
rescue ThreadError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def executor_wrap(&)
|
|
143
|
+
executor = rails_executor
|
|
144
|
+
return yield unless executor
|
|
145
|
+
|
|
146
|
+
executor.wrap(&)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def rails_executor
|
|
150
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:executor)
|
|
151
|
+
|
|
152
|
+
Rails.application.executor
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
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)
|
|
166
|
+
|
|
167
|
+
def handle_error(error)
|
|
168
|
+
Logging.warn("ActiveRecord ingestor failed: #{error.class}: #{error.message}") unless
|
|
169
|
+
LlmCostTracker.configuration.storage_error_behavior == :ignore
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ingestor_lease"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
class ActiveRecordIngestorLease
|
|
8
|
+
LEASE_NAME = "default"
|
|
9
|
+
|
|
10
|
+
def initialize(identity:, seconds:)
|
|
11
|
+
@identity = identity
|
|
12
|
+
@seconds = seconds
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def acquire
|
|
16
|
+
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)
|
|
20
|
+
next false unless available?(lease, now)
|
|
21
|
+
|
|
22
|
+
lease.update!(locked_by: identity, locked_until: now + seconds)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :identity, :seconds
|
|
32
|
+
|
|
33
|
+
def available?(lease, now)
|
|
34
|
+
lease.locked_by.nil? || lease.locked_by == identity || lease.locked_until.nil? || lease.locked_until < now
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "active_record_inbox"
|
|
4
|
+
require_relative "active_record_periods"
|
|
5
|
+
require_relative "active_record_rollups"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Storage
|
|
9
|
+
class ActiveRecordPeriodTotals
|
|
10
|
+
def self.call(periods, time:)
|
|
11
|
+
new(periods, time: time).totals
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(periods, time:)
|
|
15
|
+
@periods = ActiveRecordPeriods.valid_keys(periods)
|
|
16
|
+
@time = time
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def totals
|
|
20
|
+
return {} if periods.empty?
|
|
21
|
+
return ActiveRecordRollups.period_totals(periods, time: time) unless ActiveRecordInbox.enabled?
|
|
22
|
+
|
|
23
|
+
snapshot_totals
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :periods, :time
|
|
29
|
+
|
|
30
|
+
def snapshot_totals
|
|
31
|
+
values = periods.to_h { |period| [period, 0.0] }
|
|
32
|
+
connection.select_all(snapshot_sql).each do |row|
|
|
33
|
+
values[row.fetch("period_key").to_sym] = row.fetch("total_cost").to_f
|
|
34
|
+
end
|
|
35
|
+
values
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def snapshot_sql
|
|
39
|
+
periods.map { |period| snapshot_select(period) }.join(" UNION ALL ")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def snapshot_select(period)
|
|
43
|
+
start = range_start_for(period)
|
|
44
|
+
"SELECT #{connection.quote(period.to_s)} AS period_key, " \
|
|
45
|
+
"(#{stored_total_sql(period, start)}) + (#{pending_total_sql(start)}) AS total_cost"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stored_total_sql(period, start)
|
|
49
|
+
period_totals_table? ? rollup_total_sql(period) : ledger_total_sql(start)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rollup_total_sql(period)
|
|
53
|
+
table = connection.quote_table_name("llm_cost_tracker_period_totals")
|
|
54
|
+
"COALESCE((SELECT total_cost FROM #{table} " \
|
|
55
|
+
"WHERE period = #{connection.quote(ActiveRecordPeriods::PERIODS.fetch(period))} " \
|
|
56
|
+
"AND period_start = #{connection.quote(ActiveRecordPeriods.bucket(period, time))} LIMIT 1), 0)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ledger_total_sql(start)
|
|
60
|
+
table = LlmCostTracker::LlmApiCall.quoted_table_name
|
|
61
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
62
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
63
|
+
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
64
|
+
"WHERE #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def pending_total_sql(start)
|
|
68
|
+
table = connection.quote_table_name(ActiveRecordInbox::TABLE_NAME)
|
|
69
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
70
|
+
tracked_at = connection.quote_column_name("tracked_at")
|
|
71
|
+
attempts = connection.quote_column_name("attempts")
|
|
72
|
+
"COALESCE((SELECT SUM(#{total_cost}) FROM #{table} " \
|
|
73
|
+
"WHERE #{attempts} < #{ActiveRecordInbox::MAX_ATTEMPTS} " \
|
|
74
|
+
"AND #{tracked_at} BETWEEN #{connection.quote(start)} AND #{connection.quote(time)}), 0)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def period_totals_table? = connection.data_source_exists?("llm_cost_tracker_period_totals")
|
|
78
|
+
|
|
79
|
+
def range_start_for(period) = ActiveRecordPeriods.range_start(period, time)
|
|
80
|
+
|
|
81
|
+
def connection = LlmCostTracker::LlmApiCall.connection
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmCostTracker
|
|
4
|
+
module Storage
|
|
5
|
+
module ActiveRecordPeriods
|
|
6
|
+
PERIODS = {
|
|
7
|
+
monthly: "month",
|
|
8
|
+
daily: "day"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def valid_keys(periods)
|
|
14
|
+
periods.map(&:to_sym).select { |period| PERIODS.key?(period) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def range_start(period, time)
|
|
18
|
+
utc_time = time.to_time.utc
|
|
19
|
+
|
|
20
|
+
case period
|
|
21
|
+
when :monthly then utc_time.beginning_of_month
|
|
22
|
+
when :daily then utc_time.beginning_of_day
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def bucket(period, time)
|
|
27
|
+
range_start(period, time).to_date
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
require_relative "active_record_periods"
|
|
6
|
+
|
|
7
|
+
module LlmCostTracker
|
|
8
|
+
module Storage
|
|
9
|
+
class ActiveRecordRollupBatch
|
|
10
|
+
def self.rows(events)
|
|
11
|
+
new(events).rows
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(events)
|
|
15
|
+
@events = events
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rows
|
|
19
|
+
totals.map do |(period, period_start), total_cost|
|
|
20
|
+
{
|
|
21
|
+
period: period,
|
|
22
|
+
period_start: period_start,
|
|
23
|
+
total_cost: total_cost
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :events
|
|
31
|
+
|
|
32
|
+
def totals
|
|
33
|
+
events.each_with_object(Hash.new { |hash, key| hash[key] = BigDecimal("0") }) do |event, rows|
|
|
34
|
+
ActiveRecordPeriods::PERIODS.each do |period, name|
|
|
35
|
+
rows[[name, ActiveRecordPeriods.bucket(period, event.tracked_at)]] += BigDecimal(event.cost.total_cost.to_s)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../active_record_adapter"
|
|
4
|
+
|
|
5
|
+
module LlmCostTracker
|
|
6
|
+
module Storage
|
|
7
|
+
class ActiveRecordRollupUpsertSql
|
|
8
|
+
def self.call(model)
|
|
9
|
+
new(model).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(model)
|
|
13
|
+
@model = model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return Arel.sql(mysql_sql) if ActiveRecordAdapter.mysql?(connection)
|
|
18
|
+
return Arel.sql(postgres_sql) if ActiveRecordAdapter.postgresql?(connection)
|
|
19
|
+
|
|
20
|
+
Arel.sql("total_cost = total_cost + excluded.total_cost, updated_at = excluded.updated_at")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :model
|
|
26
|
+
|
|
27
|
+
def postgres_sql
|
|
28
|
+
total_cost = connection.quote_column_name("total_cost")
|
|
29
|
+
updated_at = connection.quote_column_name("updated_at")
|
|
30
|
+
|
|
31
|
+
"#{total_cost} = #{model.quoted_table_name}.#{total_cost} + excluded.#{total_cost}, " \
|
|
32
|
+
"#{updated_at} = excluded.#{updated_at}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def mysql_sql
|
|
36
|
+
"total_cost = total_cost + VALUES(total_cost), updated_at = VALUES(updated_at)"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def connection = model.connection
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|