Skip to content

Commit 9521c6d

Browse files
authored
feat(observability): enable telemetry integrations (#237)
Introduce runtime helpers for lifecycle and span orchestration, improve storage telemetry diagnostics, and add comprehensive unit and integration tests. Implement redaction for SQL statements and parameters, and provide telemetry snapshot functions for monitoring recent storage events.
1 parent f1b05c6 commit 9521c6d

File tree

60 files changed

+3731
-345
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+3731
-345
lines changed

docs/extensions/litestar/api.rst

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ Configure the plugin via ``extension_config`` in database configuration:
3434
"commit_mode": "autocommit",
3535
"extra_commit_statuses": {201, 204},
3636
"extra_rollback_statuses": {409},
37-
"enable_correlation_middleware": True
37+
"enable_correlation_middleware": True,
38+
"correlation_header": "x-correlation-id",
3839
}
3940
}
4041
)
@@ -74,10 +75,22 @@ Configuration Options
7475
- ``set[int]``
7576
- ``None``
7677
- Additional HTTP status codes that trigger rollbacks
77-
* - ``enable_correlation_middleware``
78+
* - ``enable_correlation_middleware``
7879
- ``bool``
7980
- ``True``
8081
- Enable request correlation tracking
82+
* - ``correlation_header``
83+
- ``str``
84+
- ``"X-Request-ID"``
85+
- HTTP header to read when populating the correlation ID middleware
86+
* - ``correlation_headers``
87+
- ``list[str]``
88+
- ``[]``
89+
- Additional headers to consider (auto-detected headers are appended unless disabled)
90+
* - ``auto_trace_headers``
91+
- ``bool``
92+
- ``True``
93+
- Toggle automatic detection of standard tracing headers (`Traceparent`, `X-Cloud-Trace-Context`, etc.)
8194

8295
Session Stores
8396
==============

