Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`.

**Stack**: Litestar + SQLAlchemy 2 (async) + advanced-alchemy + Alembic + Postgres + Granian (ASGI server) + modern-di (IoC) + lite-bootstrap (observability/CORS/Sentry/OTel wiring).

**Request flow**: `app/__main__.py` → `granian` → `app.application:build_app` (factory) → `LitestarBootstrapper` from `lite-bootstrap` wraps a `litestar.Litestar` with OpenTelemetry (asyncpg + SQLAlchemy instrumentors), Sentry, CORS, Swagger, etc., based on `Settings.api_bootstrapper_config`.
**Request flow**: `app/__main__.py` → `granian` → `app.application:build_app` (factory) → `LitestarBootstrapper` from `lite-bootstrap` wraps a `litestar.Litestar` with OpenTelemetry (SQLAlchemy instrumentor), Sentry, CORS, Swagger, etc., based on `Settings.api_bootstrapper_config`.

**Dependency injection** (`app/ioc.py`): `modern_di.Container` is created in `build_app` with the `Dependencies` group and attached via `modern_di_litestar.ModernDIPlugin`. Route handlers receive repositories as parameters; `application.py` declares them with `modern_di_litestar.FromDI(...)` so Litestar resolves them per-request. Provider scopes:
- `database_engine` — application-scoped primary engine, finalizer disposes the engine
Expand Down
6 changes: 1 addition & 5 deletions app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from advanced_alchemy.exceptions import DuplicateKeyError, NotFoundError
from lite_bootstrap import LitestarBootstrapper
from litestar.config.app import AppConfig
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

from app import exceptions, ioc, repositories
Expand Down Expand Up @@ -35,10 +34,7 @@ def build_app() -> litestar.Litestar:
},
request_max_body_size=settings.request_max_body_size,
),
opentelemetry_instrumentors=[
SQLAlchemyInstrumentor(),
AsyncPGInstrumentor(capture_parameters=True),
],
opentelemetry_instrumentors=[SQLAlchemyInstrumentor()],
)
bootstrapper = LitestarBootstrapper(bootstrap_config=bootstrap_config)
return bootstrapper.bootstrap()
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ dependencies = [
"sqlalchemy[asyncio]<2.1",
"asyncpg",
# tracing
"opentelemetry-instrumentation-asyncpg",
"opentelemetry-instrumentation-sqlalchemy",
]

Expand Down
40 changes: 40 additions & 0 deletions tests/test_tracing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import dataclasses

import pytest
from asgi_lifespan import LifespanManager
from httpx import ASGITransport, AsyncClient
from litestar import status_codes
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from app.application import build_app
from app.settings import Settings, settings


async def test_db_queries_produce_sqlalchemy_spans(monkeypatch: pytest.MonkeyPatch) -> None:
traced_config = dataclasses.replace(settings.api_bootstrapper_config, opentelemetry_log_traces=True)
monkeypatch.setattr(Settings, "api_bootstrapper_config", traced_config)
exporter = InMemorySpanExporter()
app = build_app()
tracer_provider = trace.get_tracer_provider()
assert isinstance(tracer_provider, TracerProvider)
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))

async with (
LifespanManager(app), # ty: ignore[invalid-argument-type]
AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client, # ty: ignore[invalid-argument-type]
):
response = await client.get("/api/decks/")

assert response.status_code == status_codes.HTTP_200_OK
db_spans = [
span
for span in exporter.get_finished_spans()
if span.instrumentation_scope is not None
and span.instrumentation_scope.name == "opentelemetry.instrumentation.sqlalchemy"
and span.name.startswith("SELECT")
]
assert db_spans
assert all(span.parent is not None for span in db_spans)
16 changes: 0 additions & 16 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading