Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
29edd6a
PYTHON-5947 Ignore .worktrees/ for local worktree isolation
blink1073 Jul 27, 2026
85b5635
PYTHON-5947 Add implementation plan for operation/transaction spans
blink1073 Jul 28, 2026
62a3746
PYTHON-5947 Add operation-span primitives to pymongo._otel
blink1073 Jul 28, 2026
70f2e80
PYTHON-5947 Revert accidental opentelemetry-sdk requirement addition
blink1073 Jul 28, 2026
404fa4d
PYTHON-5947 Add transaction pseudo-span primitives to pymongo._otel
blink1073 Jul 28, 2026
6ecf29c
PYTHON-5947 Add _OperationTelemetry class
blink1073 Jul 28, 2026
b67f77d
PYTHON-5947 Wire operation spans into the retryable read/write loop
blink1073 Jul 28, 2026
df42a32
PYTHON-5947 Pin sensitive-command operation-span gap in otel test
blink1073 Jul 28, 2026
815cb60
PYTHON-5947 Add transaction pseudo-spans
blink1073 Jul 28, 2026
51463ea
PYTHON-5947 Cover transaction early-return and retried-commit span paths
blink1073 Jul 28, 2026
165ea43
PYTHON-5947 Add operation span for unacknowledged client bulk writes
blink1073 Jul 28, 2026
3f0e7ca
PYTHON-5947 Update tracing docs for operation/transaction spans
blink1073 Jul 28, 2026
cbdc779
PYTHON-5947 Wire observeTracingMessages/expectTracingMessages into th…
blink1073 Jul 28, 2026
c8ae168
PYTHON-5947 Vendor OpenTelemetry unified spec tests and drop redundan…
blink1073 Jul 28, 2026
df50944
PYTHON-5947 Add regression test for _truncate_documents falsy-value bug
blink1073 Jul 28, 2026
b9a97bb
PYTHON-5947 Fix OTel operation-span naming/exception-attribute bugs f…
blink1073 Jul 28, 2026
6e5d940
PYTHON-5947 Add regression tests and refresh stale comments for OTel …
blink1073 Jul 28, 2026
488edd0
PYTHON-5947 Add replica-set coverage to the OTel Evergreen variant
blink1073 Jul 28, 2026
0de5fb9
PYTHON-5947 Remove internal SDD planning doc from git tracking
blink1073 Jul 28, 2026
5cffd77
PYTHON-5947 Add design spec for remaining OTel span coverage gaps
blink1073 Jul 28, 2026
3b8bf01
PYTHON-5947 Add implementation plan for OTel span coverage gaps
blink1073 Jul 28, 2026
a297446
PYTHON-5947 Add eager attributes and detached mode to operation spans
blink1073 Jul 28, 2026
04597f0
PYTHON-5947 Fix duplicate exception recording in use_operation_span
blink1073 Jul 28, 2026
f95d082
PYTHON-5947 Plumb dbname/collection and caller-owned spans through th…
blink1073 Jul 28, 2026
14b4e4c
PYTHON-5947 Thread dbname/collection to operation spans from all call…
blink1073 Jul 28, 2026
e28216e
PYTHON-5947 Fix change-stream namespace misclassification in _target_…
blink1073 Jul 28, 2026
7cbb6c6
PYTHON-5947 Nest find cursor getMores under one operation span
blink1073 Jul 28, 2026
bb2e8cf
PYTHON-5947 Nest command cursor getMores under their operation span
blink1073 Jul 28, 2026
bc09330
PYTHON-5947 Nest client bulk-write results cursor getMores under bulk…
blink1073 Jul 28, 2026
e8c1e2e
PYTHON-5947 Add operation spans for killCursors and endSessions
blink1073 Jul 28, 2026
610cc7e
PYTHON-5947 Reset OpenTelemetry context in the async periodic executor
blink1073 Jul 28, 2026
02340a4
PYTHON-5947 Add withTransaction span and fix retried-commit transacti…
blink1073 Jul 28, 2026
d321301
PYTHON-5947 Reuse one transaction span across with_transaction retries
blink1073 Jul 28, 2026
d91035d
PYTHON-5947 Guard against reentrant with_transaction span leak
blink1073 Jul 28, 2026
0b9f50c
PYTHON-5947 Document expanded OTel span coverage
blink1073 Jul 28, 2026
4350a1b
PYTHON-5947 Mention background-monitor span fix, polish changelog bullet
blink1073 Jul 28, 2026
a80e2a4
PYTHON-5947 Fix flaky otel span-capture cross-talk from stale monitors
blink1073 Jul 28, 2026
fa8ee79
PYTHON-5947 Route every managed test client through the monitor join
blink1073 Jul 28, 2026
2acf149
PYTHON-5947 Gate the monitor-join cleanup on tracing being enabled
blink1073 Jul 28, 2026
a0af1c2
PYTHON-5947 Fix with_transaction() clobbering a concurrent direct-API…
blink1073 Jul 28, 2026
6218352
PYTHON-5947 End single-batch command cursors' operation spans at cons…
blink1073 Jul 28, 2026
d4c973d
PYTHON-5947 Address final-review minor findings (assert, comment, cle…
blink1073 Jul 28, 2026
f40a62d
PYTHON-5947 Remove internal SDD planning docs from git tracking
blink1073 Jul 29, 2026
78723c5
PYTHON-5947 Tighten the OpenTelemetry changelog and docstring entries
blink1073 Jul 29, 2026
0e6aea2
PYTHON-5947 Clarify the OpenTelemetry span-nesting wording
blink1073 Jul 29, 2026
71e7f96
PYTHON-5947 Let test_operation_id_retry tolerate added _CommandTeleme…
blink1073 Jul 29, 2026
a4963f2
PYTHON-5947 Drop eager dbname/collection threading from the retry layer
blink1073 Jul 29, 2026
ca797ae
PYTHON-5947 Centralize namespace-parsing logic
blink1073 Jul 29, 2026
a7ec812
PYTHON-5947 Refresh a change-stream test comment after _target_namesp…
blink1073 Jul 29, 2026
6d3a17c
PYTHON-5947 Describe current behavior in the client bulk write span c…
blink1073 Jul 29, 2026
84679d3
PYTHON-5947 Extract with_transaction's retry loop and trim span comments
blink1073 Jul 29, 2026
1238aa4
PYTHON-5947 Restore original formatting at untouched retryable call s…
blink1073 Jul 29, 2026
6529f69
PYTHON-5947 Restore base formatting at three more untouched call sites
blink1073 Jul 29, 2026
b516237
PYTHON-5947 Declare cursor type on its own line
blink1073 Jul 29, 2026
5a0de65
PYTHON-5947 Let _ClientConnectionRetryable own its operation span
blink1073 Jul 29, 2026
05f2708
PYTHON-5947 Extract _retryable_read_cursor to dedupe cursor telemetry…
blink1073 Jul 29, 2026
1933c9d
PYTHON-5947 Leave _list_databases and _retryable_read_command untouched
blink1073 Jul 29, 2026
0b1b1d0
PYTHON-5947 Mirror _retryable_read's signature in _retryable_read_cursor
blink1073 Jul 29, 2026
49588fd
PYTHON-5947 Drop a trailing comma that forced a call to reformat
blink1073 Jul 29, 2026
b1ba1b0
PYTHON-5947 Fix CI-only OpenTelemetry test failures on pre-4.4/8.2 se…
blink1073 Jul 29, 2026
92d85b3
PYTHON-5947 Avoid em-dashes and 'via' in new comments and docs
blink1073 Jul 30, 2026
3afc2b4
PYTHON-5947 Rewrite ASCII double-dash prose in OTel comments and docs…
blink1073 Jul 30, 2026
bc9e680
PYTHON-5947 Reword three comments left awkward by the dash removal
blink1073 Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .evergreen/generated_configs/variants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ buildvariants:
- name: otel-rhel8
tasks:
- name: .test-non-standard .standalone-noauth-nossl
- name: .test-non-standard .replica_set-noauth-ssl
display_name: OTel RHEL8
run_on:
- rhel87-small
Expand Down
3 changes: 3 additions & 0 deletions .evergreen/resync-specs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ do
cpjson command-logging-and-monitoring/tests/logging command_logging
cpjson command-logging-and-monitoring/tests/monitoring command_monitoring
;;
open-telemetry|otel|open_telemetry)
cpjson open-telemetry/tests open_telemetry
;;
crud|CRUD)
cpjson crud/tests/ crud
;;
Expand Down
11 changes: 10 additions & 1 deletion .evergreen/scripts/generate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,16 @@ def create_otel_variants():
expansions = dict(TEST_NAME="otel", COVERAGE="1")
return [
create_variant(
[".test-non-standard .standalone-noauth-nossl"],
[
".test-non-standard .standalone-noauth-nossl",
# Transaction spans (test/open_telemetry/transaction/*.json,
# test_otel.py's @require_transactions tests) need a replica set
# (they're skipped entirely on a standalone topology), so also
# run against one, mirroring how other variants in this file
# (e.g. PyOpenSSL's ".replica_set-noauth-ssl") pair a
# standalone/standard selector with a replica-set one.
".test-non-standard .replica_set-noauth-ssl",
],
get_variant_name("OTel", host),
host=host,
tags=["pr"],
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ server.log

# uv lockfiles
uv.lock

# Local git worktrees
.worktrees/
10 changes: 4 additions & 6 deletions bson/json_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -1109,20 +1109,18 @@ def _truncate_documents(obj: Any, max_length: int) -> tuple[Any, int]:
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.

if truncated_v:
truncated[k] = truncated_v
if remaining <= 0:
break
truncated_v, remaining = _truncate_documents(v, remaining)
truncated[k] = truncated_v
return truncated, remaining
elif hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)):
truncated: Any = [] # type:ignore[no-redef]
for v in obj:
truncated_v, remaining = _truncate_documents(v, remaining)
if truncated_v:
truncated.append(truncated_v)
if remaining <= 0:
break
truncated_v, remaining = _truncate_documents(v, remaining)
truncated.append(truncated_v)
return truncated, remaining
else:
return _truncate(obj, remaining)
Expand Down
11 changes: 7 additions & 4 deletions doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ PyMongo 4.18 brings a number of changes including:
attempts, so consumers can correlate a retried operation's events. As a
result, ``operation_id`` is no longer equal to the per-attempt ``request_id``
for these operations.
- Added optional OpenTelemetry command-span support, conforming to the
- Added optional OpenTelemetry tracing support, conforming to the
`OpenTelemetry driver specification <https://github.com/mongodb/specifications/blob/master/source/open-telemetry/open-telemetry.md>`_.
Enable it with the ``tracing`` :class:`~pymongo.mongo_client.MongoClient`
option or the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment
variable. Install the ``opentelemetry-api`` package, or use the
Every public API call produces an operation span, which contains one span
per command sent to the server. Inside a transaction, those operation spans
nest under a ``transaction`` span. Enable it with the
``tracing`` :class:`~pymongo.mongo_client.MongoClient` option or the
``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable.
Install the ``opentelemetry-api`` package, or use the
``pymongo[opentelemetry]`` extra, to enable this feature.
- Fixed a potential out-of-bounds read in the C extension when decoding an
array of BSON documents. An embedded document whose declared length exceeds
Expand Down
245 changes: 240 additions & 5 deletions pymongo/_otel.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@

