Skip to content

Commit 3ad25ce

Browse files
committed
Address review: resolve events through the global index, scope by set, drop built-in auth
Query changes: * subscriptions_latency no longer reads the events table. Events are partitioned, and the oldest unprocessed event is known only by global position, which is not the partition key - looking it up in events would have to visit and lock every partition, which stops being viable well before a store reaches five figures of partitions. The lateral now joins event_subscription_positions to events_global_index and created_at is resolved via EventsGlobalIndexQueries#resolve_indexes, so each event is read from its own partition. Cost is still one index range scan per subscription and still does not grow with the size of the backlog. * Positions no longer come from sequences. The frontier is max(subscription_position) from event_subscription_positions and the store head is max(global_position) from events_global_index - both index-only scans, and neither can report an uncommitted or run-ahead value the way a sequence can. * The updated_at liveness filter is gone: that column has no index and can not get one without losing HOT updates, so the condition forced a sequential scan. Reports are instead scoped with optional, repeatable "set" query params (?set=A&set=B), which uses idx_subscriptions_set_and_name. Left optional rather than mandatory - a library should not force a filter - but since a set is normally named after the application owning it, scoping is now the documented way to keep a scrape to one application's subscriptions. Dropping the filter without a replacement would have brought back the dead-series problem it existed to solve. API changes: * No built-in authentication. Protecting the endpoint is the mounting application's business, as it already is for the Admin UI, so PG_EVENTSTORE_METRICS_TOKEN and the bearer check are removed and the docs show wrapping the app in your own middleware. * The database is chosen per request with a "config" query param instead of a predefined :metrics config, so one mounted app can serve metrics for every configured store. Unknown or absent falls back to the default config. * The Admin UI no longer serves these API routes; the standalone application is the single metrics surface. Its Metrics::Helpers include and metrics_connection helper go with them. * Metrics::Routes is deleted. With the Admin UI no longer using it there was one caller left, so the four routes are now declared literally in the application. Docs: * Corrected: event_subscription_positions is not pruned, so lag_seconds is exact rather than a lower bound. It is now absent - not zero - when the oldest unprocessed event no longer exists, because zero would read as "caught up". * Rewritten from the application's perspective, without naming internal machinery. * lag is kept as the metric name: it is the established term for this measurement.
1 parent ca61c40 commit 3ad25ce

18 files changed

Lines changed: 319 additions & 337 deletions

docs/admin_ui.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,5 @@ Now you can use any web server to run it.
4444

4545
## Prometheus metrics
4646

47-
The web application also serves subscriptions observability data in the Prometheus text format under
48-
`/metrics/subscriptions` (plus per-family sub-paths), protected by whatever protects the rest of the UI. For an
49-
actual scrape target there is a separate rack application with built-in bearer token authentication. See
50-
[Metrics](metrics.md).
47+
Subscriptions observability data in the Prometheus text format is served by a separate rack application, mounted
48+
independently of the Admin UI. See [Metrics](metrics.md).

docs/metrics.md

Lines changed: 82 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ wherever the app is mounted - mounted at `/pg_eventstore/metrics` the first one
1414

1515
| Path | Metrics | Query cost |
1616
|---|---|---|
17-
| `/subscriptions/latency` | lag of every alive subscription + store positions | the only one touching the `events` table (one index hop per subscription) |
17+
| `/subscriptions/latency` | lag of every reported subscription + store positions | the only one looking at event positions (one index range scan per subscription) |
1818
| `/subscriptions/health` | state, lock, heartbeat age, restarts, last error age | single read of the `subscriptions` table |
1919
| `/subscriptions/throughput` | processed events counter, handler capacity | single read of the `subscriptions` table |
2020
| `/subscriptions` | all of the above | all of the above |
@@ -31,29 +31,15 @@ the database.
3131

3232
### Which subscriptions are reported
3333

