Skip to content

PYTHON-5947 Add OpenTelemetry Operation and Transaction Span Support - #2964

Draft
blink1073 wants to merge 53 commits into
mongodb:otelfrom
blink1073:PYTHON-5947-otel-impl
Draft

PYTHON-5947 Add OpenTelemetry Operation and Transaction Span Support#2964
blink1073 wants to merge 53 commits into
mongodb:otelfrom
blink1073:PYTHON-5947-otel-impl

Conversation

@blink1073

@blink1073 blink1073 commented Jul 28, 2026

Copy link
Copy Markdown
Member

PYTHON-5947

Changes in this PR

Extends the OpenTelemetry support added in PYTHON-5945 with:

  • Operation-level spans — one span per public API call (e.g. find_one, insert_one, bulk_write), spanning all retry attempts, with each attempt's command span nested underneath. A cursor's whole lifetime is a single operation span, so getMore commands nest under the originating find/aggregate rather than producing spans of their own.
  • Transaction pseudo-spans — a "transaction" span wrapping start_transaction() through commit_transaction()/abort_transaction(), with operations inside the transaction nested under it. A retried with_transaction() produces one span covering all of its attempts.
  • Unified test format supportobserveTracingMessages/expectTracingMessages, plus the vendored spec test suite from mongodb/specifications.

Spans stay conformant on failure paths too: an operation that fails before any command reaches the wire still carries db.operation.name and db.operation.summary, and killCursors/endSessions now get operation spans rather than leaving their command spans unparented.

A few small pre-existing bugs surfaced by the new spec test coverage were fixed along the way (see commit history for details).

This is opt-in: no behavior changes for users who don't enable the tracing client option or OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED env var.

Reviewer's guide

The diff is large but most of it isn't hand-written. Of ~9,800 added lines:

