sqlx-otel 0.3.0

Thin wrapper around SQLx that emits OpenTelemetry spans and metrics following the database client semantic conventions.
Documentation

sqlx-otel

CI build codecov crates.io docs.rs

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::Executor method emits a SpanKind::Client span 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.type on failure.
  • Span/metric attribute parity. The db.client.operation.duration histogram 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.text is 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> implements sqlx::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.
[dependencies]
sqlx-otel = { version = "0.2.0", features = ["postgres", "runtime-tokio"] }
use sqlx_otel::PoolBuilder;

// Wrap an existing sqlx pool. Connection-level attributes (host, port, namespace)
// are auto-extracted from the underlying connect options.
let raw = sqlx::PgPool::connect("postgres://localhost/mydb").await?;
let pool = PoolBuilder::from(raw).build();

// Use it exactly like a sqlx pool.
let row: (i64,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await?;

// Transactions work via `&mut tx`.
let mut tx = pool.begin().await?;
sqlx::query("INSERT INTO users (name) VALUES ($1)")
    .bind("Alice")
    .execute(&mut tx)
    .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.name and db.query.text but no db.operation.name or db.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:

cargo run --example sqlite --features sqlite,runtime-tokio

Configuration

PoolBuilder overrides the auto-extracted attributes and tunes capture behaviour:

use sqlx_otel::{PoolBuilder, QueryTextMode};
use std::time::Duration;

let pool = PoolBuilder::from(raw_pool)
    .with_database("mydb")                  // override db.namespace
    .with_host("db.example.com")            // override server.address
    .with_port(5432)                        // override server.port
    .with_network_peer_address("10.0.0.5")  // network.peer.address (not auto-extracted)
    .with_network_peer_port(5432)           // network.peer.port    (not auto-extracted)
    .with_network_protocol_name("postgresql") // override network.protocol.name (defaulted per backend)
    .with_network_transport("tcp")          // network.transport    (not inferred from connect string)
    .with_query_text_mode(QueryTextMode::Off)
    .with_pool_name("my-service-db")        // db.client.connection.pool.name + connection-count polling
    .with_pool_metrics_interval(Duration::from_secs(5))
    .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 sqlx_otel::{QueryAnnotateExt, QueryAnnotations};

sqlx::query("SELECT * FROM users WHERE id = ?")
    .bind(42_i64)
    .with_annotations(QueryAnnotations::new().operation("SELECT").collection("users"))
    .execute(&pool)
    .await?;

// Shorthand for the common operation/collection pair:
sqlx::query("INSERT INTO orders (user_id) VALUES (?)")
    .with_operation("INSERT", "orders")
    .bind(7_i64)
    .execute(&pool)
    .await?;

The same with_annotations / with_operation methods are available on:

  • The query builders returned by sqlx::query, sqlx::query_as, and sqlx::query_scalar (via the QueryAnnotateExt trait).
  • The Map returned by query::map / query::try_map, and the compile-time-validated macro forms (sqlx::query!(), sqlx::query_as!(), sqlx::query_scalar!()) – which expand to either Query or Map.
  • Pool, PoolConnection, and Transaction directly, via pool.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

  • MSRV: Rust 1.85.0.
  • SQLx: 0.8.x.
  • OpenTelemetry: 0.31.x.

License

Licensed under either of Apache-2.0 or MIT at your option.