Sending Metrics with OTLP¶
Who needs this book
Anyone who wants a number measured inside the database — a connection count, a queue depth, a business figure — to appear in Grafana or another metrics system, without running a separate exporter. If you do not use OpenTelemetry or Grafana, you can skip this book.
Since v1.5, pg_relay can push metrics straight out of the database to any receiver that speaks the OpenTelemetry Protocol (OTLP) over HTTP/JSON — Grafana Alloy's OTLP intake is the driving use case (see Wiring Up Grafana Alloy), but the wire format is the OTLP spec itself, not anything Alloy-specific: an OpenTelemetry Collector, Grafana Cloud's own OTLP gateway, or any other OTLP-compatible endpoint works the same way.
The mechanism is deliberately unsurprising if you already know pg_relay: pg_relay.otlp is a new action type, registered on an ordinary channel like any other, sent to with the ordinary pgrelay.notify() you already use. There is no new protocol between your application and pg_relay — a metric point is just a queue event, so deferral (p_run_at), deduplication, and pgrelay.cancel() all work on it unchanged, and pairing it with a schedule for recurring metrics (below) is a normal schedule_job() call.
One-time setup¶
Register a channel whose action type is pg_relay.otlp. Unlike the channels pg_relay reserves for itself (pg_relay.health, pg_relay.exec), an otlp channel is an ordinary registration — its action column carries a small JSON object naming where this channel's metrics go, not SQL and not an empty string:
SELECT pgrelay.register('otel_metrics',
'{"endpoint": "http://localhost:4318/v1/metrics",
"service_name": "pg-collector",
"service_namespace": "platform",
"deployment_environment": "prod"}',
p_action_type := 'pg_relay.otlp');
The channel config is where the metric's resource identity lives — which service (service_name), in which namespace (service_namespace), in which environment (deployment_environment). These describe the emitter, are the same for every point the channel sends, and are what Grafana groups by; they are set once here, never in the per-point payload below. Only endpoint is required, but set all three from the start if Grafana is the destination — see Resource attributes.
Any channel name works, and you can register as many otlp channels as you like — each with its own endpoint and identity, so a local Alloy instance and a cloud OTLP gateway can both be targets from the same database, on different channels.
Sending a metric point¶
The notify() payload is the metric — one gauge reading per event:
SELECT pgrelay.notify('otel_metrics',
'{"name": "pg_connections_active", "value": 42,
"timestamp": "2026-09-01T10:00:00Z",
"attributes": {"database": "prod"}}');
The payload carries the measurement only — the resource identity (service_name and friends) comes from the channel config above and is never written here.
| Key | Required | Meaning |
|---|---|---|
name |
yes | The metric name, exactly as it will appear at the receiver |
value |
yes | A JSON number, unquoted (42, not "42" — a quoted value is rejected). pg_relay converts it to OTLP's wire form itself: a whole number becomes asInt, anything with a fractional part becomes asDouble |
timestamp |
yes | RFC 3339. The moment the measurement was actually taken — see why this is required, not defaulted below |
attributes |
no | A flat object of labels. Strings, numbers, and booleans each map to the matching OTLP attribute type automatically — a nested object or array is rejected with an error naming the key |
Within about a second the Processor claims the event, wraps the point in the standard OTLP export-request envelope — a resourceMetrics entry carrying the channel's resource identity (service.name from service_name, default pg_relay; optionally service.namespace and deployment.environment.name from service_namespace/deployment_environment, which is how Grafana groups services and assigns them to environments; plus host.name, always this Processor's own hostname, never configurable), one scopeMetrics, one gauge metric with one dataPoint — and POSTs it to the channel's endpoint. For a developer's walk-through of assembling the payload — what goes in the resource identity versus the point's own attributes, naming, timestamps, and verification — see Building a Metric Document. A normal pgrelay.log audit row records the outcome, exactly like any other channel:
SELECT channel, status, error, elapsed_ms FROM pgrelay.log WHERE channel = 'otel_metrics' ORDER BY actioned_at DESC LIMIT 5;
Why timestamp is required¶
Every other required field in a pg_relay payload speaks for itself; timestamp deserves a word of explanation, because it is easy to assume pg_relay would just stamp "now" onto the point at send time — and deliberately does not.
A queue event does not always dispatch the instant it is created: the Processor polls once a second, and under backoff or a brief backlog a row can sit pending for a few seconds longer. If pg_relay used send time, a metric describing "42 connections at 10:00:00" could reach your receiver stamped 10:00:04 — a small drift most of the time, but a real one under load, and a silent one, since nothing about the send would look wrong. Requiring the producer to supply the actual measurement time removes the ambiguity entirely: whatever you send is what the receiver records, queue delay or not.
Gauges only, for now¶
v1.5 ships one metric point type — the gauge: a value that goes up or down, read at a point in time (a connection count, a queue depth, a percentage, a temperature). OTLP's other point types — the monotonic counter (sum) and the histogram — are not yet supported; sending one requires supplying more structure than a single notify() payload currently carries (a counter's aggregation temporality, a histogram's bucket boundaries), and that contract hasn't been designed yet. If your metric is naturally a running total or a distribution, note it as a limitation for now — a future delta may add it.
Recurring metrics¶
A single notify() call sends one point once. For a metric you want to sample on a schedule — "connection count every 30 seconds," "queue depth every minute" — pair pg_relay.otlp with Scheduled Jobs: write a small function that computes the current value and calls notify(), then schedule it:
CREATE FUNCTION report_connections() RETURNS void LANGUAGE sql AS $$
SELECT pgrelay.notify('otel_metrics', jsonb_build_object(
'name', 'pg_connections_active',
'value', (SELECT count(*) FROM pg_stat_activity WHERE state = 'active'),
'timestamp', to_char(now(), 'YYYY-MM-DD"T"HH24:MI:SSOF'))::text);
$$;
SELECT pgrelay.schedule_job('report_connections', '30 seconds', 'SELECT report_connections()');
The schedule fires the function every 30 seconds, on the same 1-second Processor poll and durable queue as everything else in pg_relay — no separate exporter process to run.
Reserved-channel comparison¶
pg_relay.health / pg_relay.exec |
pg_relay.otlp |
|
|---|---|---|
| Channel | A single seeded, reserved channel (or empty-action channels you register) | Ordinary channels you register yourself, any number of them |
action column |
Empty — the Processor needs no per-channel config | A JSON object naming the endpoint (and optional auth) |
| Who can register one | Anyone with register() access, for exec/health only via the reserved channel or their own empty-action channel |
Anyone with register() access, freely |
Continue to Wiring Up Grafana Alloy, jump straight to the Configuration and Payload Reference, or — if you are the developer writing the producer — go to Building a Metric Document.