Skip to content

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.