Skip to content

Native OpenTelemetry tracing — foundation + ADR (Phase 1a) - #689

Open
wbarnha wants to merge 5 commits into
masterfrom
claude/otel-native-tracing
Open

Native OpenTelemetry tracing — foundation + ADR (Phase 1a)#689
wbarnha wants to merge 5 commits into
masterfrom
claude/otel-native-tracing

Conversation

@wbarnha

@wbarnha wbarnha commented Jul 18, 2026

Copy link
Copy Markdown
Member

Description

Groundwork for migrating Faust's built-in distributed tracing off the archived / EOL OpenTracing API to native OpenTelemetry, while keeping the full processing-pipeline span tree (agents, streams, table recovery, assignor, timers, Crontabs, rebalance).

This is Phase 1a — foundation only. It adds an architecture decision record and a self-contained, unit-tested native tracing core. No existing call sites are rewired yet, so the running OpenTracing layer is completely unaffected. Opening as a draft to get agreement on the approach before the larger Phase 1b rewire.

Synced with master. Master has since landed the sensor-level OpenTelemetry path (#748), the OTel metrics monitor (#746), and moved opentracing out of core requirements into the faust[opentracing] extra (#686). The merge commit resolves the requirements/extras/opentelemetry.txt and setup.py conflicts and refreshes the ADR against the new tree — see "Relationship to faust.contrib.opentelemetry" in the ADR.

Why native OTel (and why not an instrumentor)

  • OpenTracing is archived/EOL; the OpenTelemetry OpenTracing shim was formally deprecated on 2026-03-19 (removal ≥ Mar 2027). The shim bridge (Add optional faust[opentelemetry] extra + docs for OpenTelemetry via the OpenTracing shim #688) is a valid interim, not a destination.
  • There is no opentelemetry-instrumentation-faust package — not on PyPI, not in opentelemetry-python-contrib, nothing community-maintained. The transport-level instrumentors (opentelemetry-instrumentation-aiokafka / -confluent-kafka, both beta) only patch the raw Kafka clients and cannot reproduce Faust's agent/stream/recovery/rebalance spans — they complement, they can't replace.
  • A library should depend only on opentelemetry-api (no SDK); calls are cheap no-ops until the app registers a TracerProvider. opentelemetry.context is contextvars-based, so Faust's current-span propagation maps 1:1, including across await.

Relationship to faust.contrib.opentelemetry (#748)

Complementary, not competing. OpenTelemetrySensor sits at the sensor layer — one {topic} process span per event, which is what closes the aiokafka instrumentor's cross-thread gap — and it gives users native traces today without touching app.tracer. Sensors have no hooks for table recovery, the assignor, timers, Crontabs, the rebalance sequence, or the produce side; this work covers that built-in span tree, the ~134 call sites that today can only emit OpenTracing spans. After Phase 1b both emit into the same native OTel trace.

What's in this PR

  • docs/adr/0001-native-opentelemetry-tracing.md — the decision record: options considered, how this relates to the sensor-level path, semantic-convention coexistence, native re-expression of the two non-portable OpenTracing hacks, and the phased migration plan.
  • faust/utils/otel_tracing.py — api-only tracing primitives that degrade to no-ops when OpenTelemetry is absent:
    • tracer resolution against the global provider;
    • contextvar current-span helpers;
    • deterministic_parent_context() — native replacement for the old span.context.trace_id = murmur2(...) mutation (OTel span contexts are immutable): a NonRecordingSpan parent carries the seeded trace id and the real rebalance span inherits it;
    • PendingSpan — native replacement for the _transform_span_lazy span.finish monkeypatch: defer span creation until the Kafka generation is known, or cancel;
    • semconv-coexistence helpers — every span carries both the legacy kafka-* tags (deprecated) and the OpenTelemetry messaging.* attributes + SpanKind;
    • W3C traceparent header inject/extract.
  • tests/unit/utils/test_otel_tracing.py — 19 tests covering every primitive against an in-memory SDK exporter (both hacks validated end-to-end); the no-OTel degradation path is also exercised.
  • requirements/extras/opentelemetry.txt — adds opentelemetry-sdk to the existing extra, so apps can configure an exporter out of the box. The tracing core itself needs only opentelemetry-api.

Semantic-convention coexistence

Adopted additively: span names stay legacy by default (consume-from-{topic}, job-{topic}, produce-to-{topic}) and are marked deprecated, while messaging.* attributes + SpanKind coexist on the same span. A future opt-in flips names to semconv ({topic} process, …).

Migration plan (see ADR)

Notes for reviewers

  • Nothing here changes runtime behavior — the new module isn't imported by Faust core yet.
  • The breaking-change and release-timing decisions (Phase 1b) are intentionally left for maintainers before that work lands.

🤖 Generated with Claude Code

Groundwork for migrating Faust's built-in distributed tracing off the
archived OpenTracing API to native OpenTelemetry, keeping the full span
tree. This first increment adds the decision record and a self-contained,
unit-tested tracing core; no existing call sites are rewired yet, so the
running tracing layer is unaffected.

- docs/adr/0001-native-opentelemetry-tracing.md: architecture decision
  record (options, why no opentelemetry-instrumentation-faust exists,
  semconv coexistence, re-expression of the two non-portable hacks, and
  the phased migration plan).
- faust/utils/otel_tracing.py: api-only tracing primitives that degrade to
  no-ops when OpenTelemetry is absent -- tracer resolution against the
  global provider, contextvar current-span, deterministic rebalance parent
  context (native replacement for the immutable-context trace_id mutation),
  a deferred PendingSpan (native replacement for the finish monkeypatch),
  semconv-coexistence attribute helpers (legacy tags + messaging.*), and
  W3C header inject/extract.
- tests/unit/utils/test_otel_tracing.py: 15 tests covering every primitive
  against an in-memory SDK exporter.
- requirements/extras/opentelemetry.txt + setup.py: faust[opentelemetry]
  extra (opentelemetry-api/sdk).

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

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.24%. Comparing base (b673b94) to head (3ec48dc).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #689      +/-   ##
==========================================
+ Coverage   96.20%   96.24%   +0.03%     
==========================================
  Files         110      111       +1     
  Lines       11790    11893     +103     
  Branches     1281     1294      +13     
==========================================
+ Hits        11343    11446     +103     
  Misses        350      350              
  Partials       97       97              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The Phase 1a tests guard on `pytest.importorskip("opentelemetry.sdk.trace")`,
so without OpenTelemetry installed in CI they were all skipped, leaving
faust/utils/otel_tracing.py at 29% patch coverage and failing the codecov
gate.

- requirements/test.txt: pull in extras/opentelemetry.txt so the test job
  installs opentelemetry-api/sdk and the 15 tests actually execute
  (matches the existing `-r extras/*.txt` pattern in test.txt).
- otel_tracing.py: mark the dependency-absent `if not HAS_OTEL:` fallbacks
  with `# pragma: no cover` -- they only run when OpenTelemetry is not
  installed, a config CI no longer exercises.
- test_otel_tracing.py: add tests for the real runtime guards (None span,
  None tracer, non-bytes key, default carrier) instead of pragma-ing them.

Module coverage is now 96%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHPL4VFWQRQPpjR1gXSKyL
@wbarnha
wbarnha marked this pull request as ready for review August 11, 2026 23:43

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd9eedab7c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

carrier = {}
if not HAS_OTEL: # pragma: no cover
return carrier
propagate.inject(carrier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inject the span tracked by Faust

When a producer span is made current through set_current_span(), as the existing tracing call sites do, this call still reads OpenTelemetry's separate global context rather than _current_span. Unless every caller also manually attaches an OTel context, outbound Kafka records will therefore receive no traceparent (or may receive a stale unrelated one), breaking producer-to-consumer trace continuity. Pass a context derived from current_span() to propagate.inject() or make the setter attach and restore the OTel context.

Useful? React with 👍 / 👎.

Comment on lines +302 to +306
self._span = self.tracer.start_span(
self.name,
context=self.context,
kind=kind,
attributes=self.attributes,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the pending operation's original start time

For a rebalance span queued while the Kafka generation is unknown, materialize() starts the span only when the generation later becomes available. The old lazy implementation started the span at the beginning of the operation and deferred only its finish, so this replacement omits all waiting time and reports a near-zero or otherwise shortened rebalance duration. Capture the intended start timestamp when PendingSpan is created and pass it as start_time when starting the real span.

Useful? React with 👍 / 👎.

self.name = name
self.kind = kind
self.attributes = attributes
self.context = context

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Capture the parent context before deferring the span

When a pending operation is created without an explicit context, this stores None, so start_span() resolves its parent from whatever OpenTelemetry context happens to be current later at materialization time. A rebalance can outlive the span or task that initiated it, causing the exported span to become a root or a child of an unrelated operation. Snapshot the current context when PendingSpan is constructed so deferred creation preserves the original trace hierarchy.

Useful? React with 👍 / 👎.

Comment on lines +129 to +130
digest = hashlib.blake2b(seed.encode(), digest_size=16).digest()
trace_id = int.from_bytes(digest, "big")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the existing rebalance trace-ID derivation

During a rolling migration where old OpenTracing members and native OTel members participate in the same Kafka generation, the old path derives the shared trace ID with Kafka's murmur2, while this function derives a different BLAKE2 value for the identical reb-{app_id}-{generation} seed. The generation's rebalance spans will therefore split into separate traces precisely while the compatibility path is in use, contrary to the ADR's stated murmur2-derived replacement. Retain the numeric murmur2 result when constructing the 128-bit OTel trace ID.

Useful? React with 👍 / 👎.

span.set_attribute(_MSG_SYSTEM, "kafka")
span.set_attribute(_MSG_DESTINATION, topic)
span.set_attribute(_MSG_OPERATION_TYPE, operation)
if partition is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit the required messaging operation name

For every span produced through these semantic-convention helpers, only messaging.operation.type is emitted, while the stable messaging conventions also require messaging.operation.name. Collectors and dashboards that classify messaging spans through the required operation-name attribute will therefore see these Faust spans as incomplete even though the module claims semantic-convention coexistence. Set messaging.operation.name alongside the type, using the concrete process or send operation supplied here.

Useful? React with 👍 / 👎.

claude and others added 2 commits August 13, 2026 00:00
Brings the OTel tracing foundation up to date with master, which has since
landed the sensor-level OpenTelemetry path (#748), the OTel metrics monitor
(#746), and moved `opentracing` out of core requirements into an extra (#686).

Conflict resolutions:

- `requirements/extras/opentelemetry.txt` (add/add): keep master's
  API floor and the fastapi/aiokafka instrumentation packages, and add
  `opentelemetry-sdk` on top so apps can configure an exporter out of the
  box. The tracing core itself still needs only the API.
- `setup.py`: take master's bundle list, which already contains the
  `opentelemetry` entry this branch added.
- `requirements/test.txt`: drop this branch's `-r extras/opentelemetry.txt`
  line; master already pulls the extra in, plus explicit api/sdk pins.

The ADR is refreshed against the new tree: `opentracing` is described as an
extra rather than a hard core dependency, a section explains how this work
relates to `faust.contrib.opentelemetry` (sensor covers the message
boundary, this covers the built-in span tree), and the phase list records
what has already shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBE5RZ5mb1h5MjYyWGwmHZ

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22fee2433c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"""Extract a parent context from *carrier* (W3C trace headers)."""
if not HAS_OTEL: # pragma: no cover
return None
return propagate.extract(carrier)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use a Kafka-aware getter when extracting headers

When this helper receives Faust's normal Message.headers, propagation fails because Headers is commonly a list of (str, bytes) pairs (faust/types/core.py), while OpenTelemetry's default getter expects a mapping and does not decode Kafka's byte values. The existing OpenTelemetrySensor therefore supplies a custom getter that handles both representations; use the same normalization here, otherwise inbound Kafka records cannot continue their producer trace and list carriers may raise during extraction.

Useful? React with 👍 / 👎.

Master's project coverage has risen to 96.20%, which raises codecov's
auto-target for this PR's patch above the 94.33% it has always measured, so
`codecov/patch` and `codecov/project` went red on the sync commit rather than
on any new code.

Two causes, both in the new module:

- `.coveragerc` excludes the `if typing.TYPE_CHECKING:` spelling, which is
  what the rest of faust uses.  `otel_tracing.py` imports the name directly
  and writes bare `if TYPE_CHECKING:`, so the regex misses it and the two
  import lines under it count as unexecuted.  Marked `# pragma: no cover`,
  matching the one other bare-form site (`faust/models/typing.py`).
- The optional-argument arms of `add_consume_attributes` /
  `add_produce_attributes` were only ever called with every optional
  supplied, leaving four partial branches.  Added two tests covering the
  topic-only path, which also pin the intended behavior: an unknown
  partition/key/offset is omitted from the span rather than recorded as a
  null.

`faust/utils/otel_tracing.py` now reports 100% (103 statements, 26 branches).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBE5RZ5mb1h5MjYyWGwmHZ

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ec48dcd7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

_MSG_PARTITION = "messaging.destination.partition.id"
_MSG_OPERATION_TYPE = "messaging.operation.type"
_MSG_KAFKA_KEY = "messaging.kafka.message.key"
_MSG_KAFKA_OFFSET = "messaging.kafka.offset"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the Kafka message-offset semantic attribute

When either messaging helper receives an offset, it records messaging.kafka.offset, which is not the Kafka semantic-convention key; the existing native OTel sensor correctly emits messaging.kafka.message.offset in faust/contrib/opentelemetry.py. Backends and dashboards querying the standard attribute will therefore lose offsets from all spans created through these new helpers. Rename this constant and update the corresponding tests.

Useful? React with 👍 / 👎.

Comment on lines +310 to +312
def cancel(self, note: str = "CANCELLED") -> None:
"""Drop the pending span; if already materialized, mark and end it."""
if self._span is not None and HAS_OTEL:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent canceled pending spans from materializing

When cancel() is called before materialization—the documented path for dropping a pending operation—it records no canceled state, so a later or racing materialize() still starts and exports the span. This can occur when shutdown cancellation and generation discovery act on the same retained descriptor, defeating the promise that canceled pending work is dropped; latch the canceled state and make materialize() return None afterward.

Useful? React with 👍 / 👎.

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.

2 participants