Skip to content

Commit 6aec1d2

Browse files
authored
Merge branch 'main' into 173_usage_configuration
2 parents 91c6d0e + 9521c6d commit 6aec1d2

Some content is hidden

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

65 files changed

+3887
-378
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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,18 @@ Litestar Plugin Configuration
296296
:lines: 2-26
297297
:dedent: 2
298298

299+
Telemetry Snapshot
300+
~~~~~~~~~~~~~~~~~~
301+
302+
Call ``SQLSpec.telemetry_snapshot()`` to inspect lifecycle counters, serializer metrics, and recent storage jobs:
303+
304+
.. code-block:: python
305+
306+
snapshot = spec.telemetry_snapshot()
307+
print(snapshot["storage_bridge.bytes_written"])
308+
for job in snapshot.get("storage_bridge.recent_jobs", []):
309+
print(job["destination"], job.get("correlation_id"))
310+
299311
Environment-Based Configuration
300312
-------------------------------
301313

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: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
77
name = "sqlspec"
88
readme = "README.md"
99
requires-python = ">=3.10, <4.0"
10-
version = "0.28.1"
10+
version = "0.29.0"
1111

1212
[project.urls]
1313
Discord = "https://discord.gg/litestar"
@@ -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",
@@ -209,7 +217,7 @@ opt_level = "3" # Maximum optimization (0-3)
209217
allow_dirty = true
210218
commit = false
211219
commit_args = "--no-verify"
212-
current_version = "0.28.1"
220+
current_version = "0.29.0"
213221
ignore_missing_files = false
214222
ignore_missing_version = false
215223
message = "chore(release): bump to v{new_version}"

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)