Skip to content

Commit e66f8bd

Browse files
feat(usage): make bucket serving observable
## Context The served path delegates to the events store silently by design, so a lagging or broken pipeline is indistinguishable from usage merely being slow, which is what usage looked like before the buckets existed. Aggregation is the cost, so the size of the win equals coverage: one delegated high-traffic charge keeps a subscription slow however well the rest of the plan is served. ## Description Every current usage lookup is now counted as served or delegated and tagged with the reason, emitted from the event store provider, which is the only place that knows both the decision and why it was taken. The reasons partition the declines, so a delegation rate can be explained without reading the code, and the residue left after subtracting the ineligible charges is the signal that something is wrong. Alongside it, the freshness of the buckets behind a served read. It is the most recent bucket write rather than the oldest: the oldest reports the age of the billing period, since a bucket closed on day one is never rewritten. It cannot tell a stalled pipeline from a charge nobody sends events for, so liveness stays with the pipeline's own loopback. The reasons are deliberately coarse. One label per branch would mirror the code rather than answer a question, and the cases worth separating are the ones an operator would act on differently. A ClickHouse read that failed is not among them: it already reaches Sentry, so it shares a label with the callers that decline the prefetch outright. Metrics are emitted only for organizations the gate is on for, or the disabled buckets would drown the ratio, and no metric carries an organization, subscription or charge id, so the series count stays a function of the reason list rather than of the customer base. The alert rules and the pipeline's own liveness loopback live outside this repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent a38dbc8 commit e66f8bd

6 files changed

Lines changed: 296 additions & 25 deletions

File tree

app/services/events/stores/provider.rb

Lines changed: 80 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ module Stores
55
# Mints the event store instances used by a single usage or billing computation, and
66
# holds the pre-aggregated usage buckets it may serve count and sum from.
77
#
8+
# It is also the only place that knows whether a (charge, filter) was served or delegated
9+
# and why, so it is the only place that reports it.
10+
#
811
# Scoped to one computation at one window: build one per request or job.
912
class Provider
1013
def initialize(organization:, subscription:, boundaries:, usage_buckets: nil, current_usage: false)
@@ -13,6 +16,7 @@ def initialize(organization:, subscription:, boundaries:, usage_buckets: nil, cu
1316
@boundaries = boundaries
1417
@usage_buckets = usage_buckets
1518
@current_usage = current_usage
19+
@outcomes = {}
1620
end
1721

1822
attr_reader :subscription, :boundaries
@@ -74,27 +78,16 @@ def precomputed_options_for(charge:, filters: {})
7478
}
7579
end
7680

77-
# Whether this (charge, filter) is answered from the buckets. The totals answer for the
78-
# whole (charge, filter), so a group-scoped or pay-in-advance read cannot use them, and
79-
# a presentation breakdown reads events anyway. A window without a single bucket is a
80-
# pipeline gap rather than an absence of usage: answering zero would undercharge.
81-
#
82-
# The buckets close 15 minutes at a time, so they always lag: current usage can read a
83-
# lagging total, an invoice cannot. A `max_timestamp` freezes the read below the window
84-
# the totals cover, which would overcount by everything that landed after it.
81+
# Whether this (charge, filter) is answered from the buckets. Both callers of the decision
82+
# — the charge cache bypass and the aggregation options — come through here, so the
83+
# outcome is memoized and a lookup is reported once.
8584
def serves?(charge:, filters: {})
86-
return false if usage_buckets.blank?
8785
return false unless current_usage
88-
return false if boundaries[:max_timestamp].present?
89-
return false unless RealtimeUsage.enabled?(organization)
90-
return false if RealtimeUsage.deduplicated?(organization)
91-
return false unless RealtimeUsage.supported_charge?(charge)
92-
return false unless usage_buckets.serves_charge?(charge.id)
93-
94-
filters[:grouped_by_values].blank? &&
95-
filters[:event].blank? &&
96-
filters[:presentation_by].blank? &&
97-
filters[:filter_by_group].blank?
86+
87+
key = [charge.id, self.class.bucket_charge_filter_id(filters[:charge_filter])]
88+
return outcomes[key] if outcomes.key?(key)
89+
90+
outcomes[key] = report(delegation_reason(charge:, filters:))
9891
end
9992

10093
# The sink writes `COALESCE(charge_filter_id, '')`, while the unfiltered fee carries
@@ -105,7 +98,74 @@ def self.bucket_charge_filter_id(charge_filter)
10598

10699
private
107100