docs/guides/architecture/architecture.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ orphan: true
1414
4. [Driver Implementation](#driver-implementation)
1515
5. [Parameter Handling](#parameter-handling)
1616
6. [Testing & Development](#testing--development)
17+
7. [Observability Runtime](#observability-runtime)
1718

1819
---
1920

@@ -308,3 +309,12 @@ make install # Standard development installation
308309
2. Implement the `config.py` and `driver.py` files.
309310
3. Add integration tests for the new adapter.
310311
4. Document any special cases or configurations.
312+
313+
## Observability Runtime
314+
315+
The observability subsystem (lifecycle dispatcher, statement observers, span manager, diagnostics) now sits alongside the driver architecture. Refer to the dedicated [Observability Runtime guide](./observability.md) for:
316+
317+
- configuration sources (`ObservabilityConfig`, adapter overrides, and `driver_features` compatibility),
318+
- the full list of lifecycle events emitted by SQLSpec,
319+
- guidance on statement observers, redaction, and OpenTelemetry spans,
320+
- the Phase 4/5 roadmap for spans + diagnostics.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# SQLSpec Observability Runtime
2+
3+
This guide explains how the consolidated observability stack works after the Lifecycle Dispatcher + Statement Observer integration. Use it as the single source of truth when wiring new adapters, features, or docs.
4+
5+
## Goals
6+
7+
1. **Unified Hooks** – every pool, connection, session, and query event is emitted through one dispatcher with zero work when no listeners exist.
8+
2. **Structured Statement Events** – observers receive normalized payloads (`StatementEvent`) for printing, logging, or exporting to tracing systems.
9+
3. **Optional OpenTelemetry Spans** – span creation is lazy and never imports `opentelemetry` unless spans are enabled.
10+
4. **Diagnostics** – storage bridge + serializer metrics + lifecycle counters roll up under `SQLSpec.telemetry_snapshot()` (Phase 5).
11+
5. **Loader & Migration Telemetry** – SQL file loader, caching, and migration runners emit metrics/spans without additional plumbing (Phase 7).
12+
13+
## Configuration Sources
14+
15+
There are three ways to enable observability today:
16+
17+
1. **Registry-Level** – pass `observability_config=ObservabilityConfig(...)` to `SQLSpec()`.
18+
2. **Adapter Override** – each config constructor accepts `observability_config=` for adapter-specific knobs.
19+
3. **`driver_features` Compatibility** – existing keys such as `"on_connection_create"`, `"on_pool_destroy"`, and `"on_session_start"` are automatically promoted into lifecycle observers, so user-facing APIs do **not** change.
20+
21+
```python
22+
from sqlspec import SQLSpec
23+
from sqlspec.adapters.duckdb import DuckDBConfig
24+
25+
def ensure_extensions(connection):
26+
connection.execute("INSTALL http_client; LOAD http_client;")
27+
28+
config = DuckDBConfig(
29+
pool_config={"database": ":memory:"},
30+
driver_features={
31+
"extensions": [{"name": "http_client"}],
32+
"on_connection_create": ensure_extensions, # promoted to observability runtime
33+
},
34+
)
35+
36+
sql = SQLSpec(observability_config=ObservabilityConfig(print_sql=True))
37+
sql.add_config(config)
38+
```
39+
40+
> **Implementation note:** During config initialization we inspect `driver_features` for known hook keys and wrap them into `ObservabilityConfig` callbacks. Hooks that accepted a raw resource (e.g., connection) continue to do so without additional adapter plumbing.
41+
42+
## Lifecycle Events
43+
44+
The dispatcher exposes the following events (all opt-in and guard-checked):
45+
46+
| Event | Context contents |
47+
| --- | --- |
48+
| `on_pool_create` / `on_pool_destroy` | `pool`, `config`, `bind_key`, `correlation_id` |
49+
| `on_connection_create` / `on_connection_destroy` | `connection`, plus base context |
50+
| `on_session_start` / `on_session_end` | `session` / driver instance |
51+
| `on_query_start` / `on_query_complete` | SQL text, parameters, metadata |
52+
| `on_error` | `exception` plus last query context |
53+
54+
`SQLSpec.provide_connection()` and `SQLSpec.provide_session()` now emit these events automatically, regardless of whether the caller uses registry helpers or adapter helpers directly.
55+
56+
## Statement Observers & Print SQL
57+
58+
Statement observers receive `StatementEvent` objects. Typical uses:
59+
60+
* enable `print_sql=True` to attach the built-in logger.
61+
* add custom redaction rules via `RedactionConfig` (mask parameters, mask literals, allow-list names).
62+
* forward events to bespoke loggers or telemetry exporters.
63+
64+
```python
65+
def log_statement(event: StatementEvent) -> None:
66+
logger.info("%s (%s) -> %ss", event.operation, event.driver, event.duration_s)
67+
68+
ObservabilityConfig(
69+
print_sql=False,
70+
statement_observers=(log_statement,),
71+
redaction=RedactionConfig(mask_parameters=True, parameter_allow_list=("tenant_id",)),
72+
)
73+
```
74+
75+
### Optional Exporters (OpenTelemetry & Prometheus)
76+
77+
Two helper modules wire optional dependencies into the runtime without forcing unconditional imports:
78+
79+
* `sqlspec.extensions.otel.enable_tracing()` ensures `opentelemetry-api` is installed, then returns an `ObservabilityConfig` whose `TelemetryConfig` enables spans and (optionally) injects a tracer provider factory.
80+
* `sqlspec.extensions.prometheus.enable_metrics()` ensures `prometheus-client` is installed and appends a `PrometheusStatementObserver` that emits counters and histograms for every `StatementEvent`.
81+
82+
Both helpers rely on the conditional stubs defined in `sqlspec/typing.py`, so they remain safe to import even when the extras are absent.
83+
84+
```python
85+
from sqlspec.extensions import otel, prometheus
86+
87+
config = otel.enable_tracing(resource_attributes={"service.name": "orders-api"})
88+
config = prometheus.enable_metrics(base_config=config, label_names=("driver", "operation", "adapter"))
89+
sql = SQLSpec(observability_config=config)
90+
```
91+
92+
You can also opt in per adapter by passing `extension_config["otel"]` or `extension_config["prometheus"]` when constructing a config; the helpers above are invoked automatically during initialization.
93+
94+
## Loader & Migration Telemetry
95+
96+
`SQLSpec` instantiates a dedicated `ObservabilityRuntime` for the SQL file loader and shares it with every migration command/runner. Instrumentation highlights:
97+
98+
- Loader metrics such as `SQLFileLoader.loader.load.invocations`, `.cache.hit`, `.files.loaded`, `.statements.loaded`, and `.directories.scanned` fire automatically when queries are loaded or cache state is inspected.
99+
- Migration runners publish cache stats (`{Config}.migrations.listing.cache_hit`, `.cache_miss`, `.metadata.cache_hit`), command metrics (`{Config}.migrations.command.upgrade.invocations`, `.downgrade.errors`), and per-migration execution metrics (`{Config}.migrations.upgrade.duration_ms`, `.downgrade.applied`).
100+
- Command and migration spans (`sqlspec.migration.command.upgrade`, `sqlspec.migration.upgrade`) include version numbers, bind keys, and correlation IDs; they end with duration attributes even when exceptions occur.
101+
102+
All metrics surface through `SQLSpec.telemetry_snapshot()` under the adapter key, so exporters observe a flat counter space regardless of which subsystem produced the events.
103+
104+
## Span Manager & Diagnostics
105+
106+
* **Span Manager:** Query spans ship today, lifecycle events emit `sqlspec.lifecycle.*` spans, storage bridge helpers wrap reads/writes with `sqlspec.storage.*` spans, and migration runners create `sqlspec.migration.*` spans for both commands and individual revisions. Mocked span tests live in `tests/unit/test_observability.py`.
107+
* **Diagnostics:** `TelemetryDiagnostics` aggregates lifecycle counters, loader/migration metrics, storage bridge telemetry, and serializer cache stats. Storage telemetry carries backend IDs, bind key, and correlation IDs so snapshots/spans inherit the same context, and `SQLSpec.telemetry_snapshot()` exposes that data via flat counters plus a `storage_bridge.recent_jobs` list detailing the last 25 operations.
108+
109+
Example snapshot payload:
110+
111+
```
112+
{
113+
"storage_bridge.bytes_written": 2048,
114+
"storage_bridge.recent_jobs": [
115+
{
116+
"destination": "alias://warehouse/users.parquet",
117+
"backend": "s3",
118+
"bytes_processed": 2048,
119+
"rows_processed": 16,
120+
"config": "AsyncpgConfig",
121+
"bind_key": "analytics",
122+
"correlation_id": "8f64c0f6",
123+
"format": "parquet"
124+
}
125+
],
126+
"serializer.hits": 12,
127+
"serializer.misses": 2,
128+
"AsyncpgConfig.lifecycle.query_start": 4
129+
}
130+
```
131+
132+
## Next Steps (2025 Q4)
133+
134+
1. **Exporter Validation:** Exercise the OpenTelemetry/Prometheus helpers against the new loader + migration metrics and document recommended dashboards.
135+
2. **Adapter Audit:** Confirm every adapter’s migration tracker benefits from the instrumentation (especially Oracle/BigQuery fixtures) and extend coverage where needed.
136+
3. **Performance Budgets:** Add guard-path benchmarks/tests to ensure disabled observability remains near-zero overhead now that migration/loader events emit metrics by default.

docs/guides/extensions/litestar.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Explains how to wire SQLSpec into Litestar using the official plugin, covering d
1313
- Commit strategies: `manual`, `autocommit`, and `autocommit_include_redirect`, configured via `extension_config["litestar"]["commit_mode"]`.
1414
- Session storage uses adapter-specific stores built on `BaseSQLSpecStore` (e.g., `AsyncpgStore`, `AiosqliteStore`).
1515
- CLI support registers `litestar db ...` commands by including `database_group` in the Litestar CLI app.
16-
- Correlation middleware emits request IDs in query logs (`enable_correlation_middleware=True` by default).
16+
- Correlation middleware emits request IDs in query logs (`enable_correlation_middleware=True` by default). It auto-detects standard tracing headers (`X-Request-ID`, `Traceparent`, `X-Cloud-Trace-Context`, `X-Amzn-Trace-Id`, etc.) unless you override the set via `correlation_header` / `correlation_headers`.
1717

1818
## Installation
1919

@@ -95,6 +95,10 @@ config = AsyncpgConfig(
9595
}
9696
},
9797
)
98+
99+
## Correlation IDs
100+
101+
Enable request-level correlation tracking (on by default) to thread Litestar requests into SQLSpec's observability runtime. The plugin inspects `X-Request-ID`, `Traceparent`, `X-Cloud-Trace-Context`, `X-Amzn-Trace-Id`, `grpc-trace-bin`, and `X-Correlation-ID` automatically, then falls back to generating a UUID if none are present. Override the primary header with `correlation_header`, append more via `correlation_headers`, or set `auto_trace_headers=False` to opt out of the auto-detection list entirely. Observers (print SQL, custom hooks, OpenTelemetry spans) automatically attach the current `correlation_id` to their payloads. Disable the middleware with `enable_correlation_middleware=False` when another piece of infrastructure manages IDs.
98102
```
99103

100104
## Transaction Management
@@ -162,7 +166,7 @@ Commands include `db migrate`, `db upgrade`, `db downgrade`, and `db status`. Th
162166

163167
## Middleware and Observability
164168

165-
- Correlation middleware annotates query logs with request-scoped IDs. Disable by setting `enable_correlation_middleware=False`.
169+
- Correlation middleware annotates query logs with request-scoped IDs. Disable by setting `enable_correlation_middleware=False`, override the primary header via `correlation_header`, add more with `correlation_headers`, or disable auto-detection using `auto_trace_headers=False`.
166170
- The plugin enforces graceful shutdown by closing pools during Litestar’s lifespan events.
167171
- Combine with Litestar’s `TelemetryConfig` to emit tracing spans around database calls.
168172

docs/usage/configuration.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,10 +497,25 @@ Litestar Plugin Configuration
497497
"pool_key": "db_pool",
498498
"commit_mode": "autocommit",
499499
"enable_correlation_middleware": True,
500+
"correlation_header": "x-correlation-id",
501+
"correlation_headers": ["x-custom-trace"],
502+
"auto_trace_headers": True, # Detect Traceparent, X-Cloud-Trace-Context, etc.
500503
}
501504
}
502505
)
503506
507+
Telemetry Snapshot
508+
~~~~~~~~~~~~~~~~~~
509+
510+
Call ``SQLSpec.telemetry_snapshot()`` to inspect lifecycle counters, serializer metrics, and recent storage jobs:
511+
512+
.. code-block:: python
513+
514+
snapshot = spec.telemetry_snapshot()
515+
print(snapshot["storage_bridge.bytes_written"])
516+
for job in snapshot.get("storage_bridge.recent_jobs", []):
517+
print(job["destination"], job.get("correlation_id"))
518+
504519
Environment-Based Configuration
505520
-------------------------------
506521

docs/usage/framework_integrations.rst

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -339,13 +339,16 @@ Enable request correlation tracking via ``extension_config``:
339339
pool_config={"dsn": "postgresql://..."},
340340
extension_config={
341341
"litestar": {
342-
"enable_correlation_middleware": True # Default: True
343-
}
344-
}
345-
)
346-
)
347-
348-
# Queries will include correlation IDs in logs
342+
"enable_correlation_middleware": True, # Default: True
343+
"correlation_header": "x-request-id",
344+
"correlation_headers": ["x-client-trace"],
345+
"auto_trace_headers": True,
346+
}
347+
}
348+
)
349+
)
350+
351+
# Queries will include correlation IDs in logs (header or generated UUID)
349352
# Format: [correlation_id=abc123] SELECT * FROM users
350353
351354
FastAPI Integration

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,14 @@ include = [
179179
"sqlspec/utils/fixtures.py", # File fixture loading
180180
"sqlspec/utils/data_transformation.py", # Data transformation utilities
181181

182+
# === OBSERVABILITY ===
183+
"sqlspec/observability/_config.py",
184+
"sqlspec/observability/_diagnostics.py",
185+
"sqlspec/observability/_dispatcher.py",
186+
"sqlspec/observability/_observer.py",
187+
"sqlspec/observability/_runtime.py",
188+
"sqlspec/observability/_spans.py",
189+
182190
# === STORAGE LAYER ===
183191
"sqlspec/storage/_utils.py",
184192
"sqlspec/storage/registry.py",

sqlspec/_typing.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,9 @@ def get_tracer(
568568
) -> Tracer:
569569
return Tracer() # type: ignore[abstract] # pragma: no cover
570570

571+
def get_tracer_provider(self) -> Any: # pragma: no cover
572+
return None
573+
571574
TracerProvider = type(None) # Shim for TracerProvider if needed elsewhere
572575
StatusCode = type(None) # Shim for StatusCode
573576
Status = type(None) # Shim for Status
@@ -600,6 +603,8 @@ def __init__(
600603
unit: str = "",
601604
registry: Any = None,
602605
ejemplar_fn: Any = None,
606+
buckets: Any = None,
607+
**_: Any,
603608
) -> None:
604609
return None
605610

sqlspec/adapters/adbc/config.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from sqlspec.adapters.adbc._types import AdbcConnection
1111
from sqlspec.adapters.adbc.driver import AdbcCursor, AdbcDriver, AdbcExceptionHandler, get_adbc_statement_config
12-
from sqlspec.config import ADKConfig, FastAPIConfig, FlaskConfig, LitestarConfig, NoPoolSyncConfig, StarletteConfig
12+
from sqlspec.config import ExtensionConfigs, NoPoolSyncConfig
1313
from sqlspec.core import StatementConfig
1414
from sqlspec.exceptions import ImproperConfigurationError
1515
from sqlspec.utils.module_loader import import_string
@@ -21,6 +21,8 @@
2121

2222
from sqlglot.dialects.dialect import DialectType
2323

24+
from sqlspec.observability import ObservabilityConfig
25+
2426
logger = logging.getLogger("sqlspec.adapters.adbc")
2527

2628

@@ -116,7 +118,8 @@ def __init__(
116118
statement_config: StatementConfig | None = None,
117119
driver_features: "AdbcDriverFeatures | dict[str, Any] | None" = None,
118120
bind_key: str | None = None,
119-
extension_config: "dict[str, dict[str, Any]] | LitestarConfig | FastAPIConfig | StarletteConfig | FlaskConfig | ADKConfig | None" = None,
121+
extension_config: "ExtensionConfigs | None" = None,
122+
observability_config: "ObservabilityConfig | None" = None,
120123
) -> None:
121124
"""Initialize configuration.
122125
@@ -127,6 +130,7 @@ def __init__(
127130
driver_features: Driver feature configuration (AdbcDriverFeatures)
128131
bind_key: Optional unique identifier for this configuration
129132
extension_config: Extension-specific configuration (e.g., Litestar plugin settings)
133+
observability_config: Adapter-level observability overrides for lifecycle hooks and observers
130134
"""
131135
if connection_config is None:
132136
connection_config = {}
@@ -168,6 +172,7 @@ def __init__(
168172
driver_features=processed_driver_features,
169173
bind_key=bind_key,
170174
extension_config=extension_config,
175+
observability_config=observability_config,
171176
)
172177

173178
def _resolve_driver_name(self) -> str:
@@ -366,9 +371,10 @@ def session_manager() -> "Generator[AdbcDriver, None, None]":
366371
or self.statement_config
367372
or get_adbc_statement_config(str(self._get_dialect() or "sqlite"))
368373
)
369-
yield self.driver_type(
374+
driver = self.driver_type(
370375
connection=connection, statement_config=final_statement_config, driver_features=self.driver_features
371376
)
377+
yield self._prepare_driver(driver)
372378

373379
return session_manager()
374380

sqlspec/adapters/adbc/driver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -706,8 +706,8 @@ def select_to_storage(
706706
self._require_capability("arrow_export_enabled")
707707
arrow_result = self.select_to_arrow(statement, *parameters, statement_config=statement_config, **kwargs)
708708
sync_pipeline: SyncStoragePipeline = cast("SyncStoragePipeline", self._storage_pipeline())
709-
telemetry_payload = arrow_result.write_to_storage_sync(
710-
destination, format_hint=format_hint, pipeline=sync_pipeline
709+
telemetry_payload = self._write_result_to_storage_sync(
710+
arrow_result, destination, format_hint=format_hint, pipeline=sync_pipeline
711711
)
712712
self._attach_partition_telemetry(telemetry_payload, partitioner)
713713
return self._create_storage_job(telemetry_payload, telemetry)

0 commit comments

Comments
 (0)