sqlx-otel
Lightweight SQLx wrapper that emits OpenTelemetry-native spans and metrics following the database client semantic conventions.
The wrapper talks to the opentelemetry API directly – there is no tracing bridge in the path. That means a smaller dependency tree, attributes set per the spec rather than translated through a second model, and zero cost when no TracerProvider or MeterProvider is installed.
Full API documentation, including runnable examples for every public type, is on docs.rs/sqlx-otel.
Highlights
- Spans on every operation. Every
sqlx::Executormethod emits aSpanKind::Clientspan carrying the OTel database-spans recommended set:db.system.name,db.namespace,server.address/port,network.peer.address/port,network.protocol.name,network.transport,db.client.connection.pool.name,db.query.text, returned/affected row counts, and SQLSTATE /error.typeon failure. - Span/metric attribute parity. The
db.client.operation.durationhistogram carries the same bounded attribute set as spans – every annotation, every error-path attribute, every connection-level attribute – so dashboards can slice query latency by operation verb, target collection, error class, or pool name.db.query.textis the only span attribute deliberately excluded from metrics for cardinality. - Caller-supplied annotations. The library does not parse SQL. Per-query attributes (
db.operation.name,db.collection.name,db.query.summary,db.stored_procedure.name) come from a small annotation API. - Three backends, one API. Postgres, SQLite, and MySQL behind feature flags; the wrapper API is identical across all three.
- Drop-in.
&Pool<DB>implementssqlx::Executor, so existing call sites keep working unchanged.
Quick start
| Feature flag | Purpose |
|---|---|
sqlite / postgres / mysql |
Per-backend support – enable at least one. |
runtime-tokio / runtime-async-std |
Background polling for db.client.connection.count. Optional. |
[]
= { = "0.5.0", = ["postgres", "runtime-tokio"] }
use PoolBuilder;
// Wrap an existing sqlx pool. Connection-level attributes (host, port, namespace)
// are auto-extracted from the underlying connect options.
let raw = connect.await?;
let pool = from.build;
// Use it exactly like a sqlx pool.
let row: = query_as.fetch_one.await?;
// Transactions work via `&mut tx`.
let mut tx = pool.begin.await?;
query
.bind
.execute
.await?;
tx.commit.await?;
The wrapper itself is a no-op until your application installs an OpenTelemetry TracerProvider and/or MeterProvider. Setting that up is the application's responsibility – pair this crate with the opentelemetry_sdk (or any other compliant SDK) and your chosen exporter.
Annotations matter. Without the per-query annotation API (below), spans carry
db.system.nameanddb.query.textbut nodb.operation.nameordb.collection.name– the most useful filtering attributes. Plan to annotate your queries.
For a complete runnable end-to-end example – pool wrap, in-memory SDK, queries, and printed telemetry – see examples/sqlite.rs:
Configuration
PoolBuilder overrides the auto-extracted attributes and tunes capture behaviour:
use ;
use Duration;
let pool = from
.with_database // override db.namespace
.with_host // override server.address
.with_port // override server.port
.with_network_peer_address // network.peer.address (not auto-extracted)
.with_network_peer_port // network.peer.port (not auto-extracted)
.with_network_protocol_name // override network.protocol.name (defaulted per backend)
.with_network_transport // network.transport (not inferred from connect string)
.with_query_text_mode
.with_pool_name // db.client.connection.pool.name + connection-count polling
.with_pool_metrics_interval
.build;
with_pool_name populates db.client.connection.pool.name on every span and per-operation metric, and is the switch that activates db.client.connection.count polling – the gauge is silent until both a pool name and a runtime feature are present.
network.protocol.name defaults to the backend's wire protocol ("postgresql" for Postgres, "mysql" for MySQL, absent for SQLite); override only when the connection is tunnelled through a different application-layer protocol. network.transport is not inferred from the connect string – callers who want this attribute on spans / metrics must declare it explicitly so the value reflects the deployment configuration rather than a guess.
Per-query annotations
Because the library does not parse SQL, per-query attributes are the caller's responsibility:
use ;
query
.bind
.with_annotations
.execute
.await?;
// Shorthand for the common operation/collection pair:
query
.with_operation
.bind
.execute
.await?;
The same with_annotations / with_operation methods are available on:
- The query builders returned by
sqlx::query,sqlx::query_as, andsqlx::query_scalar(via theQueryAnnotateExttrait). - The
Mapreturned byquery::map/query::try_map, and the compile-time-validated macro forms (sqlx::query!(),sqlx::query_as!(),sqlx::query_scalar!()) – which expand to eitherQueryorMap. Pool,PoolConnection, andTransactiondirectly, viapool.with_annotations(…).fetch_all(…).
When annotations are set, the span name follows the semantic-convention hierarchy: db.query.summary → "{operation} {collection}" → "{operation}" → "{db.system.name}".
| Attribute | Builder method |
|---|---|
db.operation.name |
.operation() |
db.collection.name |
.collection() |
db.query.summary |
.query_summary() |
db.stored_procedure.name |
.stored_procedure() |
See QueryAnnotations and QueryAnnotateExt on docs.rs for the full surface and worked examples.
Query text modes
| Mode | Behaviour |
|---|---|
Full (default) |
Capture the parameterised query. Safe because SQLx uses bind parameters. |
Obfuscated |
Replace literal values (string, numeric, hex, boolean, dollar-quoted) with ? in db.query.text. |
Off |
Do not capture db.query.text. |
Obfuscated is useful when SQL is constructed via string interpolation rather than bind parameters – the structure of the query is preserved while sensitive literal values are redacted. Comments, identifiers (quoted or otherwise), operators, and NULL are kept verbatim.
Both Full and Obfuscated collapse inter-token whitespace runs to a single space and trim leading/trailing whitespace before emitting db.query.text, so multi-line SQL written for source-level readability renders cleanly in OTel exports. Whitespace inside string literals, quoted identifiers, dollar-quoted bodies, and comments is preserved verbatim. For example, an UPSERT split across multiple lines for readability:
INSERT INTO items (id, name, qty)
VALUES (?1, ?2, ?3)
ON CONFLICT (id) DO UPDATE SET
name = excluded.name,
qty = excluded.qty
emits as a single readable line in db.query.text:
INSERT INTO items (id, name, qty) VALUES (?1, ?2, ?3) ON CONFLICT (id) DO UPDATE SET name = excluded.name, qty = excluded.qty
Reference
Span attributes
Set on every Executor method (execute, fetch, fetch_all, fetch_one, fetch_optional, fetch_many, execute_many, prepare, prepare_with, describe):
| Attribute | Source | Condition |
|---|---|---|
db.system.name |
Backend ("postgresql", "sqlite", "mysql") |
Always |
db.namespace |
Database name, extracted from connect options | When available |
server.address |
Hostname, extracted from connect options | When available |
server.port |
Port, extracted from connect options | When available |
network.peer.address |
Resolved IP address | When set via builder |
network.peer.port |
Resolved port | When set via builder |
network.protocol.name |
Wire protocol; defaults per backend, overridable on builder | When applicable |
network.transport |
OSI L4 transport ("tcp", "unix", "pipe", "inproc") |
When set via builder |
db.client.connection.pool.name |
Pool identifier set via with_pool_name |
When set via builder |
db.query.text |
The SQL query string with inter-token whitespace collapsed | Unless QueryTextMode::Off |
db.operation.name |
Database operation (e.g. SELECT) |
When annotated |
db.collection.name |
Target table or collection | When annotated |
db.query.summary |
Low-cardinality query summary | When annotated |
db.stored_procedure.name |
Stored procedure name | When annotated |
db.response.returned_rows |
Row count | On fetch* methods |
db.response.affected_rows |
Rows affected (rows_affected()) |
On execute |
db.response.status_code |
SQLSTATE error code | On database errors |
error.type |
Error variant name | On any error |
db.response.affected_rows is not part of the OpenTelemetry semantic conventions, but we find it useful so have included it. It is a custom attribute that reports the database-confirmed count from QueryResult::rows_affected(), carrying the same connection-level attributes as db.response.returned_rows. It is not recorded for execute_many, which is considered deprecated by the SQLx team.
On error, the span status is set to Error and an exception event is added with exception.type and exception.message attributes.
Operation metrics
| Instrument | Type | Unit | Description |
|---|---|---|---|
db.client.operation.duration |
Histogram | s |
Duration of each database operation |
db.client.response.returned_rows |
Histogram | Number of rows returned per fetch* call |
|
db.client.response.affected_rows |
Histogram | Rows affected per execute call (custom) |
These mirror the bounded portion of the span attribute set: connection-level attributes (db.system.name, db.namespace, server.address/port, network.peer.address/port, network.protocol.name, network.transport, db.client.connection.pool.name – wherever set), plus annotation-derived attributes (db.operation.name, db.collection.name, db.query.summary, db.stored_procedure.name) when present, plus error-path attributes (error.type, plus db.response.status_code for sqlx::Error::Database) on the error path. db.query.text is deliberately excluded for cardinality; db.query.summary is caller-controlled and inherits its cardinality cost from the span side.
db.client.response.affected_rows is not part of the OpenTelemetry semantic conventions – we ship it for the same reason as the matching span attribute: backends report a useful database-confirmed count that's worth slicing alongside duration. It is recorded only on execute() calls (where QueryResult::rows_affected() is meaningful) and is not recorded for execute_many (deprecated upstream).
Connection pool metrics
| Instrument | Type | Unit | Description |
|---|---|---|---|
db.client.connection.wait_time |
Histogram | s |
Time spent waiting for a connection in acquire() |
db.client.connection.use_time |
Histogram | s |
Time a connection was held before being returned |
db.client.connection.timeouts |
Counter | Number of acquire attempts that timed out | |
db.client.connection.pending_requests |
UpDownCounter | Number of callers currently waiting in acquire() |
|
db.client.connection.count |
Gauge | Current connections by state (idle/used) |
|
db.client.connection.max |
Gauge | Maximum number of connections allowed | |
db.client.connection.idle.max |
Gauge | Maximum idle connections (equals max in SQLx) |
|
db.client.connection.idle.min |
Gauge | Configured minimum connections |
The first four are recorded inline on every acquire() / connection drop – no sampling gaps. db.client.connection.count is polled by a background task and requires both a runtime feature (runtime-tokio or runtime-async-std) and a pool name set via PoolBuilder::with_pool_name. The remaining three are static gauges recorded once at pool construction.
Backend support
| Backend | db.namespace |
server.address / server.port |
Default network.protocol.name |
Notes |
|---|---|---|---|---|
postgres |
Database name | Yes (from connect URL) | "postgresql" |
– |
mysql |
Database name | Yes (from connect URL) | "mysql" |
– |
sqlite |
File path or :memory: URI |
n/a | absent (embedded; no wire protocol) | No host/port; file or in-memory. |
db.response.returned_rows, db.response.affected_rows, db.response.status_code, and error.type are recorded uniformly across all three backends.
Compatibility
| sqlx-otel | sqlx | opentelemetry | MSRV |
|---|---|---|---|
0.5.x |
0.9.x |
0.32.x |
1.94.0 |
0.4.x |
0.8.x |
0.32.x |
1.85.0 |
0.3.x |
0.8.x |
0.31.x |
1.85.0 |
0.2.x |
0.8.x |
0.31.x |
1.85.0 |
0.1.x |
0.8.x |
0.31.x |
1.85.0 |
Pick the row matching the sqlx and opentelemetry versions your application already uses. Older releases stay on crates.io indefinitely and are never yanked, so pinning to an earlier row is a supported way to stay put.
Versioning policy
sqlx and opentelemetry are both pre-1.0, so Cargo compares their minor version for compatibility: 0.31 and 0.32 are separate ranges that never unify, whether or not the release actually changed anything. If your application's versions drift from the ones sqlx-otel was built against, Cargo resolves both copies into the build graph, and the two libraries fail differently.
sqlx fails at compile time. Its types are part of this crate's public API, and types from semver-incompatible releases are distinct types. A Pool from sqlx 0.9 will not go into a wrapper built against sqlx 0.8.
opentelemetry fails silently at runtime. It coordinates through process-global state, so two copies means two registries. Your application installs its provider in one; the wrapper emits into the other. Spans and metrics disappear, with no error and no warning.
Hence:
- Either library bumping its minor triggers a minor release here.
- Bugs are not backported. Fixes ship in the next release.
- MSRV tracks sqlx's MSRV policy.
License
Licensed under either of Apache-2.0 or MIT at your option.