108-
attr_reader :organization, :current_usage
101+
attr_reader :organization, :current_usage, :outcomes
102+
103+
# Asked once per (charge, filter) otherwise, on the path the buckets exist to make fast.
104+
def gate_open?
105+
return @gate_open if defined?(@gate_open)
106+
107+
@gate_open = RealtimeUsage.enabled?(organization)
108+
end
109+
110+
# nil when the buckets answer. The reason returned is the first thing that would have to
111+
# change for this lookup to be served, so a plan of ineligible charges reports what makes
112+
# them ineligible rather than the absent prefetch that ineligibility caused.
113+
#
114+
# The buckets close 15 minutes at a time, so they always lag: current usage can read a
115+
# lagging total, an invoice cannot. A `max_timestamp` freezes the read below the window
116+
# the totals cover, which would overcount by everything that landed after it.
117+
#
118+
# A window without a single bucket is a pipeline gap rather than an absence of usage:
119+
# answering zero would undercharge.
120+
#
121+
# `not_prefetched` covers both a caller that declined the prefetch — `full_usage` and
122+
# projected reads do — and a ClickHouse read that failed, which already reaches Sentry.
123+
def delegation_reason(charge:, filters:)
124+
return :gate_disabled unless gate_open?
125+
return :deduplicated if RealtimeUsage.deduplicated?(organization)
126+
return :frozen_window if boundaries[:max_timestamp].present?
127+
return :ineligible_charge unless RealtimeUsage.supported_charge?(charge)
128+
return :unsupported_read if unsupported_read?(filters)
129+
return :not_prefetched if usage_buckets.nil?
130+
return :no_buckets if usage_buckets.empty?
131+
132+
:drift unless usage_buckets.serves_charge?(charge.id)
133+
end
134+
135+
# The totals answer for the whole (charge, filter), so a group-scoped or pay-in-advance
136+
# read cannot use them, and a presentation breakdown reads events anyway.
137+
def unsupported_read?(filters)
138+
filters[:grouped_by_values].present? ||
139+
filters[:event].present? ||
140+
filters[:presentation_by].present? ||
141+
filters[:filter_by_group].present?
142+
end
143+
144+
# Nothing is reported for an organization the gate is shut for, or the disabled buckets
145+
# would drown the ratio.
146+
def report(reason)
147+
served = reason.nil?
148+
return served unless gate_open?
149+
150+
Yabeda.realtime_usage.lookups_total.increment(
151+
{outcome: served ? "served" : "delegated", reason: reason&.to_s || "none"}
152+
)
153+
report_freshness if served
154+
155+
served
156+
end
157+
158+
# Once per computation: the watermark answers for the whole prefetched set, not for the
159+
# charge that happened to be looked up first.
160+
def report_freshness
161+
return if @freshness_reported
162+
163+
@freshness_reported = true
164+
ingested_at = usage_buckets.last_ingested_at
165+
return if ingested_at.nil?
166+
167+
Yabeda.realtime_usage.freshness.measure({}, (Time.current - ingested_at).to_f)
168+
end
109169
end
110170
end
111171
end

app/services/events/stores/usage_bucket_set.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,20 @@ class UsageBucketSet
99

1010
# Copied before freezing: the caller usually builds these hashes as accumulators, and
1111
# freezing its own object would raise on the next write, far from here.
12-
def initialize(totals: {}, grouped_totals: {}, unservable_charge_ids: [])
12+
def initialize(totals: {}, grouped_totals: {}, unservable_charge_ids: [], last_ingested_at: nil)
1313
@totals = totals.dup.freeze
1414
@grouped_totals = grouped_totals.dup.freeze
1515
@unservable_charge_ids = unservable_charge_ids.to_set.freeze
16+
@last_ingested_at = last_ingested_at
1617
freeze
1718
end
1819

1920
attr_reader :unservable_charge_ids
2021

22+
# The watermark the whole set answers for: nil when it holds no row, so a computation
23+
# served entirely from absent rows reports no freshness rather than an age of forever.
24+
attr_reader :last_ingested_at
25+
2126
def empty?
2227
totals.empty? && grouped_totals.empty?
2328
end

app/services/realtime_usage/fetch_buckets_service.rb

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def organization
4444
# An unreachable ClickHouse has to make current usage slow, not broken: no set at all
4545
# leaves every charge reading events.
4646
def fetch
47-
Events::Stores::UsageBucketSet.new(totals:, grouped_totals:, unservable_charge_ids:)
47+
Events::Stores::UsageBucketSet.new(totals:, grouped_totals:, unservable_charge_ids:, last_ingested_at:)
4848
rescue *READ_ERRORS => e
4949
Rails.logger.warn("Realtime usage bucket prefetch failed: #{e.class} #{e.message}")
5050
Sentry.capture_exception(e)
@@ -77,15 +77,25 @@ def sum_totals(totals, row)
7777
)
7878
end
7979