from __future__ import annotations

import contextlib
import os
from collections.abc import Mapping, MutableMapping
import traceback
from collections.abc import Iterator, Mapping, MutableMapping
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any, Optional, TypedDict

from bson import json_util
Expand All @@ -31,7 +34,7 @@
from pymongo.logger import _HELLO_COMMANDS, _JSON_OPTIONS, _SENSITIVE_COMMANDS

try:
from opentelemetry import trace
from opentelemetry import context, trace
from opentelemetry.trace import SpanKind, Status, StatusCode

_HAS_OPENTELEMETRY = True
Expand All @@ -45,6 +48,14 @@
_HAS_OPENTELEMETRY = False
_TRACER = None

# The operation name of whichever operation span is currently active (entered
# by start_operation_span), so start_command_span can backfill the operation
# span's name/namespace attributes from the first command executed inside it
# (dbname/collection aren't known until then; see start_operation_span).
_CURRENT_OPERATION_NAME: ContextVar[Optional[str]] = ContextVar(
"_CURRENT_OPERATION_NAME", default=None
)

if TYPE_CHECKING:
from opentelemetry.trace import Span, Tracer

Expand Down Expand Up @@ -205,10 +216,27 @@ def start_command_span(
"""
if not _is_tracing_enabled(tracing_options):
return None

collection = _extract_collection_name(command_name, dbname, cmd)
# Backfill the ambient operation span's name/namespace/summary from the
# first command built inside it, before the sensitive-command early return
# below: the operation span still needs its (Required, per the OTel spec)
# db.namespace/db.operation.summary attributes even when the command
# itself is sensitive and gets no command span of its own.
current_operation = _CURRENT_OPERATION_NAME.get()
if current_operation is not None:
current_span = trace.get_current_span()
if current_span.is_recording():
summary = _build_query_summary(current_operation, dbname, collection)
current_span.update_name(summary)
current_span.set_attribute("db.namespace", dbname)
current_span.set_attribute("db.operation.summary", summary)
if collection:
current_span.set_attribute("db.collection.name", collection)

if _is_sensitive_command(command_name, speculative_hello):
return None

collection = _extract_collection_name(command_name, dbname, cmd)
address = conn.address
transport = "unix" if address[1] is None else "tcp"
attributes: dict[str, Any] = {
Expand Down Expand Up @@ -243,15 +271,40 @@ def start_command_span(


def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None:
"""Set the cursor id (if any) and end the span."""
"""Set the cursor id (if any open cursor) and end the span."""
if span is None:
return
cursor = reply.get("cursor")
if isinstance(cursor, Mapping) and "id" in cursor:
if isinstance(cursor, Mapping) and cursor.get("id"):
# A cursor id of 0 means the cursor is already exhausted, i.e. there is
# no cursor left to track, so per the OTel spec ("If the command
# returns a cursor, or uses a cursor, the cursor_id attribute SHOULD
# be added") the attribute is only meaningful, and only added, when
# id is nonzero.
span.set_attribute("db.mongodb.cursor_id", cursor["id"])
span.end()


def _set_exception_attributes(span: Span, exc: BaseException) -> None:
"""Set exception.type/exception.message/exception.stacktrace span attributes.

``span.record_exception`` only attaches these to an "exception" *event*,
but the OTel spec requires them as span *attributes* too ("drivers SHOULD
add the following attributes to the span"); mirror record_exception's own
formatting for consistency. Shared by the command-span and operation-span
failure paths, since the spec states the same requirement for both.
"""
module = type(exc).__module__
qualname = type(exc).__qualname__
exception_type = f"{module}.{qualname}" if module and module != "builtins" else qualname
span.set_attribute("exception.type", exception_type)
span.set_attribute("exception.message", str(exc))
span.set_attribute(
"exception.stacktrace",
"".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
)


def end_command_span_failure(
span: Optional[Span],
failure: _DocumentOut,
Expand All @@ -261,8 +314,190 @@ def end_command_span_failure(
if span is None:
return
span.record_exception(exc)
_set_exception_attributes(span, exc)
code = failure.get("code")
if code is not None:
span.set_attribute("db.response.status_code", str(code))
span.set_status(Status(StatusCode.ERROR, description=failure.get("errmsg")))
span.end()


class _OperationSpanHandle:
"""Bundles an operation span with what's needed to end it later.

``_cm`` is the ``start_as_current_span`` context manager when the span was
made current at creation, or None in detached mode (see
``start_operation_span``'s ``set_current``), where the span is made current
per-use by ``use_operation_span`` instead.
"""

__slots__ = ("_cm", "_name_token", "operation_name", "span")

def __init__(
self,
span: Span,
cm: Any,
name_token: Any,
operation_name: str,
) -> None:
self.span = span
self._cm = cm
self._name_token = name_token
self.operation_name = operation_name


def start_operation_span(
tracing_options: Optional[TracingOptions],
operation: str,
parent_span: Optional[Span],
dbname: Optional[str] = None,
collection: Optional[str] = None,
set_current: bool = True,
) -> Optional[_OperationSpanHandle]:
"""Start a CLIENT-kind span for one logical operation, or None.

Spans all retry attempts of one call to _retry_internal. The (Required,
per the OTel spec) ``db.operation.summary`` is always set immediately,
to the bare operation name unless ``dbname`` is given, in which case it
(and, when ``collection`` is also given, the "if available"
``db.namespace``/``db.collection.name``) are built from those instead.
This guarantees a conformant span even for an operation that fails before
any command is ever built, e.g. server selection timing out.
``start_command_span`` still backfills these from the real command once
one is built, overwriting these values with the authoritative ones.

``parent_span`` (the active transaction span, if any) becomes this span's
*explicit* parent; it is deliberately not read from ambient context, to
avoid a concurrently-running unrelated session's operations picking up
this transaction by accident. Pass None outside of a transaction.

With ``set_current=False`` the span is created but not made current, and
the operation-name contextvar is left alone. That suits a span whose
lifetime covers several ``_retry_internal`` calls (cursor getMores), where
the caller makes it current per call with ``use_operation_span``.
"""
if not _is_tracing_enabled(tracing_options):
return None
assert _TRACER is not None # _is_tracing_enabled already checked _HAS_OPENTELEMETRY
context = trace.set_span_in_context(parent_span) if parent_span is not None else None
attributes: dict[str, Any] = {
"db.system.name": "mongodb",
"db.operation.name": operation,
}
name = operation
if dbname is not None:
name = _build_query_summary(operation, dbname, collection)
attributes["db.namespace"] = dbname
if collection:
attributes["db.collection.name"] = collection
attributes["db.operation.summary"] = name
if not set_current:
span = _TRACER.start_span(
name, kind=SpanKind.CLIENT, context=context, attributes=attributes
)
return _OperationSpanHandle(span, None, None, operation)
cm = _TRACER.start_as_current_span(
name,
kind=SpanKind.CLIENT,
context=context,
attributes=attributes,
)
span = cm.__enter__()
name_token = _CURRENT_OPERATION_NAME.set(operation)
return _OperationSpanHandle(span, cm, name_token, operation)


@contextlib.contextmanager
def use_operation_span(handle: Optional[_OperationSpanHandle]) -> Iterator[None]:
"""Make a detached operation span current for the duration of the block.

Does not end the span; the owner (e.g. a cursor, across all of its
getMore calls) ends it explicitly. A no-op when ``handle`` is None.
"""
if handle is None:
yield
return
token = _CURRENT_OPERATION_NAME.set(handle.operation_name)
try:
# record_exception/set_status_on_exception default to True, which would
# auto-record any exception propagating out of the block and set ERROR
# status here, duplicating what the caller's own
# end_operation_span_failure does explicitly once the operation's
# final outcome is known. Disabled for the same reason the
# attached-mode path passes hardcoded Nones to cm.__exit__.
with trace.use_span(
handle.span,
end_on_exit=False,
record_exception=False,
set_status_on_exception=False,
):
yield
finally:
_CURRENT_OPERATION_NAME.reset(token)


def reset_context() -> None:
"""Clear the OTel ambient span and operation-name contextvar.

For long-lived background tasks whose context was copied from whatever
happened to be running when they were created (``asyncio.create_task``
freezes the caller's ``contextvars.Context``). Without this, spans the task
emits are parented under an unrelated, long-since-ended operation and share
its trace id. Attaching an empty context makes ``get_current_span()`` return
the non-recording invalid span, so spans started afterwards become trace
roots. Deliberately does not detach: the task's context is wrong for its
whole life, and it dies with the task.
"""
if not _HAS_OPENTELEMETRY:
return
_CURRENT_OPERATION_NAME.set(None)
context.attach(context.Context())


def end_operation_span_success(handle: Optional[_OperationSpanHandle]) -> None:
"""End the operation span with no error status."""
if handle is None:
return
if handle._cm is None:
handle.span.end()
return
_CURRENT_OPERATION_NAME.reset(handle._name_token)
handle._cm.__exit__(None, None, None)


def end_operation_span_failure(handle: Optional[_OperationSpanHandle], exc: BaseException) -> None:
"""Record the exception, set the error status, and end the operation span."""
if handle is None:
return
handle.span.record_exception(exc)
_set_exception_attributes(handle.span, exc)
handle.span.set_status(Status(StatusCode.ERROR, description=str(exc)))
if handle._cm is None:
handle.span.end()
return
_CURRENT_OPERATION_NAME.reset(handle._name_token)
handle._cm.__exit__(None, None, None)


def start_transaction_span(tracing_options: Optional[TracingOptions]) -> Optional[Span]:
"""Start (but do not make current) the ``"transaction"`` pseudo-span, or None.

Not pushed as ambient/current context; it's stored explicitly on
``session._transaction.span`` and passed as the explicit ``parent_span``
wherever an operation span is started under this transaction (see
:func:`start_operation_span`). Per the OTel driver spec, this span has
exactly one attribute.
"""
if not _is_tracing_enabled(tracing_options):
return None
assert _TRACER is not None
return _TRACER.start_span(
"transaction", kind=SpanKind.CLIENT, attributes={"db.system.name": "mongodb"}
)


def end_transaction_span(span: Optional[Span]) -> None:
"""End the transaction span, if any."""
if span is None:
return
span.end()
Loading
Loading