Skip to content

Commit 81f9d4b

Browse files
authored
feat(telemetry): Expose key stack metrics to Prometheus (#4541)
Export the following metrics on the /metrics endpoint: - defined/active shape counts, - replication lag (byte-based slot lag and a time-based receive-lag histogram) - retained WAL size - electric.plug.serve_shape.requests.count (tagged by HTTP status, `electric_plug_serve_shape_requests_count{status="200"|"409"|...}`) - admission_control.(aquire|reject).count These were already collected and exported to OTel/StatsD/Call-Home but not Prometheus, which only served system-level metrics. A new additional_prometheus_metrics telemetry option routes them through the single shared Prometheus aggregator without double-reporting via the other reporters. Single stack per instance. Fixes #4535
1 parent b67df31 commit 81f9d4b

8 files changed

Lines changed: 270 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@core/electric-telemetry': patch
3+
'@core/sync-service': patch
4+
---
5+
6+
Expose key stack-level metrics on the Prometheus `/metrics` endpoint: number of
7+
defined shapes, number of active shapes, replication lag (byte-based slot lag
8+
and time-based receive-lag histogram), retained WAL size, and per-status-code
9+
counts of shape-endpoint HTTP responses
10+
(`electric_plug_serve_shape_requests_count{status="200"}`, `409`, `503`, ...),
11+
and admission-control concurrency/rejection counts
12+
(`electric_admission_control_acquire_current{kind=...}`,
13+
`electric_admission_control_reject_count{kind=...}`).
14+
These metrics were already collected and exported to OTel/StatsD/Call-Home but
15+
not to Prometheus, which previously only served system-level (CPU/RAM/BEAM)
16+
metrics. A new `additional_prometheus_metrics` telemetry option routes them
17+
through the single shared Prometheus aggregator without double-reporting via the
18+
other reporters. Assumes a single stack per instance.

packages/electric-telemetry/lib/electric/telemetry/application_telemetry.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@ defmodule ElectricTelemetry.ApplicationTelemetry do
4242
defp exporter_child_specs(opts) do
4343
metrics = metrics(opts)
4444

45+
# Metrics that should reach Prometheus only, e.g. stack-level metrics that the other
46+
# reporters already export per-stack. Appending them here avoids double-reporting.
47+
prometheus_metrics = metrics ++ Map.get(opts, :additional_prometheus_metrics, [])
48+
4549
[
4650
Reporters.CallHomeReporter.child_spec(
4751
opts,
4852
metrics: Reporters.CallHomeReporter.application_metrics()
4953
),
5054
Reporters.Otel.child_spec(opts, metrics: metrics),
51-
Reporters.Prometheus.child_spec(opts, metrics: metrics),
55+
Reporters.Prometheus.child_spec(opts, metrics: prometheus_metrics),
5256
Reporters.Statsd.child_spec(opts, metrics: metrics)
5357
]
5458
end

packages/electric-telemetry/lib/electric/telemetry/opts.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,22 @@ defmodule ElectricTelemetry.Opts do
7676
{:struct, Telemetry.Metrics.Distribution}
7777
]}}
7878
],
79+
# Metrics exported only via the Prometheus reporter, in addition to the shared
80+
# `additional_metrics`. Used to expose stack-level metrics on the `/metrics` endpoint
81+
# without double-reporting them through the OTel/StatsD/Call-Home reporters.
82+
additional_prometheus_metrics: [
83+
type:
84+
{:list,
85+
{:or,
86+
[
87+
{:struct, Telemetry.Metrics.Counter},
88+
{:struct, Telemetry.Metrics.LastValue},
89+
{:struct, Telemetry.Metrics.Sum},
90+
{:struct, Telemetry.Metrics.Summary},
91+
{:struct, Telemetry.Metrics.Distribution}
92+
]}},
93+
default: []
94+
],
7995
otel_opts: [
8096
type: :keyword_list,
8197
keys: [

packages/electric-telemetry/test/electric/telemetry/application_telemetry_test.exs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,47 @@ defmodule ElectricTelemetry.ApplicationTelemetryTest do
33

44
alias ElectricTelemetry.ApplicationTelemetry
55

6+
describe "additional_prometheus_metrics" do
7+
setup do
8+
{:ok, opts} =
9+
ElectricTelemetry.validate_options(
10+
instance_id: "test-instance",
11+
version: "1.0.0",
12+
reporters: [prometheus?: true, statsd_host: "localhost", otel_metrics?: true],
13+
additional_prometheus_metrics: [Telemetry.Metrics.last_value("test.custom.gauge")]
14+
)
15+
16+
{:ok, {_flags, children}} = ApplicationTelemetry.init(opts)
17+
%{children: children}
18+
end
19+
20+
test "are exported to the Prometheus reporter", %{children: children} do
21+
metrics = reporter_metrics(children, :prometheus_metrics)
22+
assert Enum.any?(metrics, &(&1.name == [:test, :custom, :gauge]))
23+
end
24+
25+
test "are not exported to the OTel or StatsD reporters", %{children: children} do
26+
refute Enum.any?(
27+
reporter_metrics(children, OtelMetricExporter),
28+
&(&1.name == [:test, :custom, :gauge])
29+
)
30+
31+
refute Enum.any?(
32+
reporter_metrics(children, TelemetryMetricsStatsd),
33+
&(&1.name == [:test, :custom, :gauge])
34+
)
35+
end
36+
end
37+
38+
# `Supervisor.init/2` normalises child specs into maps, nesting the reporter's start args
39+
# (which carry `:metrics`) inside `:start`. Reporters are identified by their child spec id.
40+
defp reporter_metrics(children, id) do
41+
Enum.find_value(children, [], fn
42+
%{id: ^id, start: {_m, _f, [opts]}} when is_list(opts) -> Keyword.get(opts, :metrics, [])
43+
_ -> false
44+
end)
45+
end
46+
647
describe "get_system_memory_usage" do
748
test "returns calculated memory stats" do
849
case :os.type() do

packages/sync-service/lib/electric/application.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,10 @@ defmodule Electric.Application do
413413
if(get_env(opts, :call_home_telemetry?), do: get_env(opts, :telemetry_url)),
414414
otel_metrics?: not is_nil(Application.get_env(:otel_metric_exporter, :otlp_endpoint))
415415
],
416+
# Export the key stack-level metrics (shapes, replication lag, retained WAL) to the
417+
# Prometheus `/metrics` endpoint. They already reach the other reporters per-stack via
418+
# StackTelemetry, so they go through this Prometheus-only seam to avoid double-reporting.
419+
additional_prometheus_metrics: Electric.StackSupervisor.Telemetry.prometheus_metrics(),
416420
otel_opts: get_opts(opts, export_period: :otel_export_period)
417421
]
418422
end