80+
# When the sink last wrote anything into this window. Taking the oldest instead would report
81+
# the age of the billing period: a bucket closed on day one is never rewritten.
82+
#
83+
# It cannot tell a stalled pipeline from a charge nobody sends events for, which is why
84+
# liveness comes from the pipeline's own loopback rather than from here.
85+
def last_ingested_at
86+
rows.filter_map { it[:last_ingested_at] }.max
87+
end
88+
8089
def rows
81-
@rows ||= read_rows.map do |charge_id, charge_filter_id, grouped_by, aggregation_type, units, events_count|
90+
@rows ||= read_rows.map do |charge_id, charge_filter_id, grouped_by, aggregation_type, units, events_count, last_ingested_at|
8291
{
8392
charge_id:,
8493
charge_filter_id:,
8594
aggregation_type:,
8695
groups: parse_groups(grouped_by),
8796
units: units.to_d,
88-
events_count: events_count.to_i
97+
events_count: events_count.to_i,
98+
last_ingested_at: parse_time(last_ingested_at)
8999
}
90100
end
91101
end
@@ -100,7 +110,7 @@ def read_rows
100110
.where(organization_id: organization.id, subscription_id: subscription.id, charge_id: charge_ids)
101111
.where(bucket: window, is_deleted: 0)
102112
.group(:charge_id, :charge_filter_id, :grouped_by, :aggregation_type)
103-
.pluck(Arel.sql("charge_id, charge_filter_id, grouped_by, aggregation_type, sum(units), sum(events_count)"))
113+
.pluck(Arel.sql("charge_id, charge_filter_id, grouped_by, aggregation_type, sum(units), sum(events_count), max(last_ingested_at)"))
104114
end
105115
end
106116

@@ -120,6 +130,12 @@ def parse_groups(grouped_by)
120130
nil
121131
end
122132

133+
def parse_time(value)
134+
return nil if value.blank?
135+
136+
value.is_a?(String) ? Time.zone.parse(value) : value.to_time
137+
end
138+
123139
# Rails asks the store for one group per key of `Fees::ChargeService#grouped_by_keys`, so a
124140
# row grouped or aggregated another way answers another question than the fee asks. Only the
125141
# charge as a whole sees it, hence one drifting row delegating every filter of the charge.

config/initializers/yabeda.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,20 @@
1414
default_tag :service, ENV["OTEL_SERVICE_NAME"] || "lago-api"
1515
default_tag :environment, Rails.env
1616
default_tag :version, ENV["LAGO_VERSION"] || "unknown"
17+
18+
# Emitted only for organizations the realtime usage gate is on for, and only by
19+
# Events::Stores::Provider. No organization, subscription or charge id is ever a tag: the
20+
# series count has to stay a function of the reason list, not of the customer base.
21+
group :realtime_usage do
22+
counter :lookups_total,
23+
comment: "Current usage lookups answered from the pre-aggregated buckets or delegated to the events store",
24+
tags: %i[outcome reason]
25+
26+
# The prometheus adapter builds the exported name as group_name_unit, so the metric is named
27+
# without the suffix its unit already adds.
28+
histogram :freshness,
29+
comment: "Age of the most recent bucket write backing a served computation",
30+
unit: :seconds,
31+
buckets: [30, 60, 120, 300, 600, 1800, 3600, 21_600]
32+
end
1733
end

spec/services/events/stores/provider_spec.rb

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,165 @@
345345
end
346346
end
347347

