Skip to content

Correlate Python logs to active OTel span + bridge to stdlib logging - #169

Merged
brentrager merged 2 commits into
mainfrom
py-otel-log-correlation
Aug 11, 2026
Merged

Correlate Python logs to active OTel span + bridge to stdlib logging#169
brentrager merged 2 commits into
mainfrom
py-otel-log-correlation

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

The Python logger fabricated a uuid trace id per context, so its logs never correlated to real OTel traces. And its lines never flowed through the stdlib logging facade, so an installed @smooai/observability LoggingHandler never saw them. (Pearl th-de3805; matches ADR-100.)

Solution

  • Correlation: _build_record now replaces the fabricated uuid with the active OTel span's real W3C trace_id/span_id (get_current_span().get_span_context()), adding a spanId field. Falls back to the prior uuid behavior only when no span is active.
  • Bridge: every emitted line is forwarded through a dedicated stdlib logger (smooai_logger) so an observability LoggingHandler on root turns it into an OTLP log record — correlated to the active span, which that handler reads. A NullHandler keeps it a true no-op when observability isn't installed. The file-rotation logger no longer propagates its pretty ANSI blob to root (would otherwise leak into the bridge).
  • Depends on opentelemetry-api only — never on @smooai/observability (circular).

Tests (verified, not just compiled)

python/tests/test_otel_correlation.py, api-only NonRecordingSpan:

  • log inside a span carries that span's trace_id/span_id
  • no span → uuid fallback, no spanId
  • bridged line reaches the stdlib root logger
  • bridged record is emitted inside the span context (so an obs handler correlates it)

Full Python suite: 58 passed, ruff lint + format clean, basedpyright 0 errors.

🤖 Generated with Claude Code

Replaces the fabricated uuid trace id with the ACTIVE OTel span's real W3C
trace_id/span_id when a span is active (get_current_span().get_span_context()),
falling back to the prior uuid behavior only when no span is active. Adds a
spanId field to the record.

Also bridges every emitted line through a dedicated stdlib `logging` logger
(smooai_logger) so an installed @smooai/observability LoggingHandler on the root
logger turns each line into an OTLP log record — correlated to the active span,
which that handler reads. NullHandler keeps it a true no-op when observability
isn't installed. The file-rotation logger no longer propagates its pretty ANSI
blob to root (would otherwise leak into the OTLP bridge).

Depends on opentelemetry-api ONLY — never on @smooai/observability (circular).
Verified with an api-only NonRecordingSpan: a log inside the span carries that
span's ids; no span → uuid fallback; the bridged line reaches root within the
span context.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S2bM94GAnVjYSSv1x7HKRB
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 0255f85

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

… wire in camelCase

Two review findings, both cheaper now than after five packages publish.

1. DOUBLE EMISSION — a regression for every existing Python consumer. The bridge
   logger propagates to root, so it only helps when observability's
   `LoggingHandler` is attached there. When it is NOT, but the app has called
   `logging.basicConfig()` — Lambda, uvicorn and pytest all do — root has a plain
   StreamHandler, and every smooai line is printed a SECOND time on top of our own
   stdout writer. In exchange for nothing.

   `_otel_consumer_present()` capability-detects a real OTel logging handler
   before emitting, and honours the family-wide `SMOOAI_OBSERVABILITY_DISABLED`
   kill switch. This is the same "enabled means a consumer is registered"
   contract the other languages use, rather than Python's always-on.

   Proven, not asserted: deleting the gate fails
   `test_no_otel_consumer_means_no_bridged_record` with "a plain root handler
   received a bridged record" and fails the kill-switch test. The paired
   `test_an_otel_consumer_does_receive_the_record` is the negative control — if
   the gate were simply always-off, it would fail instead.

2. `extra["correlation_id"]` was the only snake_case key on the wire; every other
   language emits camelCase (`ContextKey` in TS/Go/Rust/.NET). Renamed to
   `correlationId` before anything queries it, since changing it later is breaking.

The PR's two existing bridge tests asserted delivery to ANY root handler — which
is precisely the double-emission behaviour — so they now use an OTel-shaped
handler and assert the contract that actually matters.

62 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brentrager
brentrager merged commit 0f66fd1 into main Aug 11, 2026
1 check passed
@brentrager
brentrager deleted the py-otel-log-correlation branch August 11, 2026 19:07
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