packages/sync-service/lib/electric/stack_supervisor/telemetry.ex

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,52 @@ defmodule Electric.StackSupervisor.Telemetry do
2626
]
2727
end
2828

29+
@doc """
30+
The curated subset of stack-level metrics exported to the Prometheus `/metrics` endpoint.
31+
32+
These are emitted by the periodic measurements above (and, for `receive_lag`, by the
33+
replication client) and are already exported to OTel/StatsD/Call-Home via
34+
`ElectricTelemetry.StackTelemetry`. They are passed to `ApplicationTelemetry` as
35+
`additional_prometheus_metrics` so they reach the single shared Prometheus aggregator
36+
without being double-reported through the other reporters.
37+
38+
Note: these definitions carry no `stack_id` tag, so they assume a single stack per
39+
instance. With multiple stacks the series would collide in the shared aggregator.
40+
"""
41+
def prometheus_metrics do
42+
[
43+
# number of defined shapes
44+
Telemetry.Metrics.last_value("electric.shapes.total_shapes.count"),
45+
# number of active shapes
46+
Telemetry.Metrics.last_value("electric.shapes.active_shapes.count"),
47+
# retained WAL size (bytes Postgres is keeping for Electric's replication slot)
48+
Telemetry.Metrics.last_value("electric.postgres.replication.slot_retained_wal_size",
49+
unit: :byte
50+
),
51+
# replication lag, byte-based: how far behind Postgres' WAL Electric is
52+
Telemetry.Metrics.last_value("electric.postgres.replication.slot_confirmed_flush_lsn_lag",
53+
unit: :byte
54+
),
55+
# replication lag, time-based: wall-clock staleness of received transactions
56+
Telemetry.Metrics.distribution(
57+
"electric.postgres.replication.transaction_received.receive_lag",
58+
unit: :millisecond
59+
),
60+
# HTTP response status-code counts for the shape endpoint (200s, 409s, 50xs, ...).
61+
# The event also carries :known_error and :live; we tag by :status only so the series
62+
# are a plain per-code count.
63+
Telemetry.Metrics.counter("electric.plug.serve_shape.requests.count",
64+
event_name: [:electric, :plug, :serve_shape],
65+
measurement: :count,
66+
tags: [:status]
67+
),
68+
# admission control: current in-flight concurrency per kind
69+
Telemetry.Metrics.last_value("electric.admission_control.acquire.current", tags: [:kind]),
70+
# admission control: number of rejected requests per kind
71+
Telemetry.Metrics.sum("electric.admission_control.reject.count", tags: [:kind])
72+
]
73+
end
74+
2975
def count_shapes(stack_id, _telemetry_opts) do
3076
# Emit active_shapes first so that a failure in shape_counts (e.g. shape cache not yet
3177
# started during stack startup) doesn't drop this metric for the tick.