348+
describe "the reported outcome" do
349+
subject(:provider) do
350+
described_class.new(organization:, subscription:, boundaries:, usage_buckets: bucket_set, current_usage: true)
351+
end
352+
353+
include_context "with realtime usage availability"
354+
355+
let(:organization) do
356+
create(:organization, clickhouse_events_store: true, feature_flags: ["realtime_usage"])
357+
end
358+
let(:reported) { [] }
359+
360+
before { allow(Yabeda.realtime_usage.lookups_total).to receive(:increment) { |tags| reported << tags } }
361+
362+
it "reports a served lookup once, however many callers ask the same question" do
363+
provider.serves?(charge:)
364+
provider.serves?(charge:)
365+
366+
expect(reported).to eq([{outcome: "served", reason: "none"}])
367+
end
368+
369+
it "reports each filter of a charge separately, as each is a lookup a fee took" do
370+
provider.serves?(charge:)
371+
provider.serves?(charge:, filters: {charge_filter: create(:charge_filter, charge:)})
372+
373+
expect(reported.size).to eq(2)
374+
end
375+
376+
it "reports nothing outside a current usage computation, which never asked for buckets" do
377+
described_class.new(organization:, subscription:, boundaries:, usage_buckets: bucket_set).serves?(charge:)
378+
379+
expect(reported).to be_empty
380+
end
381+
382+
it "reports nothing for an organization the gate is shut for, whose declines would drown the ratio" do
383+
other_organization = create(:organization, clickhouse_events_store: true)
384+
385+
described_class
386+
.new(organization: other_organization, subscription:, boundaries:, usage_buckets: bucket_set, current_usage: true)
387+
.serves?(charge:)
388+
389+
expect(reported).to be_empty
390+
end
391+
392+
context "when the organization deduplicates its events" do
393+
let(:organization) do
394+
create(:organization, clickhouse_events_store: true, feature_flags: ["realtime_usage"], clickhouse_deduplication_enabled: true)
395+
end
396+
397+
it "reports the delegation" do
398+
provider.serves?(charge:)
399+
400+
expect(reported).to eq([{outcome: "delegated", reason: "deduplicated"}])
401+
end
402+
end
403+
404+
context "when the read is frozen at a past timestamp" do
405+
let(:boundaries) { {from_datetime: Time.current.beginning_of_month, to_datetime: Time.current, max_timestamp: 1.day.ago} }
406+
407+
it "reports the delegation" do
408+
provider.serves?(charge:)
409+
410+
expect(reported).to eq([{outcome: "delegated", reason: "frozen_window"}])
411+
end
412+
end
413+
414+
context "with a charge the buckets were never going to answer" do
415+
let(:charge) { create(:percentage_charge, plan: subscription.plan, billable_metric:) }
416+
417+
it "reports the delegation" do
418+
provider.serves?(charge:)
419+
420+
expect(reported).to eq([{outcome: "delegated", reason: "ineligible_charge"}])
421+
end
422+
end
423+
424+
it "reports a read whose shape the totals cannot answer" do
425+
provider.serves?(charge:, filters: {presentation_by: ["region"]})
426+
427+
expect(reported).to eq([{outcome: "delegated", reason: "unsupported_read"}])
428+
end
429+
430+
context "when the computation holds no prefetch" do
431+
let(:bucket_set) { nil }
432+
433+
it "reports the delegation" do
434+
provider.serves?(charge:)
435+
436+
expect(reported).to eq([{outcome: "delegated", reason: "not_prefetched"}])
437+
end
438+
end
439+
440+
context "when the window holds no bucket" do
441+
let(:bucket_set) { Events::Stores::UsageBucketSet.new }
442+
443+
it "reports the delegation, as answering zero would undercharge" do
444+
provider.serves?(charge:)
445+
446+
expect(reported).to eq([{outcome: "delegated", reason: "no_buckets"}])
447+
end
448+
end
449+
450+
context "with a charge the prefetch found drifting" do
451+
let(:bucket_set) do
452+
Events::Stores::UsageBucketSet.new(
453+
totals: {[charge.id, ""] => Events::Stores::UsageBucketSet::Totals.new(units: BigDecimal("10"), events_count: 2)},
454+
unservable_charge_ids: [charge.id]
455+
)
456+
end
457+
458+
it "reports the delegation" do
459+
provider.serves?(charge:)
460+
461+
expect(reported).to eq([{outcome: "delegated", reason: "drift"}])
462+
end
463+
end
464+
end
465+
466+
describe "the reported freshness" do
467+
subject(:provider) do
468+
described_class.new(organization:, subscription:, boundaries:, usage_buckets: bucket_set, current_usage: true)
469+
end
470+
471+
include_context "with realtime usage availability"
472+
473+
let(:organization) do
474+
create(:organization, clickhouse_events_store: true, feature_flags: ["realtime_usage"])
475+
end
476+
let(:bucket_set) do
477+
Events::Stores::UsageBucketSet.new(
478+
totals: {[charge.id, ""] => Events::Stores::UsageBucketSet::Totals.new(units: BigDecimal("10"), events_count: 2)},
479+
last_ingested_at: 2.minutes.ago
480+
)
481+
end
482+
483+
before { allow(Yabeda.realtime_usage.freshness).to receive(:measure) }
484+
485+
it "measures the age of the last bucket write once, as the watermark answers for the whole set" do
486+
provider.serves?(charge:)
487+
provider.serves?(charge:, filters: {charge_filter: create(:charge_filter, charge:)})
488+
489+
expect(Yabeda.realtime_usage.freshness).to have_received(:measure).once.with({}, be_within(5).of(120))
490+
end
491+
492+
context "when the set carries no watermark" do
493+
let(:bucket_set) do
494+
Events::Stores::UsageBucketSet.new(
495+
totals: {[charge.id, ""] => Events::Stores::UsageBucketSet::Totals.new(units: BigDecimal("10"), events_count: 2)}
496+
)
497+
end
498+
499+
it "measures nothing, rather than an age of forever" do
500+
provider.serves?(charge:)
501+
502+
expect(Yabeda.realtime_usage.freshness).not_to have_received(:measure)
503+
end
504+
end
505+
end
506+
348507
describe "#plain_store" do
349508
it "returns a store with no metric code" do
350509
store = provider.plain_store

0 commit comments

Comments
 (0)