Skip to content

Instantly share code, notes, and snippets.

@blink1073
Last active July 22, 2026 00:28
Show Gist options
  • Select an option

  • Save blink1073/202dfcae1e0381a99ef525f5b963ce1d to your computer and use it in GitHub Desktop.

Select an option

Save blink1073/202dfcae1e0381a99ef525f5b963ce1d to your computer and use it in GitHub Desktop.
PYTHON-5947: Add OpenTelemetry operation and transaction support - plan

PYTHON-5947: OpenTelemetry Operation and Transaction Support

Context

PYTHON-5947 extends the command-span support added in PYTHON-5945 (PR #2946) with:

  1. Operation-level spans — one span per public-API call (find_one, insert_one, aggregate, etc.), with command spans nested as children (including one per retry attempt).
  2. Transaction pseudo-spans — a "transaction" span wrapping everything between start_transaction() and commit_transaction()/abort_transaction().
  3. Wiring the unified test format's observeTracingMessages/expectTracingMessages into test/unified_format.py, and vendoring the DRIVERS-719 spec's YAML/JSON test files.

The two prose tests from PYTHON-5945 (env var enable/disable; env-var-driven db.query.text) are only partially done: they pass today but only assert on command spans. Both are marked TODO(PYTHON-5947) to also verify the operation span once it exists, and to disambiguate command-span vs. operation-span lookups. The YAML/JSON spec tests still need to be added regardless.

Verified: no changes needed to PYTHON-5945's command spans

Tested directly that OTel's default tracer.start_span(...) (no explicit context=) already parents to whatever span is ambient/current:

with tracer.start_as_current_span("find mydb.coll") as op_span:
    cmd_span = _otel.start_command_span(...)   # unmodified PYTHON-5945 code

The command span's parent matched the operation span automatically. So pymongo/_otel.py's command-span functions and pymongo/_telemetry.py's _CommandTelemetry need no changes — PYTHON-5947 is purely additive: enter the operation/transaction span via start_as_current_span() (or an explicit context) before any command spans start underneath it, and nesting just works.

(start_span/end_span_success/end_span_failure were already renamed to start_command_span/ end_command_span_success/end_command_span_failure in PYTHON-5945 specifically to leave naming room for start_operation_span/start_transaction_span — don't redo it.)

Reusable building blocks (no changes needed)

  • TracingOptions (the tracing client option) — the same enabled flag gates operation/transaction spans too.
  • _ADMIN_DB check in _extract_collection_name — the same collection-omission rule applies to db.operation.name/db.collection.name.
  • The "{name} {dbname}.{collection}" summary format (_build_query_summary) — identical format for db.operation.summary; consider generalizing to a shared helper.
  • _is_sensitive_command — unaffected by the new operation-span layer.

New work

1. Operation spans

  • Hook point: AsyncMongoClient._retryable_read/_retryable_write_ClientConnectionRetryable.run(). This is the single retry-loop choke point for all CRUD operations, already carrying the operation name and a stable operation id. Enter the span once before the retry loop; end it when run() returns or the final exception is re-raised.
  • Gap to close: this path doesn't currently receive dbname/collection name, only the operation name/id — thread those through the same way, or set them lazily (the spec allows attributes to be added after span creation).
  • New _otel.py functions: start_operation_span(...) (via start_as_current_span, not start_span) and end_operation_span_success/end_operation_span_failure. Consider a sixth telemetry class in _telemetry.py, _OperationTelemetry, matching the file's existing pattern.
  • Client bulk write — investigated: AsyncMongoClient.bulk_write() calls _AsyncClientBulk.execute(), which for acknowledged writes goes through _retryable_write/_ClientConnectionRetryable.run() exactly once per bulk_write() call (not once per batch — the whole multi-batch loop lives inside that one retryable call). Unacknowledged (w=0) writes bypass _retryable_write entirely. So execute(), not run(), is the right hook point — it's the one place that covers both paths. db.namespace="admin", no single collection (matches spec). All server-side batches naturally nest under one operation span for free, since they all happen inside that one execute() call.

2. Transaction pseudo-spans

Highest-complexity piece of this ticket — investigated, design resolved:

  • Hook point: start_transaction/commit_transaction/abort_transaction on AsyncClientSession. There's already an internal _Transaction state object holding transaction state as plain instance attributes (state, pinned_address, conn_mgr, recovery_token) — the natural home for a new span field, set in start_transaction and cleared when the transaction ends.
  • Plumbing already exists: session is already threaded all the way down to _run_command (where command spans are created), so session._transaction.span is reachable with no new parameter passing.
  • Design decision — explicit per-session context lookup, not ambient attach/detach: considered OTel's context.attach()/detach() to push the transaction span onto the ambient/thread-local (or asyncio-contextvar) context. Rejected: that scope is "whatever runs next in this thread/task," not "operations using this specific session" — a second, unrelated session's operation running in the same coroutine/task after the first session's start_transaction() would incorrectly inherit its context. Explicit lookup off the specific session object has no such risk, and matches how pymongo already stores other cross-cutting session state (pinned_address, conn_mgr, recovery_token) as plain attributes rather than thread-locals. So: store the span on session._transaction.span, and pass context=trace.set_span_in_context(span) explicitly wherever a command/operation span is started under an active transaction.
  • Ending the span: commit_transaction()/abort_transaction() are the only two methods that ever end a transaction — end_session() and the session's context-manager exit both already funnel through abort_transaction()/commit_transaction(), so ending the span in just those two methods covers every exit path (including the "transaction never started" early-return in abort_transaction(), which needs its own cleanup).

3. Unified test format wiring (test/unified_format.py + mirror)

  • Translate observeTracingMessages: {enableCommandPayload} into the client's tracing={"enabled": True, "query_text_max_length": ...} kwarg.
    • Mapping resolved: checked the spec's find.yml directly — it asserts the full, untruncated command via exact match, so enableCommandPayload: true must map to an effectively-unlimited query_text_max_length (e.g. a large constant), not a modest default that would truncate and fail the assertion. false/omitted maps to unset (defers to the environment variable).
  • New check_tracing_messages method, modeled on the existing check_log_messages. Register one process-wide TracerProvider/InMemorySpanExporter — reuse the _shared_test_provider() helper already built in test_otel.py (extract to a shared test util now that there's a second consumer). Capture spans during run_operations, and reconstruct the parent/child tree from the flat exporter list (via each span's parent id) to match the spec's spans/nested structure — this part is genuinely new machinery.
    • Correlating spans to a client: no span attribute identifies which client emitted it (unlike logs' clientId). Works today only because every spec test file has at most one client with observeTracingMessages active at a time — a known assumption, not a blocker; revisit only if the spec adds a multi-observed-client test.
  • Add an expectTracingMessages branch to test execution, alongside the existing expectLogMessages/expectEvents handling.
  • Vendor the spec's test YAML/JSON via .evergreen/resync-specs.sh, and add a new spec-driven test file reusing the existing otel pytest marker/Evergreen test type from PYTHON-5945.
  • Once the spec files run, drop test_span_created_for_insert_and_find and test_query_text_included_when_configured in test_otel.py — already marked TODO(PYTHON-5947) since they duplicate coverage the spec files will provide.

Cleanup carried over from PYTHON-5945

  • Restore the MongoClient/AsyncMongoClient docstring section for tracing (removed on request), expanded to cover operation/transaction spans.
  • Add the changelog entry (also deferred).

File-by-file summary

File Change
pymongo/_otel.py Add operation/transaction span functions; consider generalizing the summary-format helper
pymongo/_telemetry.py Consider a new _OperationTelemetry class
pymongo/asynchronous/mongo_client.py (+ mirror) Hook the retry loop; thread dbname/collection through
pymongo/asynchronous/client_session.py (+ mirror) Hook transaction start/commit/abort; store span on _Transaction
pymongo/asynchronous/client_bulk.py (+ mirror) Hook execute(); db.namespace="admin", no collection
test/unified_format.py (+ mirror) observeTracingMessages/expectTracingMessages/check_tracing_messages
test/utils_shared.py Extract the shared TracerProvider test helper
.evergreen/resync-specs.sh Add an open-telemetry spec vendoring case
New vendored spec test data + test file Mirror the pattern used for other spec suites
test/test_otel.py (+ mirror) Drop the two now-redundant tests
mongo_client.py docstring, doc/changelog.rst Deferred documentation from PYTHON-5945
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment