A database receiver is not database observability: PostgreSQL with OpenTelemetry
By Nicolas Narbais
The PostgreSQL receiver reports what the server is doing; your traces report what the request felt. Neither joins the other. A staged guide to server metrics, client spans and pool telemetry, with what each one cannot tell you.
The PostgreSQL receiver is scraping and your application traces arrive. However, you still struggle to explain the slow request.
Nothing is technically broken. The receiver asks PostgreSQL what the server is doing and your instrumented application records which request called the database and how long that call took from the caller’s side. But both of them are disjointed: neither one correlates to the other, and no exporter setting joins them on your behalf.
This is a staged guide to PostgreSQL with OpenTelemetry, and this first part covers the two stages every deployment should have: server metrics from the receiver, and database-client spans with pool telemetry from the application. Each is useful on its own if you stop there, and for a large number of services stopping there is the right answer. The stages that follow: query samples, blocking evidence, and workload-level query analysis ask for privileges and can expose query text, so they get their own post and their own decision.
Version Everything below assumes OpenTelemetry Collector Contrib v0.158.0 (released 2026-08-04).
Four producers, one destination, and exactly one join between any two of them (the dashed one) which does not exist until you write it. OTLP moves telemetry, you still need some work to setup the join.
The receiver is working and the incident is still unexplained
You are on call. A service’s latency has moved and the trace shows a long PostgreSQL client span. What now?
The span tells you how long the database operation took from the client perspective. But that’s not enough: whether pool acquisition, connection setup or a driver-level retry sits inside that duration or outside it depends on the instrumentation you installed, not on PostgreSQL. And it does not tell you which of these is true:
- the pool is exhausted and the wait is queueing, not query time
- one query shape got slower for everyone
- this statement is blocked behind another transaction
- reads went to a replica that fell behind
- PostgreSQL is fine and the machine underneath it is not
- PostgreSQL is fine and something between the app and the server is not
Keep that list on the side. Each hypothesis has evidence that can confirm it, eliminate it, or narrow down the root cause, and this article and the next will cover them all.
Your receiver dashboard can tell you what PostgreSQL was doing at the time, but it cannot tell you which application request caused that activity.
PostgreSQL already gives us good answers to the server-side question: what is the database doing right now? The harder part is connecting that server evidence back to the symptom (user and app visible) that started the investigation. With that connection, you will then have an observable database.
Observability is connecting the user’s symptom to the evidence that explains it.
Four producers, four questions
Before any YAML, get the producers straight. There are four, they are independent, and each answers a question the others cannot.
| Layer | What produces it | What it answers | What it cannot answer alone |
|---|---|---|---|
| Application | Database-client instrumentation, plus connection-pool metrics where your library exposes them | Which request called PostgreSQL, how long the call took, whether it failed, and whether it spent that time queueing for a connection | What the database was doing internally |
| PostgreSQL receiver | Statistics queries against the server, plus opt-in receiver logs | Whether the server or a database is under pressure, and selected query evidence when enabled | Which application request caused a server observation |
| Infrastructure | Host, container, network, proxy, or managed-platform telemetry | Whether the machine or path underneath PostgreSQL is saturated, throttled, waiting on storage, or experiencing network trouble | Anything about query semantics or request causality |
| PostgreSQL diagnostics | pg_stat_statements, activity and lock views, a deliberately chosen plan command | Which query shape, wait, or plan deserves attention | Whether collecting it continuously is safe or appropriate |
That third row is often missed in articles. The PostgreSQL receiver reads PostgreSQL’s own statistics views. CPU saturation, memory pressure, storage latency and IOPS, network behaviour, container CPU throttling, proxy behaviour, and whatever your managed provider exposes are all outside it. A database can look healthy by every statistic it reports about itself while sitting on a volume that has run out of burst credit or behind a troubled network path. This article covers the other three producers in depth and does not attempt to teach infrastructure monitoring but I wanted to make sure we covers all the angles in this article.
Stage 1: server health, and the account that reads it
Receiver metrics give you database health and capacity context, they need no application change, and they are “usually” low overhead. The receiver runs statistics queries on an interval, some of them returning a row per table or per index, and v0.158.0 now runs lock collection against every configured database instead of only postgres. On a server with a large schema or a long databases list, that can add up, so keep an eye on the cost of collection.
The YAML is the relatively simple. The account to connect to the database and how the telemetry is identified is what is worth covering now.
The monitoring role
The receiver README’s baseline requirement is that “the monitoring user must be granted SELECT on pg_stat_database”. Just add this and add nothing else, because PostgreSQL’s predefined-role page carries a warning worth quoting when someone asks you to just grant pg_monitor and move on: “Care should be taken when granting these roles to ensure they are only used where needed.”
PostgreSQL does ship predefined monitoring roles: pg_monitor, and the pg_read_all_settings, pg_read_all_stats and pg_stat_scan_tables roles it aggregates. In the example below, otel_monitor is a dedicated login role you create, granted only what your enabled collection actually requires. Adapt the snippet below based on your version and metric requirements.
-- Baseline metrics only. One account, no ownership of anything.
CREATE ROLE otel_monitor WITH LOGIN PASSWORD 'set-from-your-secret-store';
-- Per database you list in the receiver config
GRANT CONNECT ON DATABASE orders TO otel_monitor;
GRANT CONNECT ON DATABASE billing TO otel_monitor;
-- The documented baseline requirement
GRANT SELECT ON pg_stat_database TO otel_monitor;
That is the base setup.
Enabling an opt-in metric can still push you past that baseline; I did not test those one by one, so approach the addition of other metrics step by step.
Resource identity, and the feature gate that moves it
The receiver is migrating toward the OpenTelemetry semantic-convention resource model behind the alpha receiver.postgresql.useOTelSemconv feature gate. With the gate off, database, table and index names are represented as resource attributes such as postgresql.database.name. With it on, the PostgreSQL server becomes the resource (server.address, server.port) and database/table identity moves to metric datapoint attributes such as db.namespace and db.collection.name.
This does not give you additional PostgreSQL visibility. It just changes how the same telemetry is identified, which can break dashboards or queries. Unless you have a reason to adopt the new model, record the feature-gate state and treat changing it as a telemetry-schema migration.
Here is the actual move, captured from both sides of the gate on v0.158.0.
| Gate off | Gate on | |
|---|---|---|
| Resource attributes | postgresql.database.name, postgresql.table.name, postgresql.index.name | server.address, server.port |
| Datapoint attributes | source, type, operation, state (on six metrics only) | the above plus db.namespace on twelve metrics, db.collection.name on seven, postgresql.index.name on two |
Side note: resource attributes describe the entity producing a group of telemetry, while datapoint attributes describe individual measurements within a metric. With the gate enabled, the receiver treats the PostgreSQL server as the resource and moves database, table, and index identity onto the individual datapoints.
Two things to extract from the release notes.
postgresql.table.namebecomesdb.collection.name,- but
postgresql.index.namestays the same and simply relocates to the datapoints. And the direction of travel is resource → datapoint, so on a server with a large schema you are moving identity from a handful of resources onto every datapoint.
The configuration, and the pipeline it needs
The shape of the configuration, annotated rather than copied:
receivers:
postgresql:
endpoint: postgres.internal:5432
transport: tcp
username: otel_monitor # scoped role, see above
password: ${env:PG_MONITOR_PASSWORD}
databases: [orders, billing] # optional: deliberately restrict collection to these databases
collection_interval: 60s # start slow, this is a query load
tls:
insecure: false
insecure_skip_verify: false # the receiver's default here is true
ca_file: /etc/otel/pg-ca.pem
# metrics: opt-in additions go here, one at a time, each with a reason
A configured receiver still collects nothing until a pipeline references it, and this is where the split that runs through the whole article shows up in YAML. Receiver health data is metrics; the query samples and top queries of stages 3 and 4 are logs. Two signals, two pipelines:
service:
pipelines:
metrics:
receivers: [postgresql]
# processors / exporters
logs:
receivers: [postgresql] # query samples and top queries land here
# processors / exporters
Stage 1 only needs the metrics pipeline. Wire the logs one when you reach stage 3, and if you skip it there, the events you enabled are collected and then silently dropped.
Watch the monitoring itself
Now looking at the telemetry is important but when nothing reports, this can mean:
- the role is missing a grant,
- a receiver query failed,
- the wrong database was listed,
- the PostgreSQL version does not support what you enabled,
- the export failed,
- or the database genuinely had nothing to report.
Those are six different fixes for the same empty widget/dashboard. Collect the Collector’s own errors and collection duration, and keep an eye on the PostgreSQL activity belonging to your monitoring account. It makes your setup debuggable and tells you what your scrapes cost. If you have not set that up before, debugging a Collector pipeline locally is the shorter version of this problem with fewer moving parts.
The metrics worth putting in the first view
The receiver can emit a long list of metrics. The five one below help you answer some critical questions. Alert on the user-facing SLO, then use these to confirm or eliminate the database from the root cause. The names are from v0.158.0 (but the individual metric definitions are Development stability).
| Signal | What it can reveal | Alerting posture | Qualification |
|---|---|---|---|
postgresql.backends with postgresql.connection.max | Connection pressure, unexpected backend growth | Warn on sustained pressure coinciding with client-span latency or errors; page only when a service SLO is threatened | The receiver counts rows in pg_stat_activity per database, which includes non-client backends such as autovacuum and parallel workers; the maximum applies to client connections. Do not present a ratio of the two as exact utilisation. |
postgresql.deadlocks (opt-in) | A real concurrency failure needing a query or transaction fix | Investigate any unexpected increase; page if it is driving request errors | The counter says a deadlock happened, not which request or lock cycle caused it. |
postgresql.rollbacks against postgresql.commits | A shift in failed or aborted transaction behaviour | Alert on a sustained ratio change from that service’s own baseline | Plenty of workloads roll back by design. This is a change detector. |
postgresql.replication.data_delay or postgresql.wal.lag (opt-in) | Replica freshness, read and failover risk | Page against the application’s freshness or RPO requirement | Topology-dependent. Do not enable a replica alert on a primary-only deployment. |
postgresql.db_size with host volume free space | Capacity trend, a storage problem you can still schedule | Forecast and warn on growth against remaining disk | Database size is not free space. It needs infrastructure storage telemetry beside it. |
Some useful PostgreSQL metrics are opt-in and are not collected by default. This includes deadlocks and replication signals such as replica lag. Enable the metrics you need explicitly in the receiver’s metrics: configuration before anything.
Always check whether a metric is enabled before assuming that missing data means nothing is happening.
metrics:
postgresql.deadlocks:
enabled: true
postgresql.replication.data_delay:
enabled: true
postgresql.wal.lag:
enabled: true
I would not alert on every metric the receiver exposes.
- Block reads and cache hits can help investigate an I/O hypothesis;
- temporary-file I/O can reveal queries spilling work to disk;
- lock metrics can expose contention;
- and
postgresql.query.execution.timecan show a change in database workload. These are useful diagnostic signals, but there is no global healthy value for them. Start by collecting or enabling them when you have a question they can answer, and promote one to a permanent dashboard or alert only when your own incidents show that it is useful.
No thresholds appear in that table, and this makes sense since every environment is different.
So stage 1 leaves you with a database you can see: how much of it is in use, whether it is losing transactions to deadlocks or rollbacks, whether a replica has drifted, and how much runway the disk has. What it cannot do is point at a request. Stage 2 adds the caller’s side of the story.
Stage 2: client spans, because the request is what people notice
Alerts should fire on user-visible symptoms, which means the entry point to a database investigation is a span, not a server metric. Stage 2 is instrumenting the caller.
The attributes that identify a database call
The database client semantic conventions define what should be set.
db.system.nameis required, which is what lets you separate PostgreSQL calls from everything else your service talks to.- Server address, port, and
db.namespaceidentify the target. db.operation.namenames the operation, anddb.query.summarygives you a low-cardinality way to group a query shape where the instrumentation can generate one.
Two of those carry most of the weight, and they are the two to get right first:
db.system.nameidentifies the database technology. For PostgreSQL it ispostgresql, on every client span in your estate. It is not the name of your database.db.namespaceidentifies the database namespace. For PostgreSQL, OpenTelemetry currently recommends qualifying the schema with the database name using{database}|{schema}, for exampleorders|publicbut we often see users just using the database name.
Older instrumentation emits the legacy pair instead, db.system and db.name so make sure your queries adapt to those.
Be careful when relying on db.namespace for correlation. Inspect a real client span and a real receiver record before you build a dashboard or a correlation on top of either.
For instance, in the versions tested for this article, both the PostgreSQL receiver and Node instrumentation-pg emitted only the database name:
client span db.namespace = app db.system.name = postgresql
receiver record db.namespace = app db.system.name = postgresql
That happens to make the two signals easy to match, but do not assume your deployment will behave the same way.
Legacy or stable? Your instrumentation version decides
Which pair of names you actually get is decided by your instrumentation version. OTEL_SEMCONV_STABILITY_OPT_IN=database is the documented switch for versions that support it. Same application, same environment variable, only the package version changed:
@opentelemetry/instrumentation-pg | What the pg.query span carried |
|---|---|
| 0.47.1 | db.system, db.name, db.statement, db.user, db.connection_string, net.peer.name, net.peer.port |
| 0.73.0 | db.system.name, db.namespace, db.query.text, server.address, server.port |
One detail in that table is a security note rather than a naming one: the older version emits db.connection_string, which seems to have leaked a lot of passwords in the past.
Query text is sensitive by default
SQL needs some care because it can contain sensitive data. The OpenTelemetry conventions distinguish between three cases:
- Parameterized query text can be collected by default. For example,
SELECT * FROM users WHERE id = $1contains the query structure but not the value of$1. - Non-parameterized query text should not be collected unless it is sanitized first. For example,
SELECT * FROM users WHERE email = '[email protected]'already contains user data. - Query parameter values, exposed as
db.query.parameter.<key>, are opt-in because the values themselves may contain PII or other sensitive data.
Parameterized does not mean safe. It reduces the risk of exposing values, but the SQL can still reveal sensitive table or column names, and applications can still inline values into otherwise parameterized queries. Treat db.query.text as potentially sensitive data and check what your driver actually emits before sending it to your telemetry backend.
Your driver decides what you actually get
Database instrumentation depends on the language and client library, not just on PostgreSQL. Python, for example, has separate OpenTelemetry instrumentation packages for psycopg, psycopg2 and other database clients. Node has instrumentation for pg and pg-pool. Coverage and features vary by driver, so verify that the instrumentation you actually use emits the spans, query information, and pool telemetry you expect.
Make sure to always verify. A Node application using ESM, with a plain import pg from 'pg', produced pg-pool.connect spans and no query spans whatsoever. In my tests, the instrumentation was installed, loaded, and visibly working, it was emitting connection spans, while the thing I actually want was not coming. Fix: The cause is that instrumentation-pg patches the CommonJS module and an ESM import resolves around the patch. Loading pg through createRequire fixed it; registering the OpenTelemetry ESM loader hook is the other way.
The failure mode is what matters here: just a database whose calls never appear in a trace.
The span fields worth looking at first
A handful of fields carry most investigations:
- Duration and span status, to establish that PostgreSQL time is part of the user-visible problem at all
db.system.name, to keep database technologies apart in one viewdb.namespacewithserver.addressandserver.port, to identify which database and endpoint, when a service talks to several or sits behind a proxy, after you have checked what your instrumentation puts indb.namespaceerror.typeand the database response status where the instrumentation populates them, to separate failures from slow successesdb.operation.name, to group by operation; it is conditionally required where a single operation name describes the call, so it is often present whendb.query.summaryis notdb.query.summary, to group a query shape without exposing the statement, when the instrumentation can derive one
Trace and span IDs matter too, but as a join key rather than a field you read. They only become useful in stage 3.
The signal that answers “is it the pool?”
Spans alone will not settle the first hypothesis from the opening list. A span that took 400ms because the query took 400ms and a span that took 400ms because the request queued 390ms waiting for a free connection can look identical if the pool wait happens outside the span and whether it does is an instrumentation detail, not something you can infer from PostgreSQL.
The database client conventions define metrics for this, all under db.client.connection. and keyed by db.client.connection.pool.name:
| Metric | The question it answers | Emitted by Node instrumentation-pg 0.73.0 |
|---|---|---|
db.client.connection.pending_requests | How many requests are queued for a connection right now | yes |
db.client.connection.count | Pool occupancy, split by db.client.connection.state = idle or used | yes |
db.client.connection.wait_time | How long they wait, as a histogram, which you can compare with request and database-span latency | no |
db.client.connection.timeouts | How often waiting ended in giving up | no |
.max, .idle.min, .idle.max | Whether configured limits support an exhaustion hypothesis | no |
db.client.connection.create_time and .use_time | Whether new connections are slow to establish, or held for unusually long periods | no |
That third column is why the recommendation has to stay conditional. These are all Development stability in the conventions, and instrumentation support is not close to complete: the most common Node PostgreSQL driver gives you two of them. wait_time, the histogram that actually separates queue time from query time (the entire reason this section exists). What you can do with what is there: pending_requests above zero at the same moment your spans slow down is strong evidence, and count split by state tells you whether the pool is saturated. You are inferring the wait rather than measuring it.
Check how your connection pools are identified
Pool metrics use db.client.connection.pool.name to distinguish one connection pool from another.
With Node instrumentation-pg 0.73.0, I found that this name depends on how the PostgreSQL connection is configured. When PGHOST, PGPORT, and PGDATABASE came from environment variables, the instrumentation reported:
db.client.connection.pool.name = unknown_host:unknown_port/unknown_database
When I passed the same connection details directly to the pool configuration, it reported:
db.client.connection.pool.name = postgres:5432/app
This matters if your application has multiple connection pools. If they all report the same unknown_host:unknown_port/unknown_database value, their metrics can be grouped together and you may not be able to tell which pool is under pressure.
Before relying on pool metrics, check the value of db.client.connection.pool.name in your own telemetry and make sure each pool can be identified.
The rollout order for these two stages
- Receiver metrics with a scoped role, an explicit database list, TLS verified, and a conservative interval. Record the version and feature-gate choice. Confirm you have infrastructure and network-path telemetry for the environment PostgreSQL depends on.
- Client spans and pool metrics from one pinned driver instrumentation. Verify the semantic-convention fields your backend needs, and find out whether your pool library exposes
db.client.connection.*.
If you only remember one sentence
A receiver tells you what PostgreSQL is doing; observability is connecting the user’s symptom to the evidence that explains it, then choosing the next safe question.
Server metrics, client spans, pool telemetry and infrastructure context get you four of the six hypotheses from the top of this post, which is further than most deployments get. So here is a check worth running this week: open a recent trace with a database span and try to name the evidence that would distinguish pool queueing, infrastructure pressure and a network-path problem. Whatever you cannot distinguish is your next gap, and if it turns out to be which query shape or what was blocking it, that is part two, where the answer costs a grant and a data-exposure decision.
Written by Nicolas Narbais
I work at Tsuga and write about observability, OpenTelemetry, and the practical work of making monitoring useful for engineering teams. Earlier Datadog experience also informs the guidance shared here. I am also running Olatuak to help teams reduce telemetry waste and improve observability outcomes.
Building an OpenTelemetry pipeline?
Explore more implementation guides and collector patterns for teams standardizing telemetry without adding unnecessary noise.