Skip to main content

Crate sqlx_otel

Crate sqlx_otel 

Source
Expand description

Lightweight SQLx wrapper that emits OpenTelemetry-native spans and metrics following the database client semantic conventions.

sqlx-otel talks to the opentelemetry API directly – there is no tracing bridge indirection. The wrapper is zero-cost when no TracerProvider or MeterProvider is installed because the global opentelemetry API resolves to no-op instruments in that configuration; the only overhead is one Arc clone per acquired connection or transaction.

§Quick start

Wrap an existing sqlx::Pool with PoolBuilder and use the result anywhere a sqlx::Pool is accepted – every operation through the wrapper is instrumented.

use sqlx_otel::PoolBuilder;

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

// Use it exactly like a sqlx pool – `&pool` is an `sqlx::Executor`.
let row: (i64,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await?;
assert_eq!(row.0, 1);

// Transactions work via `&mut tx` (note: not `&mut *tx`; the wrapper does not deref).
let mut tx = pool.begin().await?;
sqlx::query("CREATE TABLE users (name TEXT)")
    .execute(&mut tx)
    .await?;
tx.commit().await?;

Every operation through the pool emits a SpanKind::Client span, records the operation duration, and tracks pool-level metrics. No code changes are required beyond the one-line wrap.

§Setting up an OpenTelemetry SDK

sqlx-otel produces telemetry but does not install a provider – that is the application’s responsibility. Pair this crate with the opentelemetry_sdk (or any other compliant SDK) and set up exporters for your traces and metrics. Until a provider is installed via opentelemetry::global::set_tracer_provider / set_meter_provider, the instrumentation is a no-op.

§What gets emitted

§Spans

Every sqlx::Executor method (execute, fetch, fetch_all, fetch_one, fetch_optional, fetch_many, execute_many, prepare, prepare_with, describe) produces a SpanKind::Client span. The span name follows the semantic-convention hierarchy: db.query.summary (when set) → "{operation} {collection}""{operation}""{db.system.name}".

AttributeSourceCondition
db.system.nameBackend ("postgresql", "sqlite", "mysql")Always
db.namespaceDatabase name extracted from connect optionsWhen available
server.addressHostname extracted from connect optionsWhen available
server.portPort extracted from connect optionsWhen available
network.peer.addressResolved IP addressWhen set via builder
network.peer.portResolved portWhen set via builder
db.query.textThe SQL query string with inter-token whitespace collapsedUnless QueryTextMode::Off
db.operation.nameDatabase operation (e.g. SELECT)When annotated
db.collection.nameTarget table or collectionWhen annotated
db.query.summaryLow-cardinality query summaryWhen annotated
db.stored_procedure.nameStored procedure nameWhen annotated
db.response.returned_rowsRow countOn fetch* methods
db.response.affected_rowsRows affected (QueryResult::rows_affected())On execute (note)
db.response.status_codeSQLSTATE / driver error codeOn database errors
error.typeError variant nameOn any error

On error, the span status is set to Error and an exception event is added carrying exception.type and exception.message.

§Operation metrics

InstrumentTypeUnitDescription
db.client.operation.durationHistogramsDuration of each database operation
db.client.response.returned_rowsHistogramNumber of rows returned per operation

These carry the connection-level attributes (db.system.name, db.namespace, server.address, server.port).

§Connection-pool metrics

InstrumentTypeUnitDescription
db.client.connection.wait_timeHistogramsTime spent waiting for a connection in acquire()
db.client.connection.use_timeHistogramsTime a connection was held before being returned
db.client.connection.timeoutsCounterNumber of acquire attempts that timed out
db.client.connection.pending_requestsUpDownCounterNumber of callers currently waiting in acquire()
db.client.connection.countGaugeCurrent connections by state (idle / used)
db.client.connection.maxGaugeMaximum number of connections allowed
db.client.connection.idle.maxGaugeMaximum idle connections (equals max in SQLx)
db.client.connection.idle.minGaugeConfigured minimum connections

The first four are recorded inline on every acquire() and connection drop – no sampling gaps. connection.count is polled by a background task and requires both PoolBuilder::with_pool_name and a runtime feature (runtime-tokio or runtime-async-std); without either, the gauge is silent. The remaining three are static gauges recorded once at PoolBuilder::build.

§A note on db.response.affected_rows

db.response.affected_rows is not part of the OpenTelemetry semantic conventions at the time of writing; it is a custom attribute we find useful. It carries the database-confirmed count from QueryResult::rows_affected() on every execute() call, using the same connection-level attributes as the standard db.response.returned_rows. It is not recorded for execute_many, which is considered deprecated upstream.

§Per-query annotations

sqlx-otel does not parse SQL. The four per-query semantic-convention attributes – db.operation.name, db.collection.name, db.query.summary, and db.stored_procedure.name – are the caller’s responsibility, supplied through the annotation API. There are two equivalent surfaces depending on whether you prefer the annotation to live next to the executor or next to the query:

Executor-side (Pool::with_annotations, PoolConnection::with_annotations, Transaction::with_annotations) returns a borrowed wrapper that is itself an sqlx::Executor. Use it when the same query text is reused with one set of annotations, or when you want to annotate prepare / describe flows.

use sqlx::Executor as _; // brings `fetch_all` / `execute` into scope.
use sqlx_otel::QueryAnnotations;

pool.with_annotations(
    QueryAnnotations::new()
        .operation("SELECT")
        .collection("users"),
)
.fetch_all("SELECT * FROM users")
.await?;

// Shorthand for the common two-attribute case.
pool.with_operation("INSERT", "orders")
    .execute("INSERT INTO orders (id) VALUES (1)")
    .await?;

Query-side (QueryAnnotateExt) attaches the annotation directly to the sqlx::query / sqlx::query_as / sqlx::query_scalar builder. Use it when annotations belong with the query text – the typical case. Works with Query::map / Query::try_map and with the compile-time validated macro forms.

use sqlx_otel::QueryAnnotateExt;

sqlx::query("INSERT INTO orders (user_id) VALUES (?)")
    .bind(7_i64)
    .with_operation("INSERT", "orders")
    .execute(&pool)
    .await?;

See QueryAnnotations for the full set of fields and QueryAnnotateExt for the query-side surface (including the three valid annotation positions on Query::map chains).

§Error handling

All wrapper methods return sqlx::Error unchanged – there is no error wrapping or retranslation. When an error surfaces, the active span’s status is set to Error, an exception event is added with exception.type and exception.message, and – whenever the error is a sqlx::Error::Database with a SQLSTATE/driver code – the db.response.status_code attribute is recorded. The instrumentation is otherwise transparent: the caller sees exactly the same Result it would see without the wrapper.

§Feature flags

sqlx-otel ships with no default features – pick at least one backend.

FeatureEffect
sqliteEnable the sqlx::Sqlite backend.
postgresEnable the sqlx::Postgres backend.
mysqlEnable the sqlx::MySql backend.
runtime-tokioSpawn the db.client.connection.count polling task with tokio.
runtime-async-stdSame as above, but with async-std. Ignored if tokio is enabled.
[dependencies]
sqlx-otel = { version = "0.1", features = ["postgres", "runtime-tokio"] }

All operation- and pool-level metrics other than db.client.connection.count work without a runtime feature.

§MSRV

Minimum supported Rust version: 1.85.0.

Structs§

Annotated
A shared-reference annotation wrapper that carries per-query attributes alongside a borrowed executor. Returned by Pool::with_annotations and Pool::with_operation.
AnnotatedMut
A mutable-reference annotation wrapper that carries per-query attributes alongside a mutably borrowed executor. Returned by PoolConnection::with_annotations, Transaction::with_annotations, and their with_operation shorthands.
AnnotatedQuery
A SQLx query builder paired with OpenTelemetry per-query annotations.
Pool
An instrumented wrapper around sqlx::Pool that emits OpenTelemetry spans and metrics for every database operation.
PoolBuilder
Builder for constructing an instrumented Pool from a raw sqlx::Pool.
PoolConnection
A pooled connection instrumented for OpenTelemetry.
QueryAnnotations
Per-query annotation values that enrich OpenTelemetry spans with semantic-convention attributes the library cannot derive automatically (because it does not parse SQL).
Transaction
An in-progress database transaction instrumented for OpenTelemetry.

Enums§

QueryTextMode
Controls whether and how db.query.text is captured on spans.

Traits§

Database
Per-backend contract providing the database system name, connect-attribute extraction, and rows_affected projection.
QueryAnnotateExt
Extension trait that attaches OpenTelemetry per-query annotations to the function-form SQLx query builders (sqlx::query(), sqlx::query_as(), sqlx::query_scalar()) and to the Map returned by Query::map / Query::try_map.