34-
The `subscriptions` table is a registry which never garbage-collects: every handler that was ever registered keeps its
35-
row, including handlers that were later renamed or removed. To keep dashboards meaningful, the endpoints only report
36-
subscriptions which are either locked by a subscriptions set or were updated within the last 10 minutes. A
37-
subscription that died *without releasing its lock* is deliberately still reported - detecting it is what
38-
`pg_eventstore_subscription_heartbeat_age_seconds` is for.
34+
Every subscription in the queried database, unless you narrow it down with `set` params (see
35+
[Reporting only some subscription sets](#reporting-only-some-subscription-sets)). Note that the subscriptions registry
36+
never removes rows, so handlers that were renamed or removed keep theirs and are reported too.
3937

40-
## Mounting
41-
42-
### Alongside the Admin UI
43-
44-
The Admin UI application serves the same metrics under `/metrics/*`:
45-
46-
```
47-
/metrics/subscriptions
48-
/metrics/subscriptions/latency
49-
/metrics/subscriptions/health
50-
/metrics/subscriptions/throughput
51-
```
38+
A subscription that died *without releasing its lock* is reported like any other - detecting it is what
39+
`pg_eventstore_subscription_heartbeat_age_seconds` is for, and it is the most useful thing to alert on: neither the
40+
state column nor the lock can be trusted to notice a process that went away.
5241

53-
Whatever authentication protects your Admin UI protects these paths too. This is convenient for eyeballing raw values
54-
in the browser, but usually inconvenient for Prometheus - scrapers can not pass human-oriented authentication.
55-
56-
### Standalone application
42+
## Mounting
5743

5844
`PgEventstore::Web::Metrics::Application` is a separate rack application designed to be a scrape target. In your
5945
`config/routes.rb`:
@@ -72,19 +58,58 @@ require 'pg_eventstore/web'
7258
run PgEventstore::Web::Metrics::Application
7359
```
7460

75-
It supports static bearer token authentication out of the box: set the `PG_EVENTSTORE_METRICS_TOKEN` environment
76-
variable and every request must carry an `Authorization: Bearer <token>` header. When the variable is not set the
77-
application is open, and protecting it is your responsibility.
61+
### Authorization
7862

79-
It uses the `:metrics` config when defined, with a fallback to the default config. This lets you point metrics at a
80-
replica or restrict its pool size:
63+
The application ships without authentication - how you protect the endpoint is up to you, exactly as it is for the
64+
[Admin UI](admin_ui.md#authorization). Wrap it in whatever middleware your setup already uses, for example:
8165

8266
```ruby
83-
PgEventstore.configure(name: :metrics) do |config|
84-
config.pg_uri = ENV['PG_EVENTSTORE_URI']
67+
metrics_app = Rack::Builder.new do
68+
use Rack::Auth::Basic do |_username, password|
69+
Rack::Utils.secure_compare(ENV.fetch('PG_EVENTSTORE_METRICS_PASSWORD'), password)
70+
end
71+
run PgEventstore::Web::Metrics::Application
72+
end
73+
74+
mount metrics_app, at: '/pg_eventstore/metrics'
75+
```
76+
77+
### Choosing the database
78+
79+
Which store is queried is decided per request by the `config` query param, so one mounted application can serve the
80+
metrics of every configured database:
81+
82+
```ruby
83+
PgEventstore.configure(name: :db1) do |config|
84+
config.pg_uri = ENV['DB1_URI']
8585
config.connection_pool_size = 1
8686
end
87+
88+
PgEventstore.configure(name: :db2) do |config|
89+
config.pg_uri = ENV['DB2_URI']
90+
config.connection_pool_size = 1
91+
end
92+
```
93+
8794
```
95+
GET /pg_eventstore/metrics/subscriptions/latency?config=db1
96+
```
97+
98+
An unknown or missing `config` falls back to the default configuration.
99+
100+
### Reporting only some subscription sets
101+
102+
Rows of the subscriptions registry are never removed, so a database that has been running for a while also holds
103+
handlers that were renamed, removed, or never ran against it. Pass one or more `set` params to report only the
104+
subscriptions you care about:
105+
106+
```
107+
GET /pg_eventstore/metrics/subscriptions/health?set=MyAppSet&set=MyOtherSet
108+
```
109+
110+
Without a `set` param every subscription in the database is reported. Since a subscription set is usually named after
111+
the application that owns it, scoping the scrape by set is the straightforward way to keep one application's
112+
dashboards to its own subscriptions.
88113

89114
## Prometheus scrape config
90115

@@ -93,29 +118,32 @@ scrape_configs:
93118
- job_name: 'pg-eventstore-subscriptions-latency'
94119
metrics_path: /pg_eventstore/metrics/subscriptions/latency
95120
scrape_interval: 30s
96-
authorization:
97-
type: Bearer
98-
credentials: <token>
121+
params:
122+
config: ['db1']
123+
set: ['MyAppSet']
99124
static_configs:
100125
- targets: ['your-app-host:port']
101126
- job_name: 'pg-eventstore-subscriptions-health'
102127
metrics_path: /pg_eventstore/metrics/subscriptions/health
103128
scrape_interval: 30s
104-
authorization:
105-
type: Bearer
106-
credentials: <token>
129+
params:
130+
config: ['db1']
131+
set: ['MyAppSet']
107132
static_configs:
108133
- targets: ['your-app-host:port']
109134
- job_name: 'pg-eventstore-subscriptions-throughput'
110135
metrics_path: /pg_eventstore/metrics/subscriptions/throughput
111136
scrape_interval: 60s
112-
authorization:
113-
type: Bearer
114-
credentials: <token>
137+
params:
138+
config: ['db1']
139+
set: ['MyAppSet']
115140
static_configs:
116141
- targets: ['your-app-host:port']
117142
```
118143
144+
Add whatever credentials your chosen protection needs to these jobs - Prometheus supports `basic_auth`,
145+
`authorization` and `tls_config` per job.
146+
119147
The split into three jobs is intentional - do not collapse them into a single `/subscriptions` scrape unless you
120148
are fine with every scrape paying the latency query.
121149

@@ -127,37 +155,32 @@ All per-subscription metrics carry `set` and `name` labels.
127155

128156
`pg_eventstore_subscription_lag_events` (gauge)
129157

130-
Number of events between the subscription's checkpoint and the subscription positions frontier.
131-
132-
Note on units: `Subscription#current_position` is a checkpoint in *subscription position* units - the dense,
133-
commit-ordered sequence assigned via the `event_subscription_positions` table - not in `events.global_position` units.
134-
The global position sequence contains gaps and runs ahead, so comparing a checkpoint against it wildly over-reports
135-
lag. This metric compares against the frontier of assigned subscription positions, which is the correct reference.
158+
How many events the subscription still has to catch up on before it reaches the edge of the `"all"` stream and starts
159+
processing newly appended events.
136160

137-
Note on filters: subscription filters are accounted for by the store itself. The feeder advances a subscription's
138-
checkpoint through ranges containing no matching events (via checkpoint chunks), so a caught-up subscription reports
139-
`0` regardless of how narrow its filter is - unrelated traffic never shows up as its lag. For a *lagging*
140-
subscription the value counts all events in the not-yet-checked range, matching or not: it is a measure of staleness
141-
("how far behind the store is this subscription"), not of how many events its handler will actually execute while
142-
catching up - that number is usually much smaller, since non-matching ranges are skipped in SQL.
161+
Note on filters: a subscription's filter is accounted for by the store, so a caught-up subscription reports `0` no
162+
matter how narrow its filter is - traffic it does not care about never shows up as its lag. For a *lagging*
163+
subscription the value counts everything in the range it has not reached yet, matching its filter or not. Read it as
164+
staleness ("how far behind is this subscription"), not as the number of events its handler is about to run: that
165+
number is usually much smaller, because non-matching ranges are skipped without invoking the handler.
143166

144167
`pg_eventstore_subscription_lag_seconds` (gauge)
145168

146-
Age of the oldest event the subscription has not processed yet. `0` when fully caught up. Because
147-
`event_subscription_positions` rows are pruned over time, for a subscription that is very far behind this reports the
148-
age of the oldest *retained* unprocessed event - a lower bound. When `lag_seconds` and `lag_events` disagree, trust
149-
`lag_events`.
169+
Age of the oldest event the subscription has not processed yet. `0` when fully caught up.
170+
171+
The metric is absent for a subscription whose oldest unprocessed event no longer exists, which happens when that
172+
event or its stream was deleted. Reporting `0` there would read as "caught up", so nothing is reported instead and
173+
`lag_events` remains the source of truth for the backlog.
150174

151175
`pg_eventstore_store_frontier_position` (gauge)
152176

153-
Latest assigned subscription position. The frontier only advances while at least one subscriptions process runs its
154-
events position worker; when every subscriptions process is down, lag freezes - that situation is caught by the
155-
heartbeat metric below, not by the lag metrics.
177+
Latest position assigned to an event, which is what subscription checkpoints are measured against. It only advances
178+
while at least one subscriptions process is running; when they are all down, lag stops growing - that situation shows
179+
up in the heartbeat metric below, not in the lag metrics.
156180

157181
`pg_eventstore_store_head_global_position` (gauge)
158182

159-
Latest value of the events global position sequence. Contains gaps; do not compare subscription checkpoints against
160-
it.
183+
Global position of the newest event in the store. Contains gaps; do not compare subscription checkpoints against it.
161184

162185
### Health
163186

lib/pg_eventstore/web.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
require_relative 'web/metrics/collectors/subscriptions_health'
2727
require_relative 'web/metrics/collectors/subscriptions_throughput'
2828
require_relative 'web/metrics/helpers'
29-
require_relative 'web/metrics/routes'
3029
require_relative 'web/metrics/application'
3130
require_relative 'web/application'
3231

lib/pg_eventstore/web/application.rb

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class Application < Sinatra::Base
2828
set :erb, layout: :'layouts/application'
2929
set :host_authorization, { allow_if: ->(_env) { true } }
3030

31-
helpers(Paginator::Helpers, Subscriptions::Helpers, Metrics::Helpers) do
31+
helpers(Paginator::Helpers, Subscriptions::Helpers) do
3232
# @return [Array<Hash>, nil]
3333
# rubocop:disable Style/HashConversion
3434
def streams_filter
@@ -95,11 +95,6 @@ def connection
9595
PgEventstore.connection(current_config)
9696
end
9797

98-
# @return [PgEventstore::Connection]
99-
def metrics_connection
100-
connection
101-
end
102-
10398
# @param collection [PgEventstore::Web::Paginator::BaseCollection]
10499
# @return [void]
105100
def paginated_json_response(collection)
@@ -439,10 +434,6 @@ def normalize_markers(hash)
439434

440435
redirect(redirect_back_url(fallback_url: '/'))
441436
end
442-
443-
# Prometheus metrics, served under the same mount as the UI so whatever protects the UI protects them.
444-
# For an unauthenticated-by-session scrape target, mount {Metrics::Application} separately instead.
445-
Metrics::Routes.define(self, prefix: '/metrics')
446437
end
447438
end
448439
end

lib/pg_eventstore/web/metrics/application.rb

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@
33
module PgEventstore
44
module Web
55
module Metrics
6-
# Standalone rack application serving Prometheus metrics. Unlike the admin UI - which is expected to sit
7-
# behind a human-oriented authentication - this app is meant to be scraped by Prometheus, so it supports
8-
# static bearer token authentication out of the box: set the PG_EVENTSTORE_METRICS_TOKEN environment
9-
# variable and every request must carry an "Authorization: Bearer <token>" header. When the variable is not
10-
# set, the app is open - protecting it is then your responsibility.
6+
# Standalone rack application serving subscription metrics in the Prometheus text exposition format.
117
#
12-
# Uses the :metrics config when defined, with a fallback to the default config.
8+
# It ships without any authentication - how the endpoint is protected is up to the application mounting it,
9+
# the same way it is for the Admin UI (see docs/admin_ui.md#authorization).
10+
#
11+
# Which pg_eventstore database is queried is chosen per request with the "config" query param, so one mounted
12+
# app can serve metrics of every configured store:
13+
#
14+
# GET /subscriptions/latency?config=db1
15+
#
16+
# An unknown or absent config falls back to the default one. Add "set" params to report only some subscription
17+
# sets - repeat the param for several: "?set=SetA&set=SetB".
1318
class Application < Sinatra::Base
14-
# @return [Symbol]
15-
DEFAULT_METRICS_CONFIG = :metrics
16-
# @return [String]
17-
AUTH_TOKEN_ENV_VAR = 'PG_EVENTSTORE_METRICS_TOKEN'
18-
1919
set :environment, -> { (ENV['RACK_ENV'] || ENV['RAILS_ENV'] || ENV['APP_ENV'])&.to_sym || :development }
2020
set :logging, false
2121
set :sessions, false
@@ -29,27 +29,31 @@ def metrics_connection
2929

3030
# @return [Symbol]
3131
def config_name
32-
return DEFAULT_METRICS_CONFIG if PgEventstore.available_configs.include?(DEFAULT_METRICS_CONFIG)
32+
requested = params[:config]&.to_s&.to_sym
33+
return requested if requested && PgEventstore.available_configs.include?(requested)
3334

3435
PgEventstore::DEFAULT_CONFIG
3536
end
37+
end
3638

37-
# @return [void]
38-
def authorize!
39-
token = ENV[AUTH_TOKEN_ENV_VAR].to_s
40-
return if token.empty?
39+
get('/subscriptions') do
40+
metrics_response(
41+
[Collectors::SubscriptionsLatency, Collectors::SubscriptionsHealth, Collectors::SubscriptionsThroughput]
42+
)
43+
end
4144

42-
provided = request.env['HTTP_AUTHORIZATION'].to_s.delete_prefix('Bearer ')
43-
halt 401, { 'content-type' => 'text/plain' }, 'Unauthorized' unless
44-
Rack::Utils.secure_compare(token, provided)
45-
end
45+
# The only route querying event positions - one index range scan per subscription.
46+
get('/subscriptions/latency') do
47+
metrics_response([Collectors::SubscriptionsLatency])
4648
end
4749

48-
before do
49-
authorize!
50+
get('/subscriptions/health') do
51+
metrics_response([Collectors::SubscriptionsHealth])
5052
end
5153

52-
Routes.define(self)
54+
get('/subscriptions/throughput') do
55+
metrics_response([Collectors::SubscriptionsThroughput])
56+
end
5357
end
5458
end
5559
end

0 commit comments

Comments
 (0)