sqlx-otel 0.2.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 db.system.name, db.namespace, server.address/port, db.query.text, returned/affected row counts, and SQLSTATE / error.type on failure.
  • Operation and pool metrics. Histograms for query duration and rows returned; histograms, counters, and gauges for pool waits, hold times, timeouts, and connection state.
  • Caller-supplied annotations. The library does not parse SQL. Per-query attributes (db.operation.name, db.collection.name, db.query.summary) 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.1.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_query_text_mode(QueryTextMode::Off)
    .with_pool_name("my-service-db")        // required for connection pool gauges
    .with_pool_metrics_interval(Duration::from_secs(5))
    .build();

with_pool_name 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.

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 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.

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
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. 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 Notes
postgres Database name Yes (from connect URL)
mysql Database name Yes (from connect URL)
sqlite File path or :memory: URI n/a 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.