sqlx-otel
Lightweight SQLx wrapper that emits OpenTelemetry-native spans and metrics following the database client semantic conventions.
Uses the opentelemetry API directly – no tracing bridge indirection. Zero-cost when no tracer or meter provider is installed.
Quick start
use PoolBuilder;
// Wrap an existing sqlx pool.
let raw = connect.await?;
let pool = from.build;
// Use it exactly like a sqlx pool.
let row = query.fetch_one.await?;
// Transactions work with &mut tx.
let mut tx = pool.begin.await?;
query
.bind
.execute
.await?;
tx.commit.await?;
Every operation through the pool automatically emits an OpenTelemetry span and records metrics. No code changes are required beyond wrapping the pool.
Feature flags
Backends
[]
= { = "0.0.0", = ["postgres"] }
# or "sqlite", "mysql"
Runtime (optional)
Enable a runtime to get db.client.connection.count polling via a background task:
= { = "0.0.0", = ["postgres", "runtime-tokio"] }
# or "runtime-async-std"
All other metrics work without a runtime feature.
What you get out of the box
Spans
Every Executor method (execute, fetch, fetch_all, fetch_one, fetch_optional, fetch_many, execute_many, prepare, prepare_with, describe) creates a SpanKind::Client span with:
| Attribute | Source | Condition |
|---|---|---|
db.system.name |
Backend ("postgresql", "sqlite", "mysql") |
Always |
db.namespace |
Database name, extracted from connect options | Always |
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 |
db.query.text |
The SQL query string | 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 operation |
These carry the connection-level attributes (db.system.name, db.namespace, server.address, server.port).
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. connection.count is polled by a background task and requires a runtime feature (runtime-tokio or runtime-async-std). The remaining three are static gauges recorded once at pool construction.
Configuration
PoolBuilder supports overriding auto-extracted attributes and controlling query text capture:
use ;
use Duration;
let pool = from
.with_database
.with_host
.with_port
.with_network_peer_address
.with_network_peer_port
.with_query_text_mode
.with_pool_name
.with_pool_metrics_interval
.build;
Per-query annotations
The library does not parse SQL. Per-query attributes like the operation name and target table are the caller's responsibility via the annotation API:
use QueryAnnotations;
// Full builder – set whichever fields apply.
pool.with_annotations
.fetch_all
.await?;
// Shorthand for the common two-attribute case.
pool.with_operation
.execute
.await?;
Annotations work on Pool, PoolConnection, and Transaction. The wrapper borrows the underlying executor for a single operation and is then dropped.
When annotations are provided the span name follows the semantic convention hierarchy:
db.query.summary– the caller-supplied summary, e.g."users by tenant""{db.operation.name} {db.collection.name}"– e.g."SELECT users""{db.operation.name}"– e.g."INSERT""{db.system.name}"– fallback when no annotations are set
db.query.summary wins unconditionally when set – this is the spec's escape hatch for callers who cannot guarantee a low-cardinality db.operation.name (dynamic SQL, complex pipelines).
| Attribute | Builder method |
|---|---|
db.operation.name |
.operation() |
db.collection.name |
.collection() |
db.query.summary |
.query_summary() |
db.stored_procedure.name |
.stored_procedure() |
Query text modes
| Mode | Behaviour |
|---|---|
Full (default) |
Capture the parameterised query as-is. 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.
License
Licensed under either of Apache-2.0 or MIT at your option.