packages/sync-service/test/electric/config_test.exs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ defmodule Electric.ConfigTest do
5757
)
5858
end
5959

60+
test "configuration/1 exposes the curated stack metrics to Prometheus", ctx do
61+
config =
62+
Electric.Application.configuration(
63+
Keyword.take(ctx.initial_config, [:replication_connection_opts])
64+
)
65+
66+
assert config[:telemetry_opts][:additional_prometheus_metrics] ==
67+
Electric.StackSupervisor.Telemetry.prometheus_metrics()
68+
end
69+
6070
test "api/0" do
6171
Electric.Application.api()
6272
end
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
if Electric.telemetry_enabled?() and Code.ensure_loaded?(ElectricTelemetry.Reporters.Prometheus) do
2+
defmodule Electric.StackSupervisor.TelemetryTest do
3+
use ExUnit.Case, async: true
4+
5+
alias Electric.StackSupervisor.Telemetry
6+
7+
describe "prometheus_metrics/0" do
8+
test "the curated stack metrics are scrapable via the Prometheus reporter" do
9+
name = :"test_prometheus_#{System.unique_integer([:positive])}"
10+
11+
# Build the core through the real reporter so distribution buckets are applied, exactly
12+
# as ApplicationTelemetry does for the /metrics endpoint.
13+
{mod, opts} =
14+
ElectricTelemetry.Reporters.Prometheus.child_spec(
15+
%{reporters: %{prometheus?: true}},
16+
name: name,
17+
metrics: Telemetry.prometheus_metrics()
18+
)
19+
20+
start_supervised!({mod, opts})
21+
22+
# The core attaches its telemetry handlers asynchronously (via a `:setup` message).
23+
# A scrape is a synchronous call to the registry, so it drains that message and
24+
# guarantees the handlers are attached before we emit events below.
25+
_ = TelemetryMetricsPrometheus.Core.scrape(name)
26+
27+
stack_id = "test-stack"
28+
29+
:telemetry.execute(
30+
[:electric, :shapes, :total_shapes],
31+
%{count: 7, count_indexed: 4, count_unindexed: 3},
32+
%{stack_id: stack_id}
33+
)
34+
35+
:telemetry.execute(
36+
[:electric, :shapes, :active_shapes],
37+
%{count: 3},
38+
%{stack_id: stack_id}
39+
)
40+
41+
:telemetry.execute(
42+
[:electric, :postgres, :replication],
43+
%{pg_wal_offset: 1, slot_retained_wal_size: 1234, slot_confirmed_flush_lsn_lag: 56},
44+
%{stack_id: stack_id}
45+
)
46+
47+
:telemetry.execute(
48+
[:electric, :postgres, :replication, :transaction_received],
49+
%{receive_lag: 12, bytes: 0, count: 1, operations: 1},
50+
%{stack_id: stack_id}
51+
)
52+
53+
scrape = TelemetryMetricsPrometheus.Core.scrape(name)
54+
55+
# defined shapes
56+
assert scrape =~ "electric_shapes_total_shapes_count 7"
57+
# active shapes
58+
assert scrape =~ "electric_shapes_active_shapes_count 3"
59+
# retained WAL size
60+
assert scrape =~ "electric_postgres_replication_slot_retained_wal_size 1234"
61+
# byte-based replication lag
62+
assert scrape =~ "electric_postgres_replication_slot_confirmed_flush_lsn_lag 56"
63+
# time-based replication lag (histogram)
64+
assert scrape =~ "electric_postgres_replication_transaction_received_receive_lag"
65+
end
66+
67+
test "per-status HTTP request counts are scrapable" do
68+
name = :"test_prometheus_status_#{System.unique_integer([:positive])}"
69+
70+
{mod, opts} =
71+
ElectricTelemetry.Reporters.Prometheus.child_spec(
72+
%{reporters: %{prometheus?: true}},
73+
name: name,
74+
metrics: Telemetry.prometheus_metrics()
75+
)
76+
77+
start_supervised!({mod, opts})
78+
_ = TelemetryMetricsPrometheus.Core.scrape(name)
79+
80+
for status <- [200, 200, 409] do
81+
:telemetry.execute(
82+
[:electric, :plug, :serve_shape],
83+
%{count: 1},
84+
%{status: status, known_error: false, live: false, stack_id: "test-stack"}
85+
)
86+
end
87+
88+
scrape = TelemetryMetricsPrometheus.Core.scrape(name)
89+
90+
assert scrape =~ ~r/electric_plug_serve_shape_requests_count\{status="200"\} 2/
91+
assert scrape =~ ~r/electric_plug_serve_shape_requests_count\{status="409"\} 1/
92+
end
93+
94+
test "admission control metrics are scrapable" do
95+
name = :"test_prometheus_admission_#{System.unique_integer([:positive])}"
96+
97+
{mod, opts} =
98+
ElectricTelemetry.Reporters.Prometheus.child_spec(
99+
%{reporters: %{prometheus?: true}},
100+
name: name,
101+
metrics: Telemetry.prometheus_metrics()
102+
)
103+
104+
start_supervised!({mod, opts})
105+
_ = TelemetryMetricsPrometheus.Core.scrape(name)
106+
107+
:telemetry.execute(
108+
[:electric, :admission_control, :acquire],
109+
%{count: 1, current: 5, limit: 10},
110+
%{stack_id: "test-stack", kind: :shape}
111+
)
112+
113+
for _ <- 1..2 do
114+
:telemetry.execute(
115+
[:electric, :admission_control, :reject],
116+
%{count: 1, limit: 10},
117+
%{stack_id: "test-stack", reason: :overloaded, kind: :shape, current: 11}
118+
)
119+
end
120+
121+
scrape = TelemetryMetricsPrometheus.Core.scrape(name)
122+
123+
# current concurrency gauge
124+
assert scrape =~ ~r/electric_admission_control_acquire_current\{kind="shape"\} 5/
125+
# rejection count
126+
assert scrape =~ ~r/electric_admission_control_reject_count\{kind="shape"\} 2/
127+
end
128+
end
129+
end
130+
end

0 commit comments

Comments
 (0)