Lines Needs review?
Vendored spec fixtures (test/open_telemetry/*.json) ~4,100 No — copied from mongodb/specifications via resync-specs.sh
Generated sync mirrors (pymongo/synchronous/*, mirrored test/*) ~2,400 No — just synchro output
Hand-written ~3,300 Yes, and ~1,500 of that is test_otel.py

Suggested reading order. The commits are already in dependency order, so git log -p works, but if you'd rather read by file:

  1. pymongo/_otel.py (~500 lines, the core) — span creation/ending primitives. Note the three-way split: command spans, operation spans (with an eager-attribute and a detached mode), and the "transaction" pseudo-span. The parent_span parameter and the deliberate choice not to read ambient context for transaction parenting are the two decisions worth scrutinising.
  2. pymongo/_telemetry.py_OperationTelemetry, the lifecycle wrapper. One line here (parent_span = session._transaction.span) is the only coupling between operation and transaction spans.
  3. pymongo/asynchronous/mongo_client.py_retry_internal gained three span modes: owned (creates and ends its own), caller-owned (makes a caller's span current, creates/ends nothing), and passthrough (creates nothing, leaves ambient in place, used only by the client bulk-write results cursor). Also the killCursors/endSessions wrappers.
  4. pymongo/cursor_shared.py + asynchronous/cursor.py + asynchronous/command_cursor.py — cursor-lifetime span ownership. _end_operation_telemetry is idempotent and called from the two choke points every close/GC/exception path funnels through; the exactly-once property across those paths is the main thing to check.
  5. pymongo/asynchronous/client_session.py — transaction spans, including the shared-span-across-with_transaction-retries mechanism and its reentrancy guard.
  6. test/asynchronous/unified_format.pyobserveTracingMessages/expectTracingMessages wiring, modelled on the existing expectLogMessages handling.

Things you might reasonably ask about, already considered:

  • bson/json_util.py is touched_truncate_documents silently dropped falsy values (0, False, "", {}, []) from truncated output. It's shared with pymongo/logger.py, so this was affecting structured command logging too, not just tracing. Has its own regression test.
  • Change streams deliberately opt out of getMore nesting — a tailing stream's operation span would never end. Comment at the call site records this.
  • test/__init__.py and test/asynchronous/__init__.py changed — tracing-enabled test clients join their monitors on cleanup, so a stray heartbeat span can't land in another test's assertion window. Gated on tracing being enabled, so non-tracing tests keep the original cleanup path.

Test Plan

  • Unit tests for the span primitives (in-memory exporter, no server needed).
  • Integration tests against a live replica set covering operation spans, cursor and transaction span nesting, retry and failure paths, and sensitive-command redaction.
  • The full vendored OpenTelemetry spec test suite from mongodb/specifications.
  • Verified on Python 3.10 and 3.13 — one bug in this area reproduced only on 3.11+, so the suite is checked against both.

Checklist

Checklist for Author

  • Did you update the changelog (if necessary)?
  • Is there test coverage?
  • Is any followup work tracked in a JIRA ticket? If so, add link(s).

No follow-up work outstanding — the span-coverage gaps originally deferred from this ticket are all closed here.

Checklist for Reviewer

  • Does the title of the PR reference a JIRA Ticket?
  • Do you fully understand the implementation? (Would you be comfortable explaining how this code works to someone else?)
  • Is all relevant documentation (README or docstring) updated?

blink1073 added 19 commits July 27, 2026 18:40
test_sensitive_command_produces_no_span previously asserted no span at
all was produced for a sensitive command, but narrowing it to only
check the (correctly suppressed) command span silently dropped that
coverage. Add a companion assertion documenting that the wrapping
operation span is not sensitivity-gated (start_operation_span has no
such check, unlike start_command_span) and still exposes the bare
command name, so the gap is tracked instead of silently uncovered.
Add integration tests for the STARTING/COMMITTED_EMPTY early-return in
commit_transaction, the STARTING early-return in abort_transaction, and a
retried commit, per code review: none of the prior tests exercised a
transaction ended without ever sending a server command.
Task 9's fix to bson/json_util._truncate_documents (removing a truthy
check that silently dropped falsy-but-valid field values like 0, False,
"", {}, []) was previously only covered incidentally via the vendored
OTel spec tests. Add a direct unit test so a future edit to this shared
production helper (also used by pymongo/logger.py for structured
command logging) can't silently reintroduce the bug.
…rom final review

Final whole-branch review found that most _retry_internal call sites pass an
_Op enum member (a str-mixin enum) as the operation name; Python 3.11 changed
str-mixin Enum formatting so this corrupted span names/db.operation.name on
every Python version from 3.11 through 3.14 (worked by accident on 3.10). Also
fixes: Database.command() now produces a "runCommand" operation span per the
OTel spec's driver-operation-name rule instead of leaking the underlying
(possibly sensitive, e.g. saslStart) command name; the operation span's
namespace/summary backfill now runs before the sensitive-command suppression
check so it still gets its required attributes; and operation spans now carry
exception.type/message/stacktrace attributes like command spans already did.
Also merges two adjacent versionchanged:: 4.18 docstring blocks for the
tracing option into one.
…span fixes

Adds regression coverage for the final-review fixes: an operation-name
normalization test using an _Op enum member (meaningful on every Python
version, not just 3.11+, since 3.10's accidental correctness is what hid the
bug), a runCommand operation-span-naming test (unit + live), and an
operation-span exception-attribute assertion. Updates test_otel.py assertions
that assumed the old (pre-fix) db.operation.name values, and updates or
removes the now-resolved TODO(PYTHON-5947) comments. Adds one-line comments
distinguishing the two hardcoded unified-format test skips (a removed API vs.
a genuine fixture/driver mismatch) in unified_format.py.
test/open_telemetry/transaction/*.json and test_otel.py's
@require_transactions tests both require a replicaset/sharded topology, but
the OTel variant only ran against a standalone server, so transaction spans
never actually ran in CI. Adds a second task selector
(".test-non-standard .replica_set-noauth-ssl"), mirroring the existing
pattern of pairing a standard/standalone selector with a replica-set one
(e.g. create_pyopenssl_variants()). Regenerated via the generate-config
pre-commit hook rather than hand-editing the generated YAML.
docs/superpowers/plans/2026-07-27-python-5947-otel-operation-transaction-spans.md
is an internal planning artifact from the implementation process, not
project documentation (which lives under doc/, singular). The task list it
describes is now complete.
blink1073 added 10 commits July 28, 2026 07:55
trace.use_span defaults record_exception/set_status_on_exception to
True, so an exception propagating out of a `with use_operation_span():`
block was auto-recorded there and then again by the caller's own
end_operation_span_failure, producing two identical exception events
on the finished span. Disable both, matching how the attached-mode
path already avoids this via cm.__exit__(None, None, None). Also
tighten start_operation_span's dbname/collection checks to `is not
None` instead of truthiness.
…namespace

AsyncDatabase.__getattr__ synthesizes a collection for any unknown
attribute name, so the getattr(target, "database", None) probe used to
distinguish an AsyncCollection target from an AsyncDatabase target
returned a phantom collection for database/cluster-level change
streams instead of None. This misclassified AsyncDatabaseChangeStream
and AsyncClusterChangeStream targets as collections, putting the wrong
values into db.namespace/db.collection.name. Switch to isinstance
checks with a deferred (call-time) import to avoid the module-scope
circular import between change_stream.py and collection.py/database.py.
blink1073 added 14 commits July 28, 2026 12:54
Document that background monitoring spans no longer inherit a stale
parent from client startup, split a run-on sentence into two, and use
code spans for the concrete attribute names, per review feedback.
The otel test suite's InMemorySpanExporter/SimpleSpanProcessor for each
tracing-enabled test class is registered on one shared, process-wide
TracerProvider (trace.set_tracer_provider only takes effect once per
process) and is never removed. A monitor that is still finishing an
in-flight, cancelled heartbeat after its client's close() has already
returned can therefore emit a stray "hello" command span that lands in
whichever other test's span-capture window happens to be open at that
moment, since:

- The sync driver's Monitor.join() wraps two synchronous join() calls
  in an unawaited asyncio.gather(...), a leftover of generating the
  sync file from the async source, so it never actually blocks.
- Neither close() path guarantees every monitor executor has fully
  exited by the time it returns.

Wait for a tracing-enabled test client's monitor executors directly
(bypassing Monitor.join()) as part of its cleanup, instead of a plain
close(), and shut down each test class's own span exporter in
tearDownClass so it stops accumulating spans from unrelated clients
for the rest of the run.
The previous fix only joined monitors for clients created with an
explicit tracing={"enabled": True} kwarg, missing clients that become
tracing-enabled purely via the OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED
env var (e.g. TestOTelSpans's own env-var prose tests) -- exactly the
client shape identified as the root cause. There's no cheap, reliable
way to tell from outside a client whether it ended up tracing-enabled,
so route every managed client through the same close-and-join cleanup
unconditionally rather than trying to widen the gate. Confirmed via
timing (test_client.py + test_cursor.py, ~5 runs each before/after) that
this doesn't measurably slow the broader suite.
Routing every managed test client through the join-based cleanup
measurably slowed monitoring-heavy test files (test_streaming_protocol.py,
test_pooling.py, test_async_loop_unblocked.py) where clients' monitors
are still legitimately mid-flight at cleanup time, so the bounded
join(5) calls actually wait, sometimes close to the full timeout,
repeatedly. Only clients that can actually emit otel spans need this;
the rest can keep the plain client.close() cleanup.

Add _tracing_enabled_for_cleanup(), evaluated at client-creation time
(when the cleanup callable is chosen), reusing pymongo._otel's own
_OTEL_ENABLED_ENV/_env_truthy so the test harness can't disagree with
the driver about what "enabled" means. This covers both ways a client
ends up tracing-enabled -- the tracing={"enabled": True} kwarg, and the
OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED env var patched around
client creation in test_otel.py's env-var prose tests -- which was the
gap in the previous, kwarg-only gate.
… transaction's span

with_transaction()'s finally block unconditionally nulled
session._transaction.span, so calling it while a transaction started via
the direct API was already active on the same session (an illegal but
previously silent combination) would tear down the unrelated, still-active
transaction's span: it was never ended or exported, and every later
operation on that transaction became a spurious trace root.

Only clear the span this call actually owns, and defer creating
with_transaction()'s own shared span until it knows it will actually start
one, so the illegal-combination path no longer even creates a spurious
extra span.
…truction, not GC

AsyncCommandCursor.__init__ marks a cursor _killed=True when its first
batch already exhausts it, without calling close() -- no getMore is ever
sent, so the usual span-ending path (_refresh -> _die_lock, or close()'s
"if self._id == 0" check) never runs. The operation span for a
single-batch aggregate/listIndexes/listCollections/listDatabases/etc. call
was therefore only ended by an explicit close() or by __del__, inflating
its duration to whenever GC happens to run and, if the cursor is retained,
never ending or exporting it at all -- inconsistent with a multi-batch
cursor of the same kind, which always ends promptly at exhaustion.

Centralize span attachment in _AgnosticCursorBase._attach_operation_telemetry,
which ends the span immediately when the cursor is already killed at
construction, and route all 7 construction sites (aggregate, listIndexes,
listSearchIndexes, listCollections, cursorCommand, listDatabases, and their
sync mirrors) through it instead of poking _operation_telemetry directly.
…anup, consistency)

- mongo_client.py _retry_internal: replace the bare assert guarding
  reuse_current_span/operation_telemetry mutual exclusion with a real
  raise ValueError, so it still holds under python -O.
- change_stream.py: document why _run_aggregation_cmd deliberately omits
  operation_telemetry (a tailing change stream's operation span would
  never end), so the gap isn't mistaken for an oversight later.
- client_bulk.py execute(): replace the hand-built span name/db.namespace/
  db.operation.summary poking for the unacknowledged bulk-write path with
  _OperationTelemetry(..., dbname="admin"), which now produces the same
  attributes through the normal path.
- _otel.py start_operation_span: use the same collection truthiness
  convention (if collection:) as _build_query_summary, instead of
  is not None, so an (unreachable today) empty-string collection can't
  set db.collection.name to empty string while being omitted from the
  summary.
These are process artifacts from the implementation session, not project
documentation (which lives under doc/, singular). The task list they
describe is complete.
Comment thread bson/json_util.py
if hasattr(obj, "items"):
truncated: Any = {}
for k, v in obj.items():
truncated_v, remaining = _truncate_documents(v, remaining)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

These changes were needed for the new distinct.json, which has a falsey value for the field. These fields should not be dropped.

blink1073 added 10 commits July 28, 2026 19:50
Both had grown by accretion across two rounds of work, reading as a base
description followed by a list of later additions. Describe the feature as
it ships instead.
…try args

PYTHON-5963's test monkeypatches _CommandTelemetry.__init__ with a fixed
positional signature. PYTHON-5945 added tracing_options and
speculative_hello to that signature on this branch, so merging main in
broke the test: git saw no conflict because the two changes touch
different files. Forward the extra arguments instead of enumerating them.
db.namespace/db.collection.name are only "Required if available" per the
OTel spec, and no vendored fixture covers the failure-before-any-command
case they existed for, so remove the ~25-call-site eager threading of
dbname/collection through _retry_internal/_retryable_read/_retryable_write/
_retry_with_session (and delete AsyncChangeStream._target_namespace, which
only fed it). Instead, guarantee the always-Required db.operation.summary
by falling back to the bare operation name in start_operation_span when no
dbname is given. The lazy backfill in start_command_span still covers
every success path, and direct _OperationTelemetry construction (cursors,
killCursors, endSessions, unacknowledged bulk writes) is untouched.
Replace the hand-written "dbname.collname".split(".", 1) parsing
duplicated in command_cursor.py, cursor.py, mongo_client.py, and
aggregation.py with a single shared _split_namespace helper in
helpers_shared.py, keeping both the async and sync trees import-cycle
free without needing synchro generation for the helper itself.
Split with_transaction() into a thin wrapper that owns the shared
"transaction" span plus a new _with_transaction_retry_loop() helper
holding the retry loop at its original indentation, removing the ~51
lines of pure re-indentation diff noise the try/finally wrapping had
introduced. Also trims the span bookkeeping comments down to the
essential facts and drops the "Important #1" review-finding reference.

No behavior change: the retry loop's statements, control flow, and
this file's mirrored pymongo/synchronous/client_session.py (via
`just synchro`) are unchanged apart from the de-indent.
…ites

Adding dbname/collection arguments pushed several _retryable_read/write
calls over the line limit, so ruff exploded them one-per-line with a
magic trailing comma. Removing those arguments again left the trailing
comma behind, which keeps ruff from collapsing the calls back. Drop it
so these sites match their original single-line form.
Same magic-trailing-comma residue as the previous commit, at call sites
whose base form spread arguments across one continuation line rather
than fitting entirely on one line.
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