Skip to content

fix(backend): surface SQLAlchemy instrumentation's silent no-op; document observability - #53

Merged
markadams31 merged 3 commits into
mainfrom
fix/sqlalchemy-instrumentation-conflict
Jul 4, 2026
Merged

fix(backend): surface SQLAlchemy instrumentation's silent no-op; document observability#53
markadams31 merged 3 commits into
mainfrom
fix/sqlalchemy-instrumentation-conflict

Conversation

@markadams31

@markadams31 markadams31 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Generated real traffic against the dev deployment via Chrome DevTools (CRUD, search, a rowversion-checked update, deliberate 409/422/404 errors) to verify the observability story end-to-end, then found and fixed a real SQL-tracing gap in two stages.

Stage 1 — surfaced the silent failure: AppDependencies showed zero SQL statement spans despite the startup log claiming SQLAlchemy database instrumentation enabled. Root cause: SQLAlchemyInstrumentor().instrument() defaults to raise_exception_on_conflict=Falseopentelemetry-instrumentation-sqlalchemy declares support for sqlalchemy>=1.0.0,<2.1.0, this app requires the 2.1 line (for the mssql+mssqlpython dialect, which can't change), and with the default that conflict silently logs a warning instead of failing. First fix: raise_exception_on_conflict=True, so the app honestly reports failure instead of a false success.

Stage 2 — actually fixed it: since SQLAlchemy can't be downgraded, researched whether Microsoft/Azure ship an alternative. opentelemetry-instrumentation-dbapi (same OpenTelemetry contrib family, one layer lower) patches a DB-API driver's connect() directly and has no SQLAlchemy version dependency at all — it isn't blocked on the SQLAlchemy-specific instrumentor ever catching up to 2.1. instrument_sqlalchemy() now calls trace_integration against mssql_python directly. SQLAlchemyInstrumentor and the now-unneeded opentelemetry-instrumentation-sqlalchemy dependency are removed.

Also verified, tracing a live request end-to-end: the App Insights traceparent/OperationId is per browser page session, not per user action. The reliable per-action key is request_id (app/middleware.py), confirmed by pulling the exact request_id off a live 409 CONSTRAINT_VIOLATION and querying AppTraces | where Properties.request_id == "<id>", which returned both the access-log line and the route handler's own database-error detail line for that one request.

README: adds an Observability section (what's wired up, how to trace one action end-to-end, and the now-resolved SQL-tracing note explaining why the DB-API approach was chosen).

Verification

  • instrument_sqlalchemy() patches mssql_python.connect (identity-checked, not just trusting the return value)
  • A DB span emitted during a real FastAPI request shares that request's trace id and parents correctly — proven with sqlite3 as a stand-in DB-API 2.0 driver in the committed unit tests
  • Confirmed against a real SQL Server 2025 container (Testcontainers image already used by the integration tier): ran a real FastAPI request through the instrumented app, over a real mssql_python connection to the live container. Captured 7 real db.statement spans (actual SQL text — SQLAlchemy's own dialect-detection queries plus a manual SELECT @@VERSION), every one sharing the exact trace_id of the GET /ping request span and correctly parented underneath it. This is the strongest evidence available short of the actual Azure SQL connection.
  • Full integration suite (169 tests) still passes against the same container, unaffected by the telemetry changes
  • All 449 unit/API tests pass, pyright clean, ruff clean
  • README's documented KQL query (AppTraces | where Properties.request_id == "<id>") verified directly against the real Log Analytics workspace using a request_id pulled from a live request

🤖 Generated with Claude Code

markadams31 and others added 3 commits July 4, 2026 15:01
Traced live traffic on the dev deployment (constraint violations, not-found
errors, a rowversion-checked update) and found zero SQL-typed spans in
AppDependencies across the entire session, despite the startup log claiming
"SQLAlchemy database instrumentation enabled."

Root cause: SQLAlchemyInstrumentor().instrument() defaults to
raise_exception_on_conflict=False. opentelemetry-instrumentation-sqlalchemy
declares support for sqlalchemy>=1.0.0,<2.1.0 (true as of its latest release,
0.64b0 — there is currently no compatible release), but this app requires the
2.1 line (see connection.py, for the mssql+mssqlpython dialect). With the
default, that conflict is logged as a warning and instrument() returns
normally WITHOUT actually patching sqlalchemy.create_engine — so
instrument_sqlalchemy() had no way to notice, and every deploy has silently
reported success while shipping zero database dependency spans.

Pass raise_exception_on_conflict=True so the conflict raises instead,
routing through the existing except block (which already logs exc_info and
returns False) — an honest failure instead of a false success.

This is a real, currently-unfixable upstream gap: no released version of
opentelemetry-instrumentation-sqlalchemy supports SQLAlchemy 2.1. The app
will correctly report no SQL dependency spans until that changes; this
commit's job is making that visible rather than silently wrong.

Also extends test_telemetry.py: instrument_sqlalchemy()'s return value must
now match whether create_engine was actually replaced — verified directly by
comparing object identity before/after, not by trusting the return value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents the telemetry stack (server + client App Insights export,
structured access log correlation id, platform logs) and — concretely,
verified by generating real traffic against the dev deployment via Chrome
DevTools and cross-referencing every log source — how to actually trace one
user action end-to-end.

Two things worth calling out explicitly, since they'd otherwise surprise
whoever next reaches for this:

- App Insights' own trace-id (traceparent/OperationId) is per browser page
  session, not per user action — every request from one loaded page shares
  it. The per-action key is request_id (app/middleware.py), verified by
  querying AppTraces for the exact request_id off a live 409 constraint
  violation and getting back both the access-log line and the route
  handler's own error-detail log line for that one request.
- The Known gap section documents the SQL-instrumentation limitation fixed
  in the previous commit: no released opentelemetry-instrumentation-
  sqlalchemy version supports SQLAlchemy 2.1, so AppDependencies has no SQL
  spans until upstream catches up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…QLAlchemy support

Researched whether Microsoft/Azure ship an alternative SQL telemetry path,
since SQLAlchemy can't be downgraded (the 2.1 line is required for the
mssql+mssqlpython dialect). opentelemetry-instrumentation-dbapi — the same
OpenTelemetry contrib family as the sqlalchemy instrumentor, one layer lower
— patches a DB-API driver's connect() directly and has no SQLAlchemy version
dependency at all, so it isn't blocked on the SQLAlchemy-specific
instrumentor catching up to 2.1.

Verified before committing to the approach:
- trace_integration(mssql_python, "connect", "mssql") genuinely patches
  mssql_python.connect (confirmed via identity check)
- the resulting spans carry db.statement (actual SQL text) and db.system
- critically, a DB span emitted during a request shares that request's
  trace id and is parented to the request span — confirmed with a real
  FastAPI request through TestClient, not just an isolated connect() call

instrument_sqlalchemy() now calls trace_integration against mssql_python
directly; SQLAlchemyInstrumentor and the now-unneeded
opentelemetry-instrumentation-sqlalchemy dependency are removed.

Test changes: replaces the create_engine-identity check (no longer the
patching mechanism) with two tests — one confirming mssql_python.connect
gets patched, one driving a real request through an instrumented app and
asserting the resulting DB span nests under the request span (shared trace
id, real parent-child relationship), using sqlite3 as a stand-in DB-API 2.0
driver to isolate the assertion from needing a reachable SQL Server.

README: updates the "Known gap" section — no longer a gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@markadams31
markadams31 merged commit 6fcf8e3 into main Jul 4, 2026
20 checks passed
@markadams31
markadams31 deleted the fix/sqlalchemy-instrumentation-conflict branch July 4, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant