From 1846a1d4272b49027834ccd84dd67be6632bcc2c Mon Sep 17 00:00:00 2001 From: Borislav Borisov Date: Tue, 28 Apr 2026 13:25:33 +0100 Subject: [PATCH] docs: Restructure README around highlights and add runnable SQLite example --- Cargo.toml | 6 +- README.md | 240 ++++++++++++++++++++------------------------- examples/sqlite.rs | 85 ++++++++++++++++ 3 files changed, 198 insertions(+), 133 deletions(-) create mode 100644 examples/sqlite.rs diff --git a/Cargo.toml b/Cargo.toml index fc2713b..2906c91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,10 +42,14 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [dev-dependencies] -ctor = "0.4" +ctor = "0.10" opentelemetry_sdk = { version = "0.31", features = ["testing", "rt-tokio"] } proptest = "1" serial_test = "3" sqlx = { version = "0.8", features = ["macros", "sqlite", "postgres", "mysql", "runtime-tokio"] } testcontainers = "0.27" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[[example]] +name = "sqlite" +required-features = ["sqlite", "runtime-tokio"] diff --git a/README.md b/README.md index 7f0d93b..b36320f 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,42 @@ Lightweight [SQLx](https://github.com/launchbadge/sqlx) wrapper that emits OpenTelemetry-native spans and metrics following the [database client semantic conventions](https://opentelemetry.io/docs/specs/semconv/database/). -Uses the `opentelemetry` API directly – no `tracing` bridge indirection. Zero-cost when no tracer or meter provider is installed. +The wrapper talks to the [`opentelemetry`](https://docs.rs/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](https://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` 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. | + +```toml +[dependencies] +sqlx-otel = { version = "0.1.0", features = ["postgres", "runtime-tokio"] } +``` + ```rust use sqlx_otel::PoolBuilder; -// Wrap an existing sqlx pool. +// 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 = sqlx::query("SELECT 1").fetch_one(&pool).await?; +let row: (i64,) = sqlx::query_as("SELECT 1").fetch_one(&pool).await?; -// Transactions work with &mut tx. +// Transactions work via `&mut tx`. let mut tx = pool.begin().await?; sqlx::query("INSERT INTO users (name) VALUES ($1)") .bind("Alice") @@ -32,34 +51,91 @@ sqlx::query("INSERT INTO users (name) VALUES ($1)") 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. +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`](https://docs.rs/opentelemetry_sdk) (or any other compliant SDK) and your chosen exporter. -## Feature flags +> **Annotations matter.** Without the per-query annotation API ([below](#per-query-annotations)), 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. -### Backends +For a complete runnable end-to-end example – pool wrap, in-memory SDK, queries, and printed telemetry – see [`examples/sqlite.rs`](examples/sqlite.rs): -```toml -[dependencies] -sqlx-otel = { version = "0.1.0", features = ["postgres"] } -# or "sqlite", "mysql" +```bash +cargo run --example sqlite --features sqlite,runtime-tokio ``` -### Runtime (optional) +## Configuration -Enable a runtime to get `db.client.connection.count` polling via a background task: +`PoolBuilder` overrides the auto-extracted attributes and tunes capture behaviour: -```toml -sqlx-otel = { version = "0.1.0", features = ["postgres", "runtime-tokio"] } -# or "runtime-async-std" +```rust +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: + +```rust +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?; ``` -All other metrics work without a runtime feature. +The same `with_annotations` / `with_operation` methods are available on: -## What you get out of the box +- 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(…)`. -### Spans +When annotations are set, the span name follows the [semantic-convention hierarchy](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#name): `db.query.summary` → `"{operation} {collection}"` → `"{operation}"` → `"{db.system.name}"`. -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 | Builder method | +|----------------------------|-----------------------| +| `db.operation.name` | `.operation()` | +| `db.collection.name` | `.collection()` | +| `db.query.summary` | `.query_summary()` | +| `db.stored_procedure.name` | `.stored_procedure()` | + +See [`QueryAnnotations`](https://docs.rs/sqlx-otel/latest/sqlx_otel/struct.QueryAnnotations.html) and [`QueryAnnotateExt`](https://docs.rs/sqlx-otel/latest/sqlx_otel/trait.QueryAnnotateExt.html) 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 | |-----------------------------|-------------------------------------------------|-----------------------------| @@ -105,123 +181,23 @@ These carry the connection-level attributes (`db.system.name`, `db.namespace`, ` | `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: - -```rust -use sqlx_otel::{PoolBuilder, QueryTextMode}; -use std::time::Duration; - -let pool = PoolBuilder::from(raw_pool) - .with_database("mydb") - .with_host("db.example.com") - .with_port(5432) - .with_network_peer_address("10.0.0.5") - .with_network_peer_port(5432) - .with_query_text_mode(QueryTextMode::Off) - .with_pool_name("my-service-db") - .with_pool_metrics_interval(Duration::from_secs(5)) - .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: - -```rust -use sqlx_otel::QueryAnnotations; - -// Full builder – set whichever fields apply. -pool.with_annotations( - QueryAnnotations::new() - .operation("SELECT") - .collection("users")) - .fetch_all("SELECT * FROM users WHERE active = true") - .await?; - -// Shorthand for the common two-attribute case. -pool.with_operation("INSERT", "orders") - .execute("INSERT INTO orders (id) VALUES ($1)") - .await?; -``` - -Annotations work on `Pool`, `PoolConnection`, and `Transaction`. The wrapper borrows the underlying executor for a single operation and is then dropped. - -### Query-side annotations - -For locality, the same `with_annotations` / `with_operation` methods are also available on the query builder produced by `sqlx::query`, `sqlx::query_as`, and `sqlx::query_scalar`. Bring `QueryAnnotateExt` into scope and chain the annotation directly on the query – binds may appear before or after `with_annotations`: - -```rust -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?; +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. -// Shorthand and bind-after-annotate also work: -sqlx::query("INSERT INTO orders (user_id) VALUES (?)") - .with_operation("INSERT", "orders") - .bind(7_i64) - .execute(&pool) - .await?; -``` +## Backend support -#### Map and macro queries +| 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. | -`Query::map` / `Query::try_map` return [`sqlx::query::Map<'q, DB, F, A>`](https://docs.rs/sqlx/0.8/sqlx/query/struct.Map.html), which is also supported by the trait. `with_annotations` and `with_operation` may be placed at any of three positions on a hand-written chain: +`db.response.returned_rows`, `db.response.affected_rows`, `db.response.status_code`, and `error.type` are recorded uniformly across all three backends. -```rust -use sqlx_otel::QueryAnnotateExt; +## Compatibility -sqlx::query("SELECT id FROM users WHERE name = ?") - .bind("alice") - .map(|row: sqlx::sqlite::SqliteRow| row.get::("id")) - .with_operation("SELECT", "users") - .fetch_one(&pool) - .await?; -``` - -The compile-time validated macro forms (`sqlx::query!()`, `sqlx::query_as!()`, `sqlx::query_scalar!()`) expand to either `Query<'q, DB, _>` (no result columns) or `Map<'q, DB, _, _>` (any shape that decodes columns). Both are covered: - -```rust -sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = ?", 42_i64) - .with_operation("SELECT", "users") - .fetch_one(&pool) - .await?; -``` - -Macro queries can only carry annotations *after* the macro returns – the macro itself pre-applies `bind` and `try_map`, so the alternative positions are not reachable by the caller. - -When annotations are provided the span name follows the [semantic convention hierarchy](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#name): - -1. `db.query.summary` – the caller-supplied summary, e.g. `"users by tenant"` -2. `"{db.operation.name} {db.collection.name}"` – e.g. `"SELECT users"` -3. `"{db.operation.name}"` – e.g. `"INSERT"` -4. `"{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. +- **MSRV:** Rust **1.85.0**. +- **SQLx:** `0.8.x`. +- **OpenTelemetry:** `0.31.x`. ## License diff --git a/examples/sqlite.rs b/examples/sqlite.rs new file mode 100644 index 0000000..2d3c76b --- /dev/null +++ b/examples/sqlite.rs @@ -0,0 +1,85 @@ +//! End-to-end example: wrap an in-memory `SQLite` pool, install an in-memory `OpenTelemetry` +//! tracer/meter, run a small CRUD sequence, and print the emitted spans and metrics. +//! +//! Run with: +//! +//! ```text +//! cargo run --example sqlite --features sqlite,runtime-tokio +//! ``` +//! +//! In a real application, replace the in-memory exporters with whichever exporter ships +//! to your collector (OTLP, stdout, Jaeger, etc.). The wrapper itself is unchanged – +//! installing a provider is the application's responsibility. + +use opentelemetry::global; +use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider}; +use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; +use sqlx::Row as _; +use sqlx_otel::{PoolBuilder, QueryAnnotateExt as _, QueryAnnotations}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let span_exporter = InMemorySpanExporter::default(); + let tracer_provider = SdkTracerProvider::builder() + .with_simple_exporter(span_exporter.clone()) + .build(); + global::set_tracer_provider(tracer_provider.clone()); + + let metric_exporter = InMemoryMetricExporter::default(); + let metric_reader = PeriodicReader::builder(metric_exporter.clone()).build(); + let meter_provider = SdkMeterProvider::builder() + .with_reader(metric_reader) + .build(); + global::set_meter_provider(meter_provider.clone()); + + let raw = sqlx::SqlitePool::connect(":memory:").await?; + let pool = PoolBuilder::from(raw).build(); + + sqlx::query("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)") + .with_operation("CREATE", "users") + .execute(&pool) + .await?; + + sqlx::query("INSERT INTO users (id, name) VALUES (?, ?)") + .bind(1_i64) + .bind("Alice") + .with_annotations( + QueryAnnotations::new() + .operation("INSERT") + .collection("users"), + ) + .execute(&pool) + .await?; + + let row = sqlx::query("SELECT name FROM users WHERE id = ?") + .bind(1_i64) + .with_operation("SELECT", "users") + .fetch_one(&pool) + .await?; + let name: String = row.get("name"); + println!("fetched user: {name}\n"); + + let _ = tracer_provider.force_flush(); + let _ = meter_provider.force_flush(); + + println!("--- emitted spans ---"); + for span in span_exporter.get_finished_spans()? { + println!("span: {}", span.name); + for kv in &span.attributes { + println!(" {} = {:?}", kv.key, kv.value); + } + } + + println!("\n--- emitted metrics ---"); + for resource_metrics in metric_exporter.get_finished_metrics()? { + for scope_metrics in resource_metrics.scope_metrics() { + for metric in scope_metrics.metrics() { + println!("metric: {}", metric.name()); + } + } + } + + let _ = tracer_provider.shutdown(); + let _ = meter_provider.shutdown(); + Ok(()) +}