From eeea42a5367ba3dd476da5eb1830983977e2138a Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 20 Jul 2026 12:53:47 -0500 Subject: [PATCH 01/23] PYTHON-5945 Add OpenTelemetry command-span support Adds optional, opt-in OpenTelemetry tracing for server commands, conforming to the DRIVERS-719 client-side OpenTelemetry specification, by hooking into the existing consolidated command telemetry. --- doc/changelog.rst | 6 + pymongo/_otel.py | 211 +++++++++++++++++++++++++ pymongo/_telemetry.py | 33 +++- pymongo/asynchronous/command_runner.py | 15 +- pymongo/asynchronous/mongo_client.py | 17 ++ pymongo/client_options.py | 13 ++ pymongo/common.py | 17 ++ pymongo/synchronous/command_runner.py | 15 +- pymongo/synchronous/mongo_client.py | 17 ++ pyproject.toml | 1 + requirements/opentelemetry.txt | 1 + requirements/test.txt | 1 + test/asynchronous/test_otel.py | 167 +++++++++++++++++++ test/test_otel.py | 165 +++++++++++++++++++ 14 files changed, 671 insertions(+), 8 deletions(-) create mode 100644 pymongo/_otel.py create mode 100644 requirements/opentelemetry.txt create mode 100644 test/asynchronous/test_otel.py create mode 100644 test/test_otel.py diff --git a/doc/changelog.rst b/doc/changelog.rst index aa4feb0af9..de33220b31 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -17,6 +17,12 @@ Changes in Version 4.18.0 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 + `OpenTelemetry driver specification `_. + 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. Changes in Version 4.17.0 (2026/04/20) -------------------------------------- diff --git a/pymongo/_otel.py b/pymongo/_otel.py new file mode 100644 index 0000000000..ad638c3cf6 --- /dev/null +++ b/pymongo/_otel.py @@ -0,0 +1,211 @@ +# Copyright 2026-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Optional OpenTelemetry command-span support (DRIVERS-719). + +Kept separate from :mod:`pymongo._telemetry` so that module stays free of +``opentelemetry`` import guards. Every function here is a no-op when +``opentelemetry`` isn't installed or tracing isn't enabled. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping, MutableMapping +from typing import TYPE_CHECKING, Any, Optional, TypedDict + +from bson import json_util +from pymongo._version import __version__ +from pymongo.logger import _HELLO_COMMANDS, _SENSITIVE_COMMANDS + +try: + from opentelemetry import trace + from opentelemetry.trace import SpanKind, Status, StatusCode + + _HAS_OPENTELEMETRY = True +except ImportError: + _HAS_OPENTELEMETRY = False + +if TYPE_CHECKING: + from opentelemetry.trace import Span, Tracer + + from pymongo.pool_shared import _ConnectionTelemetryInfo + from pymongo.typings import _DocumentOut + + +class TracingOptions(TypedDict): + """The shape of the ``MongoClient`` ``tracing`` option.""" + + enabled: bool + query_text_max_length: int + + +_OTEL_ENABLED_ENV = "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED" +_OTEL_QUERY_TEXT_MAX_LENGTH_ENV = "OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH" +_TRUTHY = frozenset({"1", "true", "yes"}) + +# Fields redacted from the db.query.text attribute, mirroring the fields excluded +# from the equivalent CommandStartedEvent.command per the OpenTelemetry spec. +_QUERY_TEXT_EXCLUDED_FIELDS = frozenset({"lsid", "$db", "$clusterTime", "signature"}) + +# getMore's own command value is the cursor id, not the collection name; the +# collection lives under a separate "collection" key instead. +# See _gen_get_more_command in pymongo/message.py. +_GET_MORE = "getMore" + + +def _env_truthy(name: str) -> bool: + return os.getenv(name, "").strip().lower() in _TRUTHY + + +def _is_tracing_enabled(tracing_options: Optional[TracingOptions]) -> bool: + """Return True if OTel command spans should be created for this client. + + The ``MongoClient`` ``tracing.enabled`` option and the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable both + gate enablement; either one being truthy is sufficient. + """ + if not _HAS_OPENTELEMETRY: + return False + if tracing_options and tracing_options.get("enabled"): + return True + return _env_truthy(_OTEL_ENABLED_ENV) + + +def _get_tracer() -> Tracer: + return trace.get_tracer("PyMongo", __version__) + + +def _get_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int: + """Return the configured db.query.text truncation length, or 0 to omit the attribute.""" + client_value = tracing_options.get("query_text_max_length", 0) if tracing_options else 0 + if client_value > 0: + return client_value + try: + return max(0, int(os.getenv(_OTEL_QUERY_TEXT_MAX_LENGTH_ENV, "0"))) + except ValueError: + return 0 + + +def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: + filtered = {k: v for k, v in cmd.items() if k not in _QUERY_TEXT_EXCLUDED_FIELDS} + text = json_util.dumps(filtered) + return text[:max_length] + + +def _extract_collection_name(command_name: str, cmd: Mapping[str, Any]) -> Optional[str]: + """Return the collection name targeted by ``cmd``, or None if it doesn't target one.""" + key = "collection" if command_name == _GET_MORE else command_name + value = cmd.get(key) + return value if isinstance(value, str) else None + + +def _build_query_summary(command_name: str, dbname: str, collection: Optional[str]) -> str: + if collection: + return f"{command_name} {dbname}.{collection}" + return f"{command_name} {dbname}" + + +def _is_sensitive_command(command_name: str, speculative_hello: bool) -> bool: + """Mirror the redaction rules in ``pymongo.logger.LogMessage._is_sensitive``.""" + if command_name in _SENSITIVE_COMMANDS: + return True + return command_name in _HELLO_COMMANDS and speculative_hello + + +def _format_lsid(lsid: Mapping[str, Any]) -> Optional[str]: + id_value = lsid.get("id") + if id_value is None: + return None + try: + return str(id_value.as_uuid()) + except (AttributeError, ValueError): + return str(id_value) + + +def start_span( + tracing_options: Optional[TracingOptions], + conn: _ConnectionTelemetryInfo, + cmd: MutableMapping[str, Any], + dbname: str, + command_name: str, + speculative_hello: bool, +) -> Optional[Span]: + """Start and return a CLIENT-kind span for a server command, or None. + + Returns None when tracing is disabled/unavailable or the command is + sensitive (mirroring the redaction applied to logs and APM events). + """ + if not _is_tracing_enabled(tracing_options): + return None + if _is_sensitive_command(command_name, speculative_hello): + return None + + collection = _extract_collection_name(command_name, cmd) + address = conn.address + attributes: dict[str, Any] = { + "db.system.name": "mongodb", + "db.namespace": dbname, + "db.command.name": command_name, + "db.query.summary": _build_query_summary(command_name, dbname, collection), + "server.address": address[0], + "server.port": address[1], + "network.transport": "unix" if address[0].endswith(".sock") else "tcp", + "db.mongodb.driver_connection_id": conn.id, + } + if collection: + attributes["db.collection.name"] = collection + if conn.server_connection_id is not None: + attributes["db.mongodb.server_connection_id"] = conn.server_connection_id + lsid = cmd.get("lsid") + if lsid: + formatted_lsid = _format_lsid(lsid) + if formatted_lsid is not None: + attributes["db.mongodb.lsid"] = formatted_lsid + txn_number = cmd.get("txnNumber") + if txn_number is not None: + attributes["db.mongodb.txn_number"] = txn_number + max_query_text_length = _get_query_text_max_length(tracing_options) + if max_query_text_length > 0: + attributes["db.query.text"] = _build_query_text(cmd, max_query_text_length) + + tracer = _get_tracer() + return tracer.start_span(command_name, kind=SpanKind.CLIENT, attributes=attributes) + + +def end_span_success(span: Optional[Span], reply: _DocumentOut) -> None: + """Set the cursor id (if any) and end the span.""" + if span is None: + return + cursor = reply.get("cursor") + if isinstance(cursor, Mapping) and "id" in cursor: + span.set_attribute("db.mongodb.cursor_id", cursor["id"]) + span.end() + + +def end_span_failure( + span: Optional[Span], + failure: _DocumentOut, + exc: Optional[BaseException], +) -> None: + """Record the exception, set the error status, and end the span.""" + if span is None: + return + if exc is not None: + span.record_exception(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() diff --git a/pymongo/_telemetry.py b/pymongo/_telemetry.py index f972a402df..e9064d381b 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -23,6 +23,7 @@ from collections.abc import MutableMapping from typing import TYPE_CHECKING, Any, Optional +from pymongo import _otel from pymongo.logger import ( _COMMAND_LOGGER, _CONNECTION_LOGGER, @@ -75,8 +76,12 @@ class _CommandTelemetry: "_publish", "_request_id", "_should_log", + "_span", + "_speculative_hello", "_start", "_topology_id", + "_tracing_enabled", + "_tracing_options", ) def __init__( @@ -88,11 +93,15 @@ def __init__( dbname: str, request_id: int, op_id: Optional[int], + tracing_options: Optional[_otel.TracingOptions] = None, + speculative_hello: bool = False, ) -> None: self._topology_id = topology_id self._should_log = topology_id is not None and _COMMAND_LOGGER.isEnabledFor(logging.DEBUG) self._publish = listeners is not None and listeners.enabled_for_commands - self._active = self._should_log or self._publish + self._tracing_options = tracing_options + self._tracing_enabled = _otel._is_tracing_enabled(tracing_options) + self._active = self._should_log or self._publish or self._tracing_enabled self._listeners = listeners self._conn = conn self._cmd = cmd @@ -100,6 +109,8 @@ def __init__( self._dbname = dbname self._request_id = request_id self._op_id = op_id + self._speculative_hello = speculative_hello + self._span: Optional[Any] = None self._start: datetime.datetime self._duration: datetime.timedelta @@ -121,7 +132,7 @@ def _emit_log(self, message: _CommandStatusMessage, **extra: Any) -> None: ) def started(self, orig: MutableMapping[str, Any], ensure_db: bool) -> None: - """Emit the STARTED log entry and APM event, and start the duration clock.""" + """Emit the STARTED log entry and APM event, start the span, and start the duration clock.""" self._start = datetime.datetime.now() if not self._active: return @@ -140,6 +151,15 @@ def started(self, orig: MutableMapping[str, Any], ensure_db: bool) -> None: self._op_id, service_id=self._conn.service_id, ) + if self._tracing_enabled: + self._span = _otel.start_span( + self._tracing_options, + self._conn, + self._cmd, + self._dbname, + self._name, + self._speculative_hello, + ) @property def duration(self) -> datetime.timedelta: @@ -152,7 +172,7 @@ def succeeded( command_name: str, speculative_hello: bool, ) -> None: - """Emit the SUCCEEDED log entry and APM event.""" + """Emit the SUCCEEDED log entry and APM event, and end the span.""" self._duration = datetime.datetime.now() - self._start if not self._active: return @@ -177,14 +197,17 @@ def succeeded( speculative_hello=speculative_hello, database_name=self._dbname, ) + if self._span is not None: + _otel.end_span_success(self._span, reply) def failed( self, failure: _DocumentOut, command_name: str, is_server_side_error: bool, + exc: Optional[BaseException] = None, ) -> None: - """Emit the FAILED log entry and APM event.""" + """Emit the FAILED log entry and APM event, and end the span.""" self._duration = datetime.datetime.now() - self._start if not self._active: return @@ -208,6 +231,8 @@ def failed( service_id=self._conn.service_id, database_name=self._dbname, ) + if self._span is not None: + _otel.end_span_failure(self._span, failure, exc) class _CmapTelemetry: diff --git a/pymongo/asynchronous/command_runner.py b/pymongo/asynchronous/command_runner.py index 683dc0d8e8..6d6e7ad1c1 100644 --- a/pymongo/asynchronous/command_runner.py +++ b/pymongo/asynchronous/command_runner.py @@ -162,7 +162,18 @@ async def _run_command( if op_id is None: op_id = _op_id.OP_ID.get() - telemetry = _CommandTelemetry(topology_id, conn, listeners, cmd, dbname, request_id, op_id) + tracing_options = client.options.tracing if client is not None else None + telemetry = _CommandTelemetry( + topology_id, + conn, + listeners, + cmd, + dbname, + request_id, + op_id, + tracing_options, + speculative_hello, + ) telemetry.started(orig, ensure_db) reply: Optional[_OpMsg] = None @@ -211,7 +222,7 @@ async def _run_command( failure: _DocumentOut = exc.details # type: ignore[assignment] else: failure = _convert_exception(exc) - telemetry.failed(failure, command_name, isinstance(exc, OperationFailure)) + telemetry.failed(failure, command_name, isinstance(exc, OperationFailure), exc) raise telemetry.succeeded(docs[0], command_name, speculative_hello) diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index cd1cefea13..6cacd9b5f6 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -615,8 +615,25 @@ def __init__( If enabled, server overload errors will cause retry attempts to select a server that has not yet returned an overload error, if possible. Defaults to ``False``. + | **OpenTelemetry options:** + | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) + + - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: + + - ``enabled``: (boolean) Whether to create spans for server commands issued by + this client. Defaults to ``False``. Also controlled by the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either + being enabled is sufficient. + - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` + span attribute. Defaults to ``0``, which omits the attribute. Also controlled + by the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` + environment variable. + .. seealso:: The MongoDB documentation on `connections `_. + .. versionchanged:: 4.18 + Added the ``tracing`` keyword argument. + .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. diff --git a/pymongo/client_options.py b/pymongo/client_options.py index 99a2bcb4e4..efc2197b46 100644 --- a/pymongo/client_options.py +++ b/pymongo/client_options.py @@ -40,6 +40,7 @@ if TYPE_CHECKING: from bson.codec_options import CodecOptions + from pymongo import _otel from pymongo.auth_shared import MongoCredential from pymongo.encryption_options import AutoEncryptionOpts from pymongo.pyopenssl_context import SSLContext @@ -247,6 +248,10 @@ def __init__( if "enable_overload_retargeting" in options else options.get("enableoverloadretargeting", common.ENABLE_OVERLOAD_RETARGETING) ) + self.__tracing = cast( + "_otel.TracingOptions", + options.get("tracing") or {"enabled": False, "query_text_max_length": 0}, + ) @property def _options(self) -> Mapping[str, Any]: @@ -374,3 +379,11 @@ def enable_overload_retargeting(self) -> bool: .. versionadded:: 4.17 """ return self.__enable_overload_retargeting + + @property + def tracing(self) -> _otel.TracingOptions: + """The configured ``tracing`` option for OpenTelemetry command spans. + + .. versionadded:: 4.18 + """ + return self.__tracing diff --git a/pymongo/common.py b/pymongo/common.py index 57606a6317..9079ea27cf 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -51,6 +51,7 @@ from pymongo.write_concern import DEFAULT_WRITE_CONCERN, WriteConcern, validate_boolean if TYPE_CHECKING: + from pymongo import _otel from pymongo.typings import _AgnosticClientSession @@ -607,6 +608,21 @@ def validate_server_api_or_none(option: Any, value: Any) -> Optional[ServerApi]: return value +def validate_tracing_or_none(option: str, value: Any) -> Optional[_otel.TracingOptions]: + """Validate the tracing keyword arg.""" + if value is None: + return value + validate_is_mapping(option, value) + unknown = set(value) - {"enabled", "query_text_max_length"} + if unknown: + raise ConfigurationError(f"Unknown tracing option(s): {sorted(unknown)}") + enabled = value.get("enabled", False) + validate_boolean("tracing.enabled", enabled) + query_text_max_length = value.get("query_text_max_length", 0) + validate_non_negative_integer("tracing.query_text_max_length", query_text_max_length) + return {"enabled": enabled, "query_text_max_length": query_text_max_length} + + def validate_is_callable_or_none(option: Any, value: Any) -> Optional[Callable[..., Any]]: """Validates that 'value' is a callable.""" if value is None: @@ -784,6 +800,7 @@ def validate_server_monitoring_mode(option: str, value: str) -> str: "authoidcallowedhosts": validate_list, "max_adaptive_retries": validate_non_negative_integer, "enable_overload_retargeting": validate_boolean_or_string, + "tracing": validate_tracing_or_none, } # Dictionary where keys are any URI option name, and values are the diff --git a/pymongo/synchronous/command_runner.py b/pymongo/synchronous/command_runner.py index e81f514003..5c63bee2ab 100644 --- a/pymongo/synchronous/command_runner.py +++ b/pymongo/synchronous/command_runner.py @@ -162,7 +162,18 @@ def _run_command( if op_id is None: op_id = _op_id.OP_ID.get() - telemetry = _CommandTelemetry(topology_id, conn, listeners, cmd, dbname, request_id, op_id) + tracing_options = client.options.tracing if client is not None else None + telemetry = _CommandTelemetry( + topology_id, + conn, + listeners, + cmd, + dbname, + request_id, + op_id, + tracing_options, + speculative_hello, + ) telemetry.started(orig, ensure_db) reply: Optional[_OpMsg] = None @@ -211,7 +222,7 @@ def _run_command( failure: _DocumentOut = exc.details # type: ignore[assignment] else: failure = _convert_exception(exc) - telemetry.failed(failure, command_name, isinstance(exc, OperationFailure)) + telemetry.failed(failure, command_name, isinstance(exc, OperationFailure), exc) raise telemetry.succeeded(docs[0], command_name, speculative_hello) diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index d3f6a1e217..8a637a885c 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -616,8 +616,25 @@ def __init__( If enabled, server overload errors will cause retry attempts to select a server that has not yet returned an overload error, if possible. Defaults to ``False``. + | **OpenTelemetry options:** + | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) + + - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: + + - ``enabled``: (boolean) Whether to create spans for server commands issued by + this client. Defaults to ``False``. Also controlled by the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either + being enabled is sufficient. + - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` + span attribute. Defaults to ``0``, which omits the attribute. Also controlled + by the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` + environment variable. + .. seealso:: The MongoDB documentation on `connections `_. + .. versionchanged:: 4.18 + Added the ``tracing`` keyword argument. + .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. diff --git a/pyproject.toml b/pyproject.toml index 05fd0658d8..4adbed0de5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ docs = ["requirements/docs.txt"] encryption = ["requirements/encryption.txt"] gssapi = ["requirements/gssapi.txt"] ocsp = ["requirements/ocsp.txt"] +opentelemetry = ["requirements/opentelemetry.txt"] snappy = ["requirements/snappy.txt"] test = ["requirements/test.txt"] zstd = ["requirements/zstd.txt"] diff --git a/requirements/opentelemetry.txt b/requirements/opentelemetry.txt new file mode 100644 index 0000000000..d20388d07f --- /dev/null +++ b/requirements/opentelemetry.txt @@ -0,0 +1 @@ +opentelemetry-api>=1.20.0 diff --git a/requirements/test.txt b/requirements/test.txt index 566cade7ec..f7ce1bd6eb 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1,4 @@ pytest>=8.2 pytest-asyncio>=0.24.0 importlib_metadata>=7.0;python_version < "3.13" +opentelemetry-sdk>=1.20.0 diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py new file mode 100644 index 0000000000..e710e190ec --- /dev/null +++ b/test/asynchronous/test_otel.py @@ -0,0 +1,167 @@ +# Copyright 2026-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test OpenTelemetry command-span support.""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +sys.path[0:0] = [""] + +import pymongo._otel as _otel +from pymongo.errors import OperationFailure +from test.asynchronous import AsyncIntegrationTest, unittest + +if _otel._HAS_OPENTELEMETRY: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +_IS_SYNC = False + + +def _shared_test_provider() -> TracerProvider: + """Return a process-wide SDK TracerProvider for tests to attach exporters to. + + ``trace.set_tracer_provider`` only takes effect once per process (later calls + are silently ignored), so tests must share one provider and each register + their own span processor rather than trying to install a fresh provider. + """ + current = trace.get_tracer_provider() + if isinstance(current, TracerProvider): + return current + provider = TracerProvider() + trace.set_tracer_provider(provider) + return provider + + +@unittest.skipUnless(_otel._HAS_OPENTELEMETRY, "opentelemetry is not installed") +class TestOTelSpans(AsyncIntegrationTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + async def asyncSetUp(self): + await super().asyncSetUp() + self.exporter.clear() + + def spans(self, name: str | None = None): + finished = self.exporter.get_finished_spans() + if name is None: + return list(finished) + return [s for s in finished if s.name == name] + + async def test_span_created_for_insert_and_find(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test_otel + await coll.drop() + self.exporter.clear() + await coll.insert_one({"x": 1}) + + insert_spans = self.spans("insert") + self.assertEqual(len(insert_spans), 1) + attrs = insert_spans[0].attributes + self.assertEqual(attrs["db.system.name"], "mongodb") + self.assertEqual(attrs["db.namespace"], self.db.name) + self.assertEqual(attrs["db.collection.name"], "test_otel") + self.assertEqual(attrs["db.command.name"], "insert") + self.assertEqual(attrs["db.query.summary"], f"insert {self.db.name}.test_otel") + self.assertIn("server.address", attrs) + self.assertIn("server.port", attrs) + self.assertIn(attrs["network.transport"], ("tcp", "unix")) + self.assertIn("db.mongodb.driver_connection_id", attrs) + self.assertNotIn("db.query.text", attrs) + + self.exporter.clear() + docs = await coll.find({}).to_list() + self.assertEqual(len(docs), 1) + find_spans = self.spans("find") + self.assertEqual(len(find_spans), 1) + self.assertEqual(find_spans[0].attributes["db.command.name"], "find") + + async def test_span_created_for_get_more(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test_otel_getmore + await coll.drop() + await coll.insert_many([{"x": i} for i in range(5)]) + self.exporter.clear() + + docs = await coll.find({}, batch_size=2).to_list() + self.assertEqual(len(docs), 5) + + get_more_spans = self.spans("getMore") + self.assertGreater(len(get_more_spans), 0) + for span in get_more_spans: + self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore") + self.assertEqual(span.attributes["db.command.name"], "getMore") + + async def test_sensitive_command_produces_no_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with self.assertRaises(OperationFailure): + await client.admin.command("saslStart", mechanism="SCRAM-SHA-256", payload=b"") + + names = [s.name for s in self.spans()] + self.assertNotIn("saslStart", names) + + async def test_failure_records_exception_and_status_code(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with self.assertRaises(OperationFailure): + await client[self.db.name].command("thisCommandDoesNotExist") + + spans = self.spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) + self.assertIn("db.response.status_code", span.attributes) + self.assertTrue(any(event.name == "exception" for event in span.events)) + + async def test_tracing_disabled_by_default(self): + client = await self.async_rs_or_single_client() + self.exporter.clear() + await client.admin.command("ping") + self.assertEqual(self.spans(), []) + + async def test_env_var_enables_tracing(self): + client = await self.async_rs_or_single_client() + self.exporter.clear() + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + await client.admin.command("ping") + + self.assertIn("ping", [s.name for s in self.spans()]) + + async def test_query_text_included_when_configured(self): + client = await self.async_rs_or_single_client( + tracing={"enabled": True, "query_text_max_length": 1000} + ) + coll = client[self.db.name].test_otel + await coll.drop() + self.exporter.clear() + await coll.insert_one({"x": 1}) + + spans = self.spans("insert") + self.assertEqual(len(spans), 1) + self.assertIn("db.query.text", spans[0].attributes) + self.assertNotIn("lsid", spans[0].attributes["db.query.text"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_otel.py b/test/test_otel.py new file mode 100644 index 0000000000..bc4e10356f --- /dev/null +++ b/test/test_otel.py @@ -0,0 +1,165 @@ +# Copyright 2026-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test OpenTelemetry command-span support.""" + +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +sys.path[0:0] = [""] + +import pymongo._otel as _otel +from pymongo.errors import OperationFailure +from test import IntegrationTest, unittest + +if _otel._HAS_OPENTELEMETRY: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +_IS_SYNC = True + + +def _shared_test_provider() -> TracerProvider: + """Return a process-wide SDK TracerProvider for tests to attach exporters to. + + ``trace.set_tracer_provider`` only takes effect once per process (later calls + are silently ignored), so tests must share one provider and each register + their own span processor rather than trying to install a fresh provider. + """ + current = trace.get_tracer_provider() + if isinstance(current, TracerProvider): + return current + provider = TracerProvider() + trace.set_tracer_provider(provider) + return provider + + +@unittest.skipUnless(_otel._HAS_OPENTELEMETRY, "opentelemetry is not installed") +class TestOTelSpans(IntegrationTest): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + def setUp(self): + super().setUp() + self.exporter.clear() + + def spans(self, name: str | None = None): + finished = self.exporter.get_finished_spans() + if name is None: + return list(finished) + return [s for s in finished if s.name == name] + + def test_span_created_for_insert_and_find(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test_otel + coll.drop() + self.exporter.clear() + coll.insert_one({"x": 1}) + + insert_spans = self.spans("insert") + self.assertEqual(len(insert_spans), 1) + attrs = insert_spans[0].attributes + self.assertEqual(attrs["db.system.name"], "mongodb") + self.assertEqual(attrs["db.namespace"], self.db.name) + self.assertEqual(attrs["db.collection.name"], "test_otel") + self.assertEqual(attrs["db.command.name"], "insert") + self.assertEqual(attrs["db.query.summary"], f"insert {self.db.name}.test_otel") + self.assertIn("server.address", attrs) + self.assertIn("server.port", attrs) + self.assertIn(attrs["network.transport"], ("tcp", "unix")) + self.assertIn("db.mongodb.driver_connection_id", attrs) + self.assertNotIn("db.query.text", attrs) + + self.exporter.clear() + docs = coll.find({}).to_list() + self.assertEqual(len(docs), 1) + find_spans = self.spans("find") + self.assertEqual(len(find_spans), 1) + self.assertEqual(find_spans[0].attributes["db.command.name"], "find") + + def test_span_created_for_get_more(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test_otel_getmore + coll.drop() + coll.insert_many([{"x": i} for i in range(5)]) + self.exporter.clear() + + docs = coll.find({}, batch_size=2).to_list() + self.assertEqual(len(docs), 5) + + get_more_spans = self.spans("getMore") + self.assertGreater(len(get_more_spans), 0) + for span in get_more_spans: + self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore") + self.assertEqual(span.attributes["db.command.name"], "getMore") + + def test_sensitive_command_produces_no_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with self.assertRaises(OperationFailure): + client.admin.command("saslStart", mechanism="SCRAM-SHA-256", payload=b"") + + names = [s.name for s in self.spans()] + self.assertNotIn("saslStart", names) + + def test_failure_records_exception_and_status_code(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with self.assertRaises(OperationFailure): + client[self.db.name].command("thisCommandDoesNotExist") + + spans = self.spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) + self.assertIn("db.response.status_code", span.attributes) + self.assertTrue(any(event.name == "exception" for event in span.events)) + + def test_tracing_disabled_by_default(self): + client = self.rs_or_single_client() + self.exporter.clear() + client.admin.command("ping") + self.assertEqual(self.spans(), []) + + def test_env_var_enables_tracing(self): + client = self.rs_or_single_client() + self.exporter.clear() + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client.admin.command("ping") + + self.assertIn("ping", [s.name for s in self.spans()]) + + def test_query_text_included_when_configured(self): + client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 1000}) + coll = client[self.db.name].test_otel + coll.drop() + self.exporter.clear() + coll.insert_one({"x": 1}) + + spans = self.spans("insert") + self.assertEqual(len(spans), 1) + self.assertIn("db.query.text", spans[0].attributes) + self.assertNotIn("lsid", spans[0].attributes["db.query.text"]) + + +if __name__ == "__main__": + unittest.main() From 29f7ccc72746ecce07694eb85d5c7639a2b429b2 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 20 Jul 2026 19:18:20 -0500 Subject: [PATCH 02/23] PYTHON-5945 Infer is_server_side_error from the exception in _CommandTelemetry.failed Avoids computing isinstance(exc, OperationFailure) twice at the single call site now that the exception is already threaded through for span exception recording. --- pymongo/_otel.py | 5 ++--- pymongo/_telemetry.py | 6 +++--- pymongo/asynchronous/command_runner.py | 2 +- pymongo/synchronous/command_runner.py | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index ad638c3cf6..cd085f274d 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -197,13 +197,12 @@ def end_span_success(span: Optional[Span], reply: _DocumentOut) -> None: def end_span_failure( span: Optional[Span], failure: _DocumentOut, - exc: Optional[BaseException], + exc: BaseException, ) -> None: """Record the exception, set the error status, and end the span.""" if span is None: return - if exc is not None: - span.record_exception(exc) + span.record_exception(exc) code = failure.get("code") if code is not None: span.set_attribute("db.response.status_code", str(code)) diff --git a/pymongo/_telemetry.py b/pymongo/_telemetry.py index e9064d381b..8384da9371 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -24,6 +24,7 @@ from typing import TYPE_CHECKING, Any, Optional from pymongo import _otel +from pymongo.errors import OperationFailure from pymongo.logger import ( _COMMAND_LOGGER, _CONNECTION_LOGGER, @@ -204,8 +205,7 @@ def failed( self, failure: _DocumentOut, command_name: str, - is_server_side_error: bool, - exc: Optional[BaseException] = None, + exc: BaseException, ) -> None: """Emit the FAILED log entry and APM event, and end the span.""" self._duration = datetime.datetime.now() - self._start @@ -216,7 +216,7 @@ def failed( _CommandStatusMessage.FAILED, durationMS=self._duration, failure=failure, - isServerSideError=is_server_side_error, + isServerSideError=isinstance(exc, OperationFailure), ) if self._publish: assert self._listeners is not None diff --git a/pymongo/asynchronous/command_runner.py b/pymongo/asynchronous/command_runner.py index 6d6e7ad1c1..6f9cce77aa 100644 --- a/pymongo/asynchronous/command_runner.py +++ b/pymongo/asynchronous/command_runner.py @@ -222,7 +222,7 @@ async def _run_command( failure: _DocumentOut = exc.details # type: ignore[assignment] else: failure = _convert_exception(exc) - telemetry.failed(failure, command_name, isinstance(exc, OperationFailure), exc) + telemetry.failed(failure, command_name, exc) raise telemetry.succeeded(docs[0], command_name, speculative_hello) diff --git a/pymongo/synchronous/command_runner.py b/pymongo/synchronous/command_runner.py index 5c63bee2ab..06be4e51a2 100644 --- a/pymongo/synchronous/command_runner.py +++ b/pymongo/synchronous/command_runner.py @@ -222,7 +222,7 @@ def _run_command( failure: _DocumentOut = exc.details # type: ignore[assignment] else: failure = _convert_exception(exc) - telemetry.failed(failure, command_name, isinstance(exc, OperationFailure), exc) + telemetry.failed(failure, command_name, exc) raise telemetry.succeeded(docs[0], command_name, speculative_hello) From 1450f05a3f6234db6687fe300f9ac31bad4353de Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 20 Jul 2026 19:18:57 -0500 Subject: [PATCH 03/23] PYTHON-5945 Defer changelog entry to follow-up ticket The OpenTelemetry feature isn't spec-test-complete yet (PYTHON-5947), so hold off announcing it in the changelog until that lands. --- doc/changelog.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/doc/changelog.rst b/doc/changelog.rst index de33220b31..aa4feb0af9 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -17,12 +17,6 @@ Changes in Version 4.18.0 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 - `OpenTelemetry driver specification `_. - 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. Changes in Version 4.17.0 (2026/04/20) -------------------------------------- From 8c99dd3003cade50ba30b39683620e6bceaef5dc Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 20 Jul 2026 19:29:20 -0500 Subject: [PATCH 04/23] PYTHON-5945 Add docstrings to _otel.py private helpers, drop DRIVERS-719/APM mentions Keeps the module's comments focused on what the code does rather than referencing tickets or the separate command-monitoring API. --- pymongo/_otel.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index cd085f274d..e130621e87 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Optional OpenTelemetry command-span support (DRIVERS-719). +"""Optional OpenTelemetry command-span support. Kept separate from :mod:`pymongo._telemetry` so that module stays free of ``opentelemetry`` import guards. Every function here is a no-op when @@ -66,6 +66,7 @@ class TracingOptions(TypedDict): def _env_truthy(name: str) -> bool: + """Return True if the environment variable ``name`` is set to "1", "true", or "yes".""" return os.getenv(name, "").strip().lower() in _TRUTHY @@ -84,6 +85,7 @@ def _is_tracing_enabled(tracing_options: Optional[TracingOptions]) -> bool: def _get_tracer() -> Tracer: + """Return a Tracer scoped to this driver's name and version.""" return trace.get_tracer("PyMongo", __version__) @@ -99,6 +101,7 @@ def _get_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: + """Serialize ``cmd`` to extended JSON, redacted and truncated to ``max_length``.""" filtered = {k: v for k, v in cmd.items() if k not in _QUERY_TEXT_EXCLUDED_FIELDS} text = json_util.dumps(filtered) return text[:max_length] @@ -112,6 +115,7 @@ def _extract_collection_name(command_name: str, cmd: Mapping[str, Any]) -> Optio def _build_query_summary(command_name: str, dbname: str, collection: Optional[str]) -> str: + """Build the ``db.query.summary`` attribute value for a command.""" if collection: return f"{command_name} {dbname}.{collection}" return f"{command_name} {dbname}" @@ -125,6 +129,7 @@ def _is_sensitive_command(command_name: str, speculative_hello: bool) -> bool: def _format_lsid(lsid: Mapping[str, Any]) -> Optional[str]: + """Return the ``db.mongodb.lsid`` attribute value for a session id document.""" id_value = lsid.get("id") if id_value is None: return None @@ -145,7 +150,7 @@ def start_span( """Start and return a CLIENT-kind span for a server command, or None. Returns None when tracing is disabled/unavailable or the command is - sensitive (mirroring the redaction applied to logs and APM events). + sensitive (mirroring the redaction applied to logs). """ if not _is_tracing_enabled(tracing_options): return None From 9b06bc267877986ff32263469bc75c9a73d03d9b Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 07:31:27 -0500 Subject: [PATCH 05/23] PYTHON-5945 Give otel tests their own pytest marker and Evergreen test type opentelemetry-sdk was only needed for exercising the in-memory span exporter in tests, not for typing pymongo/_otel.py itself, so it no longer belongs in the shared test requirements. Instead: - opentelemetry (api + sdk) is available to the typing venv only. - test_otel.py is tagged with a new "otel" pytest marker, so it's excluded from the default test run like encryption/kms/etc. - "otel" is wired into utils.py/setup_tests.py/generate_config.py the same way other optional test suites are, installing opentelemetry-sdk only when that suite is selected. --- .evergreen/generated_configs/variants.yml | 10 ++++++++++ .evergreen/scripts/generate_config.py | 13 +++++++++++++ .evergreen/scripts/setup_tests.py | 6 ++++++ .evergreen/scripts/utils.py | 1 + justfile | 2 +- pyproject.toml | 1 + requirements/test.txt | 1 - test/asynchronous/test_otel.py | 4 ++++ test/test_otel.py | 4 ++++ 9 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index b167508539..79944ebc75 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -441,6 +441,16 @@ buildvariants: - windows-2022-latest-small batchtime: 1440 + # Otel tests + - name: otel-rhel8 + tasks: + - name: .test-non-standard .standalone-noauth-nossl + display_name: OTel RHEL8 + run_on: + - rhel87-small + expansions: + TEST_NAME: otel + # Perf tests - name: performance-benchmarks tasks: diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 6dfa014a16..605a5ffce4 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -451,6 +451,19 @@ def create_doctests_variants(): ] +def create_otel_variants(): + host = DEFAULT_HOST + expansions = dict(TEST_NAME="otel") + return [ + create_variant( + [".test-non-standard .standalone-noauth-nossl"], + get_variant_name("OTel", host), + host=host, + expansions=expansions, + ) + ] + + def create_atlas_connect_variants(): host = DEFAULT_HOST return [ diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index e188dcaa9d..61da6b3aa5 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -42,6 +42,7 @@ "enterprise_auth": "gssapi", "kms": "encryption", "ocsp": "ocsp", + "otel": "opentelemetry", "pyopenssl": "ocsp", } @@ -439,6 +440,11 @@ def handle_test_env() -> None: if test_name == "numpy": UV_ARGS.append("--with numpy") + if test_name == "otel": + # The SDK is test-only tooling (for the in-memory span exporter); the driver + # itself must not depend on it, only on opentelemetry-api (the "opentelemetry" extra). + UV_ARGS.append("--with opentelemetry-sdk") + if test_name == "perf": data_dir = ROOT / "specifications/source/benchmarking/data" if not data_dir.exists(): diff --git a/.evergreen/scripts/utils.py b/.evergreen/scripts/utils.py index 914cd9ac60..4b1252ccca 100644 --- a/.evergreen/scripts/utils.py +++ b/.evergreen/scripts/utils.py @@ -43,6 +43,7 @@ class Distro: "load_balancer": "load_balancer", "mockupdb": "mockupdb", "ocsp": "ocsp", + "otel": "otel", "perf": "perf", "numpy": "", } diff --git a/justfile b/justfile index f6f10ca5a9..8b6f485056 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,7 @@ set shell := ["bash", "-c"] export UV_NO_LOCK := "1" # Commonly used command segments. -typing_run := "uv run --group typing --extra aws --extra encryption --with numpy --extra ocsp --extra snappy --extra test --extra zstd" +typing_run := "uv run --group typing --extra aws --extra encryption --with numpy --extra ocsp --extra opentelemetry --with opentelemetry-sdk --extra snappy --extra test --extra zstd" docs_run := "uv run --extra docs" doc_build := "./doc/_build" mypy_args := "--install-types --non-interactive" diff --git a/pyproject.toml b/pyproject.toml index 4adbed0de5..3e0b464dd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,6 +145,7 @@ markers = [ "encryption: encryption tests", "load_balancer: load balancer tests", "mockupdb: tests that rely on mockupdb", + "otel: tests that rely on opentelemetry", "default: default test suite", "default_async: default async test suite", ] diff --git a/requirements/test.txt b/requirements/test.txt index f7ce1bd6eb..566cade7ec 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,4 +1,3 @@ pytest>=8.2 pytest-asyncio>=0.24.0 importlib_metadata>=7.0;python_version < "3.13" -opentelemetry-sdk>=1.20.0 diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index e710e190ec..b8cc0fcdff 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -22,6 +22,8 @@ sys.path[0:0] = [""] +import pytest + import pymongo._otel as _otel from pymongo.errors import OperationFailure from test.asynchronous import AsyncIntegrationTest, unittest @@ -34,6 +36,8 @@ _IS_SYNC = False +pytestmark = pytest.mark.otel + def _shared_test_provider() -> TracerProvider: """Return a process-wide SDK TracerProvider for tests to attach exporters to. diff --git a/test/test_otel.py b/test/test_otel.py index bc4e10356f..e232a18f5a 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -22,6 +22,8 @@ sys.path[0:0] = [""] +import pytest + import pymongo._otel as _otel from pymongo.errors import OperationFailure from test import IntegrationTest, unittest @@ -34,6 +36,8 @@ _IS_SYNC = True +pytestmark = pytest.mark.otel + def _shared_test_provider() -> TracerProvider: """Return a process-wide SDK TracerProvider for tests to attach exporters to. From 9e0827bf8e3ed9175cb747fa39f380785f9d0ef7 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 07:42:49 -0500 Subject: [PATCH 06/23] PYTHON-5945 Omit db.collection.name for admin-database commands Per spec, db.collection.name must be omitted for commands against the admin database. Several non-sensitive admin commands (dropUser, grantRolesToUser, renameCollection, setFeatureCompatibilityVersion, ...) carry a string command value that isn't a collection - a username, role name, or namespace - and were leaking into db.collection.name (and db.query.summary) instead of being omitted. --- pymongo/_otel.py | 19 ++++++++++++++++--- test/asynchronous/test_otel.py | 14 ++++++++++++++ test/test_otel.py | 14 ++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index e130621e87..271c6cb51a 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -64,6 +64,10 @@ class TracingOptions(TypedDict): # See _gen_get_more_command in pymongo/message.py. _GET_MORE = "getMore" +# Commands against this database (e.g. user/role management, renameCollection) +# never have a real collection name, even when their command value is a string. +_ADMIN_DB = "admin" + def _env_truthy(name: str) -> bool: """Return True if the environment variable ``name`` is set to "1", "true", or "yes".""" @@ -107,8 +111,17 @@ def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: return text[:max_length] -def _extract_collection_name(command_name: str, cmd: Mapping[str, Any]) -> Optional[str]: - """Return the collection name targeted by ``cmd``, or None if it doesn't target one.""" +def _extract_collection_name( + command_name: str, dbname: str, cmd: Mapping[str, Any] +) -> Optional[str]: + """Return the collection name targeted by ``cmd``, or None if it doesn't target one. + + Always None for commands against the admin database: several (e.g. dropUser, + renameCollection) carry a string command value that names a user, role, or + namespace rather than a collection. + """ + if dbname == _ADMIN_DB: + return None key = "collection" if command_name == _GET_MORE else command_name value = cmd.get(key) return value if isinstance(value, str) else None @@ -157,7 +170,7 @@ def start_span( if _is_sensitive_command(command_name, speculative_hello): return None - collection = _extract_collection_name(command_name, cmd) + collection = _extract_collection_name(command_name, dbname, cmd) address = conn.address attributes: dict[str, Any] = { "db.system.name": "mongodb", diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index b8cc0fcdff..754cba6821 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -125,6 +125,20 @@ async def test_sensitive_command_produces_no_span(self): names = [s.name for s in self.spans()] self.assertNotIn("saslStart", names) + async def test_admin_command_omits_collection_name(self): + # usersInfo's command value is a username string, not a collection, and + # it always runs against admin; querying a nonexistent user is a no-op. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + await client.admin.command("usersInfo", "pymongo_otel_nonexistent_user") + + spans = self.spans("usersInfo") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertEqual(attrs["db.namespace"], "admin") + self.assertNotIn("db.collection.name", attrs) + self.assertEqual(attrs["db.query.summary"], "usersInfo admin") + async def test_failure_records_exception_and_status_code(self): client = await self.async_rs_or_single_client(tracing={"enabled": True}) self.exporter.clear() diff --git a/test/test_otel.py b/test/test_otel.py index e232a18f5a..d157b1d7e1 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -125,6 +125,20 @@ def test_sensitive_command_produces_no_span(self): names = [s.name for s in self.spans()] self.assertNotIn("saslStart", names) + def test_admin_command_omits_collection_name(self): + # usersInfo's command value is a username string, not a collection, and + # it always runs against admin; querying a nonexistent user is a no-op. + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + client.admin.command("usersInfo", "pymongo_otel_nonexistent_user") + + spans = self.spans("usersInfo") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertEqual(attrs["db.namespace"], "admin") + self.assertNotIn("db.collection.name", attrs) + self.assertEqual(attrs["db.query.summary"], "usersInfo admin") + def test_failure_records_exception_and_status_code(self): client = self.rs_or_single_client(tracing={"enabled": True}) self.exporter.clear() From d6da2ad4ab17eae781319fcc6b25ffcc695201db Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 07:48:28 -0500 Subject: [PATCH 07/23] PYTHON-5945 Add the OpenTelemetry spec's two prose tests Every YAML/JSON test under specifications/open-telemetry/tests asserts an outer operation-level span with the command span only as a nested child, so none of them are runnable without operation-span support (deferred to PYTHON-5947). The spec's two prose tests are command/config-scoped only, so add them directly: env-var enable and disable, and env-var-driven db.query.text emission (a path that wasn't previously exercised - only the client-option path was). --- test/asynchronous/test_otel.py | 36 ++++++++++++++++++++++++++++++---- test/test_otel.py | 36 ++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 754cba6821..fa39cc3ad6 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -158,14 +158,42 @@ async def test_tracing_disabled_by_default(self): await client.admin.command("ping") self.assertEqual(self.spans(), []) - async def test_env_var_enables_tracing(self): - client = await self.async_rs_or_single_client() - self.exporter.clear() - with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + async def test_prose_1_tracing_enable_disable_via_env_var(self): + """Prose Test 1: Tracing Enable/Disable via Environment Variable.""" + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "false"}): + client = await self.async_rs_or_single_client() + self.exporter.clear() await client.admin.command("ping") + self.assertEqual(self.spans(), []) + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = await self.async_rs_or_single_client() + self.exporter.clear() + await client.admin.command("ping") self.assertIn("ping", [s.name for s in self.spans()]) + async def test_prose_2_command_payload_emission_via_env_var(self): + """Prose Test 2: Command Payload Emission via Environment Variable.""" + env = { + "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true", + "OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024", + } + with patch.dict(os.environ, env): + client = await self.async_rs_or_single_client() + self.exporter.clear() + await client[self.db.name].test_otel.find({}).to_list() + spans = self.spans("find") + self.assertEqual(len(spans), 1) + self.assertIn("db.query.text", spans[0].attributes) + + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = await self.async_rs_or_single_client() + self.exporter.clear() + await client[self.db.name].test_otel.find({}).to_list() + spans = self.spans("find") + self.assertEqual(len(spans), 1) + self.assertNotIn("db.query.text", spans[0].attributes) + async def test_query_text_included_when_configured(self): client = await self.async_rs_or_single_client( tracing={"enabled": True, "query_text_max_length": 1000} diff --git a/test/test_otel.py b/test/test_otel.py index d157b1d7e1..25919b13dc 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -158,14 +158,42 @@ def test_tracing_disabled_by_default(self): client.admin.command("ping") self.assertEqual(self.spans(), []) - def test_env_var_enables_tracing(self): - client = self.rs_or_single_client() - self.exporter.clear() - with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + def test_prose_1_tracing_enable_disable_via_env_var(self): + """Prose Test 1: Tracing Enable/Disable via Environment Variable.""" + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "false"}): + client = self.rs_or_single_client() + self.exporter.clear() client.admin.command("ping") + self.assertEqual(self.spans(), []) + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = self.rs_or_single_client() + self.exporter.clear() + client.admin.command("ping") self.assertIn("ping", [s.name for s in self.spans()]) + def test_prose_2_command_payload_emission_via_env_var(self): + """Prose Test 2: Command Payload Emission via Environment Variable.""" + env = { + "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true", + "OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024", + } + with patch.dict(os.environ, env): + client = self.rs_or_single_client() + self.exporter.clear() + client[self.db.name].test_otel.find({}).to_list() + spans = self.spans("find") + self.assertEqual(len(spans), 1) + self.assertIn("db.query.text", spans[0].attributes) + + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = self.rs_or_single_client() + self.exporter.clear() + client[self.db.name].test_otel.find({}).to_list() + spans = self.spans("find") + self.assertEqual(len(spans), 1) + self.assertNotIn("db.query.text", spans[0].attributes) + def test_query_text_included_when_configured(self): client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 1000}) coll = client[self.db.name].test_otel From 8b7479b44d2bc8a027be8e808aa3f14d4683a001 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 09:32:53 -0500 Subject: [PATCH 08/23] PYTHON-5945 Fix four command-span correctness bugs found in review - db.query.text truncation reused a blind full-string slice, almost always producing invalid JSON; now truncates oversized field values first (matching the existing log-message truncation approach), with the blind slice only as a last-resort safety net. - explain wraps the real command ({"explain": {"find": ...}}), the same shape as getMore's indirection, but wasn't handled, so explain spans silently lost db.collection.name. - An explicit tracing.query_text_max_length=0 (meant to disable db.query.text) was indistinguishable from "not configured" and got silently overridden by the environment variable. The option is now Optional[int]: unset defers to the environment variable, any explicit value (including 0) always wins. - server.port was set to None for Unix domain socket connections (address[1] is None for .sock addresses), an invalid attribute type that the OTel SDK would warn about and drop; the attribute is now omitted instead. Also corrected _ConnectionTelemetryInfo.address's type to match reality (_Address, not a non-optional int port). --- pymongo/_otel.py | 55 ++++++++++++++++----- pymongo/asynchronous/mongo_client.py | 8 +-- pymongo/client_options.py | 2 +- pymongo/common.py | 5 +- pymongo/pool_shared.py | 2 +- pymongo/synchronous/mongo_client.py | 8 +-- test/asynchronous/test_otel.py | 73 ++++++++++++++++++++++++++++ test/test_otel.py | 69 ++++++++++++++++++++++++++ 8 files changed, 201 insertions(+), 21 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 271c6cb51a..8d5e0c2ec4 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -26,8 +26,9 @@ from typing import TYPE_CHECKING, Any, Optional, TypedDict from bson import json_util +from bson.json_util import _truncate_documents from pymongo._version import __version__ -from pymongo.logger import _HELLO_COMMANDS, _SENSITIVE_COMMANDS +from pymongo.logger import _HELLO_COMMANDS, _JSON_OPTIONS, _SENSITIVE_COMMANDS try: from opentelemetry import trace @@ -45,10 +46,15 @@ class TracingOptions(TypedDict): - """The shape of the ``MongoClient`` ``tracing`` option.""" + """The shape of the ``MongoClient`` ``tracing`` option. + + ``query_text_max_length`` is None when the client didn't configure it, so + the environment variable can be consulted; any explicit value (including + 0, to force ``db.query.text`` off) overrides the environment variable. + """ enabled: bool - query_text_max_length: int + query_text_max_length: Optional[int] _OTEL_ENABLED_ENV = "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED" @@ -64,6 +70,11 @@ class TracingOptions(TypedDict): # See _gen_get_more_command in pymongo/message.py. _GET_MORE = "getMore" +# explain wraps the real command (e.g. find/aggregate) rather than naming a +# collection directly: {"explain": {"find": "coll", ...}}. See _Query.as_command +# in pymongo/message.py. +_EXPLAIN = "explain" + # Commands against this database (e.g. user/role management, renameCollection) # never have a real collection name, even when their command value is a string. _ADMIN_DB = "admin" @@ -94,10 +105,14 @@ def _get_tracer() -> Tracer: def _get_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int: - """Return the configured db.query.text truncation length, or 0 to omit the attribute.""" - client_value = tracing_options.get("query_text_max_length", 0) if tracing_options else 0 - if client_value > 0: - return client_value + """Return the configured db.query.text truncation length, or 0 to omit the attribute. + + An explicit client value (including 0) always wins; the environment + variable is only consulted when the client didn't configure it at all. + """ + client_value = tracing_options.get("query_text_max_length") if tracing_options else None + if client_value is not None: + return max(0, client_value) try: return max(0, int(os.getenv(_OTEL_QUERY_TEXT_MAX_LENGTH_ENV, "0"))) except ValueError: @@ -105,10 +120,20 @@ def _get_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: - """Serialize ``cmd`` to extended JSON, redacted and truncated to ``max_length``.""" + """Serialize ``cmd`` to extended JSON, redacted and truncated to ``max_length``. + + Mirrors the truncation approach used for log messages: truncate field + values first, which usually keeps the result well-formed JSON (unlike a + blind cut of the fully-serialized string), then fall back to a hard + string cut with a "..." marker as a safety net for whatever the field + truncation's size estimate still leaves over ``max_length``. + """ filtered = {k: v for k, v in cmd.items() if k not in _QUERY_TEXT_EXCLUDED_FIELDS} - text = json_util.dumps(filtered) - return text[:max_length] + truncated_cmd = _truncate_documents(filtered, max_length)[0] + text = json_util.dumps(truncated_cmd, json_options=_JSON_OPTIONS) + if len(text) > max_length: + text = text[:max_length] + "..." + return text def _extract_collection_name( @@ -122,6 +147,12 @@ def _extract_collection_name( """ if dbname == _ADMIN_DB: return None + if command_name == _EXPLAIN: + inner = cmd.get(_EXPLAIN) + if not isinstance(inner, Mapping) or not inner: + return None + inner_name = next(iter(inner)) + return _extract_collection_name(inner_name, dbname, inner) key = "collection" if command_name == _GET_MORE else command_name value = cmd.get(key) return value if isinstance(value, str) else None @@ -178,10 +209,12 @@ def start_span( "db.command.name": command_name, "db.query.summary": _build_query_summary(command_name, dbname, collection), "server.address": address[0], - "server.port": address[1], "network.transport": "unix" if address[0].endswith(".sock") else "tcp", "db.mongodb.driver_connection_id": conn.id, } + # Unix domain socket addresses have no port. + if address[1] is not None: + attributes["server.port"] = address[1] if collection: attributes["db.collection.name"] = collection if conn.server_connection_id is not None: diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index 6cacd9b5f6..90151acf3e 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -625,9 +625,11 @@ def __init__( ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either being enabled is sufficient. - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` - span attribute. Defaults to ``0``, which omits the attribute. Also controlled - by the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` - environment variable. + span attribute. Unset by default, which defers to the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment + variable (itself defaulting to ``0``, which omits the attribute). Setting + this explicitly, including to ``0``, always overrides the environment + variable. .. seealso:: The MongoDB documentation on `connections `_. diff --git a/pymongo/client_options.py b/pymongo/client_options.py index efc2197b46..d2d33f6805 100644 --- a/pymongo/client_options.py +++ b/pymongo/client_options.py @@ -250,7 +250,7 @@ def __init__( ) self.__tracing = cast( "_otel.TracingOptions", - options.get("tracing") or {"enabled": False, "query_text_max_length": 0}, + options.get("tracing") or {"enabled": False, "query_text_max_length": None}, ) @property diff --git a/pymongo/common.py b/pymongo/common.py index 9079ea27cf..bf9a85620a 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -618,8 +618,9 @@ def validate_tracing_or_none(option: str, value: Any) -> Optional[_otel.TracingO raise ConfigurationError(f"Unknown tracing option(s): {sorted(unknown)}") enabled = value.get("enabled", False) validate_boolean("tracing.enabled", enabled) - query_text_max_length = value.get("query_text_max_length", 0) - validate_non_negative_integer("tracing.query_text_max_length", query_text_max_length) + query_text_max_length = value.get("query_text_max_length") + if query_text_max_length is not None: + validate_non_negative_integer("tracing.query_text_max_length", query_text_max_length) return {"enabled": enabled, "query_text_max_length": query_text_max_length} diff --git a/pymongo/pool_shared.py b/pymongo/pool_shared.py index 410ffd8189..ca7bf9370b 100644 --- a/pymongo/pool_shared.py +++ b/pymongo/pool_shared.py @@ -55,7 +55,7 @@ class _ConnectionTelemetryInfo(Protocol): id: int server_connection_id: Optional[int] - address: tuple[str, int] + address: _Address service_id: Optional[ObjectId] diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 8a637a885c..4f01e03a7e 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -626,9 +626,11 @@ def __init__( ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either being enabled is sufficient. - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` - span attribute. Defaults to ``0``, which omits the attribute. Also controlled - by the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` - environment variable. + span attribute. Unset by default, which defers to the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment + variable (itself defaulting to ``0``, which omits the attribute). Setting + this explicitly, including to ``0``, always overrides the environment + variable. .. seealso:: The MongoDB documentation on `connections `_. diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index fa39cc3ad6..9d85883947 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -18,6 +18,7 @@ import os import sys +from typing import Optional from unittest.mock import patch sys.path[0:0] = [""] @@ -26,6 +27,7 @@ import pymongo._otel as _otel from pymongo.errors import OperationFailure +from pymongo.typings import _Address from test.asynchronous import AsyncIntegrationTest, unittest if _otel._HAS_OPENTELEMETRY: @@ -116,6 +118,43 @@ async def test_span_created_for_get_more(self): self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore") self.assertEqual(span.attributes["db.command.name"], "getMore") + async def test_explain_retains_collection_name(self): + # explain wraps the real command ({"explain": {"find": "coll", ...}}), the + # same shape as getMore's indirection, so it needs the same handling. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + await client[self.db.name].command("explain", {"find": "test_otel", "filter": {}}) + + spans = self.spans("explain") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertEqual(attrs["db.collection.name"], "test_otel") + self.assertEqual(attrs["db.query.summary"], f"explain {self.db.name}.test_otel") + + async def test_server_port_omitted_for_unix_socket(self): + class _FakeUnixConn: + id = 1 + server_connection_id: Optional[int] = None + address: _Address = ("/tmp/fake-otel-test.sock", None) + service_id = None + + self.exporter.clear() + span = _otel.start_span( + {"enabled": True, "query_text_max_length": None}, + _FakeUnixConn(), + {"ping": 1}, + "admin", + "ping", + False, + ) + _otel.end_span_success(span, {"ok": 1}) + + spans = self.spans("ping") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("server.port", attrs) + self.assertEqual(attrs["network.transport"], "unix") + async def test_sensitive_command_produces_no_span(self): client = await self.async_rs_or_single_client(tracing={"enabled": True}) self.exporter.clear() @@ -208,6 +247,40 @@ async def test_query_text_included_when_configured(self): self.assertIn("db.query.text", spans[0].attributes) self.assertNotIn("lsid", spans[0].attributes["db.query.text"]) + async def test_explicit_query_text_max_length_zero_overrides_env_var(self): + # An explicit client-side 0 must win over the environment variable, unlike + # unset (which defers to it) - otherwise an app can't reliably opt out. + env = {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024"} + with patch.dict(os.environ, env): + client = await self.async_rs_or_single_client( + tracing={"enabled": True, "query_text_max_length": 0} + ) + self.exporter.clear() + await client.admin.command("ping") + + spans = self.spans("ping") + self.assertEqual(len(spans), 1) + self.assertNotIn("db.query.text", spans[0].attributes) + + async def test_query_text_truncation_shrinks_oversized_field_values(self): + client = await self.async_rs_or_single_client( + tracing={"enabled": True, "query_text_max_length": 200} + ) + coll = client[self.db.name].test_otel + await coll.drop() + self.exporter.clear() + await coll.insert_one({"x": "a" * 500}) + + spans = self.spans("insert") + self.assertEqual(len(spans), 1) + query_text = spans[0].attributes["db.query.text"] + # The oversized field value must be truncated at the field level (not + # just a blind cut of the fully-serialized string), keeping the result + # close to the configured bound; a "..." marker signals the (possible) + # safety-net cut, mirroring the driver's existing log-truncation approach. + self.assertLessEqual(len(query_text), 200 + len("...")) + self.assertNotIn("a" * 500, query_text) + if __name__ == "__main__": unittest.main() diff --git a/test/test_otel.py b/test/test_otel.py index 25919b13dc..d7e43e1324 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -18,6 +18,7 @@ import os import sys +from typing import Optional from unittest.mock import patch sys.path[0:0] = [""] @@ -26,6 +27,7 @@ import pymongo._otel as _otel from pymongo.errors import OperationFailure +from pymongo.typings import _Address from test import IntegrationTest, unittest if _otel._HAS_OPENTELEMETRY: @@ -116,6 +118,43 @@ def test_span_created_for_get_more(self): self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore") self.assertEqual(span.attributes["db.command.name"], "getMore") + def test_explain_retains_collection_name(self): + # explain wraps the real command ({"explain": {"find": "coll", ...}}), the + # same shape as getMore's indirection, so it needs the same handling. + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + client[self.db.name].command("explain", {"find": "test_otel", "filter": {}}) + + spans = self.spans("explain") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertEqual(attrs["db.collection.name"], "test_otel") + self.assertEqual(attrs["db.query.summary"], f"explain {self.db.name}.test_otel") + + def test_server_port_omitted_for_unix_socket(self): + class _FakeUnixConn: + id = 1 + server_connection_id: Optional[int] = None + address: _Address = ("/tmp/fake-otel-test.sock", None) + service_id = None + + self.exporter.clear() + span = _otel.start_span( + {"enabled": True, "query_text_max_length": None}, + _FakeUnixConn(), + {"ping": 1}, + "admin", + "ping", + False, + ) + _otel.end_span_success(span, {"ok": 1}) + + spans = self.spans("ping") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("server.port", attrs) + self.assertEqual(attrs["network.transport"], "unix") + def test_sensitive_command_produces_no_span(self): client = self.rs_or_single_client(tracing={"enabled": True}) self.exporter.clear() @@ -206,6 +245,36 @@ def test_query_text_included_when_configured(self): self.assertIn("db.query.text", spans[0].attributes) self.assertNotIn("lsid", spans[0].attributes["db.query.text"]) + def test_explicit_query_text_max_length_zero_overrides_env_var(self): + # An explicit client-side 0 must win over the environment variable, unlike + # unset (which defers to it) - otherwise an app can't reliably opt out. + env = {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024"} + with patch.dict(os.environ, env): + client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 0}) + self.exporter.clear() + client.admin.command("ping") + + spans = self.spans("ping") + self.assertEqual(len(spans), 1) + self.assertNotIn("db.query.text", spans[0].attributes) + + def test_query_text_truncation_shrinks_oversized_field_values(self): + client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 200}) + coll = client[self.db.name].test_otel + coll.drop() + self.exporter.clear() + coll.insert_one({"x": "a" * 500}) + + spans = self.spans("insert") + self.assertEqual(len(spans), 1) + query_text = spans[0].attributes["db.query.text"] + # The oversized field value must be truncated at the field level (not + # just a blind cut of the fully-serialized string), keeping the result + # close to the configured bound; a "..." marker signals the (possible) + # safety-net cut, mirroring the driver's existing log-truncation approach. + self.assertLessEqual(len(query_text), 200 + len("...")) + self.assertNotIn("a" * 500, query_text) + if __name__ == "__main__": unittest.main() From ed1971fee40cba12307af49045f6f9b211055744 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 09:42:57 -0500 Subject: [PATCH 09/23] PYTHON-5945 Note which otel tests will be superseded by PYTHON-5947 test_span_created_for_insert_and_find and test_query_text_included_when_configured duplicate coverage the unified test format's find_without_query_text.yml/insert.yml and find.yml will provide once operation-span support and the expectTracingMessages runner land; flag them for removal at that point rather than leaving the redundancy undocumented. --- test/asynchronous/test_otel.py | 6 ++++++ test/test_otel.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 9d85883947..90ee256cef 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -74,6 +74,9 @@ def spans(self, name: str | None = None): return list(finished) return [s for s in finished if s.name == name] + # TODO(PYTHON-5947): once the unified test format runner supports + # expectTracingMessages/operation spans, this is superseded by the spec's + # find_without_query_text.yml and insert.yml. async def test_span_created_for_insert_and_find(self): client = await self.async_rs_or_single_client(tracing={"enabled": True}) coll = client[self.db.name].test_otel @@ -233,6 +236,9 @@ async def test_prose_2_command_payload_emission_via_env_var(self): self.assertEqual(len(spans), 1) self.assertNotIn("db.query.text", spans[0].attributes) + # TODO(PYTHON-5947): once the unified test format runner supports + # expectTracingMessages/operation spans, this is superseded by the spec's + # find.yml (db.query.text assertion). async def test_query_text_included_when_configured(self): client = await self.async_rs_or_single_client( tracing={"enabled": True, "query_text_max_length": 1000} diff --git a/test/test_otel.py b/test/test_otel.py index d7e43e1324..77c344c81f 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -74,6 +74,9 @@ def spans(self, name: str | None = None): return list(finished) return [s for s in finished if s.name == name] + # TODO(PYTHON-5947): once the unified test format runner supports + # expectTracingMessages/operation spans, this is superseded by the spec's + # find_without_query_text.yml and insert.yml. def test_span_created_for_insert_and_find(self): client = self.rs_or_single_client(tracing={"enabled": True}) coll = client[self.db.name].test_otel @@ -233,6 +236,9 @@ def test_prose_2_command_payload_emission_via_env_var(self): self.assertEqual(len(spans), 1) self.assertNotIn("db.query.text", spans[0].attributes) + # TODO(PYTHON-5947): once the unified test format runner supports + # expectTracingMessages/operation spans, this is superseded by the spec's + # find.yml (db.query.text assertion). def test_query_text_included_when_configured(self): client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 1000}) coll = client[self.db.name].test_otel From 4fb56bb507f7e05d7222a8c34a616940dfba948b Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 11:59:14 -0500 Subject: [PATCH 10/23] PYTHON-5945 Defer MongoClient docstring updates for tracing to follow-up PR The tracing keyword argument remains fully functional (validated, threaded through to _CommandTelemetry); only its user-facing docstring documentation is deferred alongside the changelog entry. --- pymongo/asynchronous/mongo_client.py | 19 ------------------- pymongo/synchronous/mongo_client.py | 19 ------------------- 2 files changed, 38 deletions(-) diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index 90151acf3e..cd1cefea13 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -615,27 +615,8 @@ def __init__( If enabled, server overload errors will cause retry attempts to select a server that has not yet returned an overload error, if possible. Defaults to ``False``. - | **OpenTelemetry options:** - | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) - - - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: - - - ``enabled``: (boolean) Whether to create spans for server commands issued by - this client. Defaults to ``False``. Also controlled by the - ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either - being enabled is sufficient. - - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` - span attribute. Unset by default, which defers to the - ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment - variable (itself defaulting to ``0``, which omits the attribute). Setting - this explicitly, including to ``0``, always overrides the environment - variable. - .. seealso:: The MongoDB documentation on `connections `_. - .. versionchanged:: 4.18 - Added the ``tracing`` keyword argument. - .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 4f01e03a7e..d3f6a1e217 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -616,27 +616,8 @@ def __init__( If enabled, server overload errors will cause retry attempts to select a server that has not yet returned an overload error, if possible. Defaults to ``False``. - | **OpenTelemetry options:** - | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) - - - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: - - - ``enabled``: (boolean) Whether to create spans for server commands issued by - this client. Defaults to ``False``. Also controlled by the - ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either - being enabled is sufficient. - - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` - span attribute. Unset by default, which defers to the - ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment - variable (itself defaulting to ``0``, which omits the attribute). Setting - this explicitly, including to ``0``, always overrides the environment - variable. - .. seealso:: The MongoDB documentation on `connections `_. - .. versionchanged:: 4.18 - Added the ``tracing`` keyword argument. - .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. From 1cebc3a342b238b72dc35c07ce5c7ddf9eb0766e Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 12:23:29 -0500 Subject: [PATCH 11/23] PYTHON-5945 Rename command-span functions in _otel.py for clarity start_span/end_span_success/end_span_failure only ever handle command spans; renaming to start_command_span/end_command_span_success/ end_command_span_failure now (a fully private, 6-call-site module) leaves unambiguous naming room for PYTHON-5947's operation and transaction span functions. --- pymongo/_otel.py | 6 +++--- pymongo/_telemetry.py | 6 +++--- test/asynchronous/test_otel.py | 4 ++-- test/test_otel.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 8d5e0c2ec4..5d92525254 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -183,7 +183,7 @@ def _format_lsid(lsid: Mapping[str, Any]) -> Optional[str]: return str(id_value) -def start_span( +def start_command_span( tracing_options: Optional[TracingOptions], conn: _ConnectionTelemetryInfo, cmd: MutableMapping[str, Any], @@ -235,7 +235,7 @@ def start_span( return tracer.start_span(command_name, kind=SpanKind.CLIENT, attributes=attributes) -def end_span_success(span: Optional[Span], reply: _DocumentOut) -> None: +def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None: """Set the cursor id (if any) and end the span.""" if span is None: return @@ -245,7 +245,7 @@ def end_span_success(span: Optional[Span], reply: _DocumentOut) -> None: span.end() -def end_span_failure( +def end_command_span_failure( span: Optional[Span], failure: _DocumentOut, exc: BaseException, diff --git a/pymongo/_telemetry.py b/pymongo/_telemetry.py index 8384da9371..b770add9d6 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -153,7 +153,7 @@ def started(self, orig: MutableMapping[str, Any], ensure_db: bool) -> None: service_id=self._conn.service_id, ) if self._tracing_enabled: - self._span = _otel.start_span( + self._span = _otel.start_command_span( self._tracing_options, self._conn, self._cmd, @@ -199,7 +199,7 @@ def succeeded( database_name=self._dbname, ) if self._span is not None: - _otel.end_span_success(self._span, reply) + _otel.end_command_span_success(self._span, reply) def failed( self, @@ -232,7 +232,7 @@ def failed( database_name=self._dbname, ) if self._span is not None: - _otel.end_span_failure(self._span, failure, exc) + _otel.end_command_span_failure(self._span, failure, exc) class _CmapTelemetry: diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 90ee256cef..b620ac8dbf 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -142,7 +142,7 @@ class _FakeUnixConn: service_id = None self.exporter.clear() - span = _otel.start_span( + span = _otel.start_command_span( {"enabled": True, "query_text_max_length": None}, _FakeUnixConn(), {"ping": 1}, @@ -150,7 +150,7 @@ class _FakeUnixConn: "ping", False, ) - _otel.end_span_success(span, {"ok": 1}) + _otel.end_command_span_success(span, {"ok": 1}) spans = self.spans("ping") self.assertEqual(len(spans), 1) diff --git a/test/test_otel.py b/test/test_otel.py index 77c344c81f..e01a6313c8 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -142,7 +142,7 @@ class _FakeUnixConn: service_id = None self.exporter.clear() - span = _otel.start_span( + span = _otel.start_command_span( {"enabled": True, "query_text_max_length": None}, _FakeUnixConn(), {"ping": 1}, @@ -150,7 +150,7 @@ class _FakeUnixConn: "ping", False, ) - _otel.end_span_success(span, {"ok": 1}) + _otel.end_command_span_success(span, {"ok": 1}) spans = self.spans("ping") self.assertEqual(len(spans), 1) From 993e1517b23eb622e38de0f561b4f3279f5b29db Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 12:27:18 -0500 Subject: [PATCH 12/23] PYTHON-5945 Run the otel CI variant on PRs Tag otel-rhel8 with "pr" so the new test type is exercised on every pull request rather than only on mainline/manual runs, matching how other single-variant optional test suites (e.g. atlas-connect) are tagged. --- .evergreen/generated_configs/variants.yml | 1 + .evergreen/scripts/generate_config.py | 1 + 2 files changed, 2 insertions(+) diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 79944ebc75..12e2d7716c 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -450,6 +450,7 @@ buildvariants: - rhel87-small expansions: TEST_NAME: otel + tags: [pr] # Perf tests - name: performance-benchmarks diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 605a5ffce4..6108ed12a3 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -459,6 +459,7 @@ def create_otel_variants(): [".test-non-standard .standalone-noauth-nossl"], get_variant_name("OTel", host), host=host, + tags=["pr"], expansions=expansions, ) ] From b1ae7038f5d842a3bf404ea970415f282c6aad76 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 14:00:07 -0500 Subject: [PATCH 13/23] PYTHON-5945 Merge otel coverage into the combined Evergreen report setup_tests.py only installed the coverage tooling and wrote COVERAGE to test-env.sh when --cov was passed as an explicit CLI flag, so a buildvariant-level COVERAGE expansion (the only mechanism available to generated variants like otel-rhel8) was silently a no-op: the "coverage" package was never installed, so `coverage run` would have failed had the env var actually been wired up. Fix setup_tests.py to also honor a pre-set COVERAGE env var, and tag otel-rhel8 with it so its coverage merges into the combined report instead of showing as ~0% patch coverage. Verified locally: with COVERAGE=1, setup-tests now adds `--group coverage` and writes COVERAGE=1 to test-env.sh, and running the suite reports 88% coverage for pymongo/_otel.py (vs. ~28% previously, when the otel-marked tests weren't measured with coverage at all). --- .evergreen/generated_configs/variants.yml | 1 + .evergreen/scripts/generate_config.py | 3 ++- .evergreen/scripts/setup_tests.py | 5 +++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 12e2d7716c..5fe79df1f0 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -450,6 +450,7 @@ buildvariants: - rhel87-small expansions: TEST_NAME: otel + COVERAGE: "1" tags: [pr] # Perf tests diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 6108ed12a3..d250580ce6 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -453,7 +453,8 @@ def create_doctests_variants(): def create_otel_variants(): host = DEFAULT_HOST - expansions = dict(TEST_NAME="otel") + # Merge otel's coverage into the combined report; see setup_tests.py's COVERAGE handling. + expansions = dict(TEST_NAME="otel", COVERAGE="1") return [ create_variant( [".test-non-standard .standalone-noauth-nossl"], diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 61da6b3aa5..ccfcfafc25 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -466,9 +466,10 @@ def handle_test_env() -> None: else: TEST_ARGS = f"test/performance/async_perf_test.py {TEST_ARGS}" - # Add coverage if requested. + # Add coverage if requested, either via --cov or a pre-set COVERAGE expansion + # (e.g. a buildvariant that wants its coverage merged into the combined report). # Only cover CPython. PyPy reports suspiciously low coverage. - if opts.cov and platform.python_implementation() == "CPython": + if (opts.cov or is_set("COVERAGE")) and platform.python_implementation() == "CPython": # Keep in sync with combine-coverage.sh. # coverage >=5 is needed for relative_files=true. UV_ARGS.append("--group coverage") From afc2bdccb9f2af4c25e9824e26ceb50a13912456 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 14:50:41 -0500 Subject: [PATCH 14/23] PYTHON-5945 Add unit tests for validate_tracing_or_none Covers defaults, the enabled/query_text_max_length combination, the explicit-0-vs-unset distinction (0 must survive validation rather than being coerced away, since it's meaningful for overriding the environment variable), and each rejection path (non-mapping, unknown key, non-boolean enabled, non-integer/negative query_text_max_length). No live server or opentelemetry install required. --- test/asynchronous/test_common.py | 50 +++++++++++++++++++++++++++++++- test/test_common.py | 50 +++++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/test/asynchronous/test_common.py b/test/asynchronous/test_common.py index 8440ca98dc..ca03141c63 100644 --- a/test/asynchronous/test_common.py +++ b/test/asynchronous/test_common.py @@ -24,7 +24,8 @@ from bson.binary import PYTHON_LEGACY, STANDARD, Binary, UuidRepresentation from bson.codec_options import CodecOptions from bson.objectid import ObjectId -from pymongo.errors import OperationFailure +from pymongo import common +from pymongo.errors import ConfigurationError, OperationFailure from pymongo.write_concern import WriteConcern from test.asynchronous import AsyncIntegrationTest, async_client_context, connected, unittest @@ -181,5 +182,52 @@ async def test_validate_boolean(self): await self.db.test.update_one({}, {"$set": {"total": 1}}, {"upsert": True}) # type: ignore +class TestValidateTracingOrNone(unittest.TestCase): + def test_none(self): + self.assertIsNone(common.validate_tracing_or_none("tracing", None)) + + def test_defaults(self): + self.assertEqual( + common.validate_tracing_or_none("tracing", {}), + {"enabled": False, "query_text_max_length": None}, + ) + + def test_enabled_and_query_text_max_length(self): + self.assertEqual( + common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 500} + ), + {"enabled": True, "query_text_max_length": 500}, + ) + + def test_explicit_zero_query_text_max_length_preserved(self): + # 0 must stay distinct from "unset" (None) so it can override the + # environment variable instead of being treated as not configured. + result = common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 0} + ) + self.assertEqual(result["query_text_max_length"], 0) + + def test_rejects_non_mapping(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", "enabled") + + def test_rejects_unknown_option(self): + with self.assertRaisesRegex(ConfigurationError, "Unknown tracing option"): + common.validate_tracing_or_none("tracing", {"bogus": True}) + + def test_rejects_non_boolean_enabled(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"enabled": "yes"}) + + def test_rejects_non_integer_query_text_max_length(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": [1]}) + + def test_rejects_negative_query_text_max_length(self): + with self.assertRaises(ValueError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_common.py b/test/test_common.py index b0c706b9f2..ca39e21c6b 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -24,7 +24,8 @@ from bson.binary import PYTHON_LEGACY, STANDARD, Binary, UuidRepresentation from bson.codec_options import CodecOptions from bson.objectid import ObjectId -from pymongo.errors import OperationFailure +from pymongo import common +from pymongo.errors import ConfigurationError, OperationFailure from pymongo.write_concern import WriteConcern from test import IntegrationTest, client_context, connected, unittest @@ -179,5 +180,52 @@ def test_validate_boolean(self): self.db.test.update_one({}, {"$set": {"total": 1}}, {"upsert": True}) # type: ignore +class TestValidateTracingOrNone(unittest.TestCase): + def test_none(self): + self.assertIsNone(common.validate_tracing_or_none("tracing", None)) + + def test_defaults(self): + self.assertEqual( + common.validate_tracing_or_none("tracing", {}), + {"enabled": False, "query_text_max_length": None}, + ) + + def test_enabled_and_query_text_max_length(self): + self.assertEqual( + common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 500} + ), + {"enabled": True, "query_text_max_length": 500}, + ) + + def test_explicit_zero_query_text_max_length_preserved(self): + # 0 must stay distinct from "unset" (None) so it can override the + # environment variable instead of being treated as not configured. + result = common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 0} + ) + self.assertEqual(result["query_text_max_length"], 0) + + def test_rejects_non_mapping(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", "enabled") + + def test_rejects_unknown_option(self): + with self.assertRaisesRegex(ConfigurationError, "Unknown tracing option"): + common.validate_tracing_or_none("tracing", {"bogus": True}) + + def test_rejects_non_boolean_enabled(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"enabled": "yes"}) + + def test_rejects_non_integer_query_text_max_length(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": [1]}) + + def test_rejects_negative_query_text_max_length(self): + with self.assertRaises(ValueError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) + + if __name__ == "__main__": unittest.main() From e2c3b34adc6723ab93350f8c0339dffb77a0d305 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 14:58:01 -0500 Subject: [PATCH 15/23] PYTHON-5945 Reserve room for the "..." marker in db.query.text truncation The fallback slice appended "..." after already cutting to max_length, so the result could be up to 3 characters longer than the configured bound instead of respecting it. Carve the marker out of the budget instead of adding it on top. --- pymongo/_otel.py | 9 ++++++--- test/asynchronous/test_otel.py | 7 +++---- test/test_otel.py | 7 +++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 5d92525254..ef3f9e5c5d 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -125,14 +125,17 @@ def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: Mirrors the truncation approach used for log messages: truncate field values first, which usually keeps the result well-formed JSON (unlike a blind cut of the fully-serialized string), then fall back to a hard - string cut with a "..." marker as a safety net for whatever the field - truncation's size estimate still leaves over ``max_length``. + string cut as a safety net for whatever the field truncation's size + estimate still leaves over ``max_length``. The "..." marker is carved out + of the budget (not appended on top of it) so the result never exceeds + ``max_length``. """ filtered = {k: v for k, v in cmd.items() if k not in _QUERY_TEXT_EXCLUDED_FIELDS} truncated_cmd = _truncate_documents(filtered, max_length)[0] text = json_util.dumps(truncated_cmd, json_options=_JSON_OPTIONS) if len(text) > max_length: - text = text[:max_length] + "..." + suffix = "..." + text = text[: max(0, max_length - len(suffix))] + suffix return text diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index b620ac8dbf..b3f5db985e 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -281,10 +281,9 @@ async def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertEqual(len(spans), 1) query_text = spans[0].attributes["db.query.text"] # The oversized field value must be truncated at the field level (not - # just a blind cut of the fully-serialized string), keeping the result - # close to the configured bound; a "..." marker signals the (possible) - # safety-net cut, mirroring the driver's existing log-truncation approach. - self.assertLessEqual(len(query_text), 200 + len("...")) + # just a blind cut of the fully-serialized string), and the result must + # never exceed the configured bound, even when a "..." marker is added. + self.assertLessEqual(len(query_text), 200) self.assertNotIn("a" * 500, query_text) diff --git a/test/test_otel.py b/test/test_otel.py index e01a6313c8..d83d207e34 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -275,10 +275,9 @@ def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertEqual(len(spans), 1) query_text = spans[0].attributes["db.query.text"] # The oversized field value must be truncated at the field level (not - # just a blind cut of the fully-serialized string), keeping the result - # close to the configured bound; a "..." marker signals the (possible) - # safety-net cut, mirroring the driver's existing log-truncation approach. - self.assertLessEqual(len(query_text), 200 + len("...")) + # just a blind cut of the fully-serialized string), and the result must + # never exceed the configured bound, even when a "..." marker is added. + self.assertLessEqual(len(query_text), 200) self.assertNotIn("a" * 500, query_text) From e3d6c1c6a75fd67d55201f4b8430399e03e347cc Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 15:29:58 -0500 Subject: [PATCH 16/23] PYTHON-5945 Note follow-up work needed in the otel prose tests and validator tests - test_prose_1/test_prose_2 in test_otel.py: once PYTHON-5947 adds operation spans, these need to account for the outer operation span appearing alongside the command span (span counts and name-based lookups will need to disambiguate between the two). - TestValidateTracingOrNone in test_common.py: superseded once the unified test format's expectTracingMessages/observeTracingMessages tests exercise this validator indirectly through real client construction. --- test/asynchronous/test_common.py | 3 +++ test/asynchronous/test_otel.py | 7 +++++++ test/test_common.py | 3 +++ test/test_otel.py | 7 +++++++ 4 files changed, 20 insertions(+) diff --git a/test/asynchronous/test_common.py b/test/asynchronous/test_common.py index ca03141c63..89468dbd83 100644 --- a/test/asynchronous/test_common.py +++ b/test/asynchronous/test_common.py @@ -182,6 +182,9 @@ async def test_validate_boolean(self): await self.db.test.update_one({}, {"$set": {"total": 1}}, {"upsert": True}) # type: ignore +# TODO(PYTHON-5947): superseded once the unified test format's +# expectTracingMessages/observeTracingMessages tests exercise this validator +# indirectly through real client construction; remove this class then. class TestValidateTracingOrNone(unittest.TestCase): def test_none(self): self.assertIsNone(common.validate_tracing_or_none("tracing", None)) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index b3f5db985e..8f179d52dc 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -200,6 +200,9 @@ async def test_tracing_disabled_by_default(self): await client.admin.command("ping") self.assertEqual(self.spans(), []) + # TODO(PYTHON-5947): once operation spans exist, also assert that the + # "ping" *operation* span (not just the command span) is absent/present + # here, and that self.spans() counts both. async def test_prose_1_tracing_enable_disable_via_env_var(self): """Prose Test 1: Tracing Enable/Disable via Environment Variable.""" with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "false"}): @@ -214,6 +217,10 @@ async def test_prose_1_tracing_enable_disable_via_env_var(self): await client.admin.command("ping") self.assertIn("ping", [s.name for s in self.spans()]) + # TODO(PYTHON-5947): once operation spans exist, self.spans("find") will + # also match the outer find *operation* span; disambiguate (e.g. by + # db.command.name vs db.operation.name) so this only asserts on the + # command span's db.query.text attribute. async def test_prose_2_command_payload_emission_via_env_var(self): """Prose Test 2: Command Payload Emission via Environment Variable.""" env = { diff --git a/test/test_common.py b/test/test_common.py index ca39e21c6b..b8049e3b73 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -180,6 +180,9 @@ def test_validate_boolean(self): self.db.test.update_one({}, {"$set": {"total": 1}}, {"upsert": True}) # type: ignore +# TODO(PYTHON-5947): superseded once the unified test format's +# expectTracingMessages/observeTracingMessages tests exercise this validator +# indirectly through real client construction; remove this class then. class TestValidateTracingOrNone(unittest.TestCase): def test_none(self): self.assertIsNone(common.validate_tracing_or_none("tracing", None)) diff --git a/test/test_otel.py b/test/test_otel.py index d83d207e34..e3a907ad14 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -200,6 +200,9 @@ def test_tracing_disabled_by_default(self): client.admin.command("ping") self.assertEqual(self.spans(), []) + # TODO(PYTHON-5947): once operation spans exist, also assert that the + # "ping" *operation* span (not just the command span) is absent/present + # here, and that self.spans() counts both. def test_prose_1_tracing_enable_disable_via_env_var(self): """Prose Test 1: Tracing Enable/Disable via Environment Variable.""" with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "false"}): @@ -214,6 +217,10 @@ def test_prose_1_tracing_enable_disable_via_env_var(self): client.admin.command("ping") self.assertIn("ping", [s.name for s in self.spans()]) + # TODO(PYTHON-5947): once operation spans exist, self.spans("find") will + # also match the outer find *operation* span; disambiguate (e.g. by + # db.command.name vs db.operation.name) so this only asserts on the + # command span's db.query.text attribute. def test_prose_2_command_payload_emission_via_env_var(self): """Prose Test 2: Command Payload Emission via Environment Variable.""" env = { From 89a084665ca7b96cbb020f7bfdd883477f80ff25 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 15:46:21 -0500 Subject: [PATCH 17/23] PYTHON-5945 Move TestValidateTracingOrNone into test_otel.py Colocate the temporary tracing-option validator tests with the rest of the otel-specific, PYTHON-5947-superseded test surface instead of test_common.py's general validator coverage. Note this moves them behind the "otel" pytest marker, so they now run alongside the other otel tests rather than under the default suite. --- test/asynchronous/test_common.py | 53 +------------------------------- test/asynchronous/test_otel.py | 53 +++++++++++++++++++++++++++++++- test/test_common.py | 53 +------------------------------- test/test_otel.py | 53 +++++++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 106 deletions(-) diff --git a/test/asynchronous/test_common.py b/test/asynchronous/test_common.py index 89468dbd83..8440ca98dc 100644 --- a/test/asynchronous/test_common.py +++ b/test/asynchronous/test_common.py @@ -24,8 +24,7 @@ from bson.binary import PYTHON_LEGACY, STANDARD, Binary, UuidRepresentation from bson.codec_options import CodecOptions from bson.objectid import ObjectId -from pymongo import common -from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.errors import OperationFailure from pymongo.write_concern import WriteConcern from test.asynchronous import AsyncIntegrationTest, async_client_context, connected, unittest @@ -182,55 +181,5 @@ async def test_validate_boolean(self): await self.db.test.update_one({}, {"$set": {"total": 1}}, {"upsert": True}) # type: ignore -# TODO(PYTHON-5947): superseded once the unified test format's -# expectTracingMessages/observeTracingMessages tests exercise this validator -# indirectly through real client construction; remove this class then. -class TestValidateTracingOrNone(unittest.TestCase): - def test_none(self): - self.assertIsNone(common.validate_tracing_or_none("tracing", None)) - - def test_defaults(self): - self.assertEqual( - common.validate_tracing_or_none("tracing", {}), - {"enabled": False, "query_text_max_length": None}, - ) - - def test_enabled_and_query_text_max_length(self): - self.assertEqual( - common.validate_tracing_or_none( - "tracing", {"enabled": True, "query_text_max_length": 500} - ), - {"enabled": True, "query_text_max_length": 500}, - ) - - def test_explicit_zero_query_text_max_length_preserved(self): - # 0 must stay distinct from "unset" (None) so it can override the - # environment variable instead of being treated as not configured. - result = common.validate_tracing_or_none( - "tracing", {"enabled": True, "query_text_max_length": 0} - ) - self.assertEqual(result["query_text_max_length"], 0) - - def test_rejects_non_mapping(self): - with self.assertRaises(TypeError): - common.validate_tracing_or_none("tracing", "enabled") - - def test_rejects_unknown_option(self): - with self.assertRaisesRegex(ConfigurationError, "Unknown tracing option"): - common.validate_tracing_or_none("tracing", {"bogus": True}) - - def test_rejects_non_boolean_enabled(self): - with self.assertRaises(TypeError): - common.validate_tracing_or_none("tracing", {"enabled": "yes"}) - - def test_rejects_non_integer_query_text_max_length(self): - with self.assertRaises(TypeError): - common.validate_tracing_or_none("tracing", {"query_text_max_length": [1]}) - - def test_rejects_negative_query_text_max_length(self): - with self.assertRaises(ValueError): - common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) - - if __name__ == "__main__": unittest.main() diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 8f179d52dc..a5eb5a4d8e 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -26,7 +26,8 @@ import pytest import pymongo._otel as _otel -from pymongo.errors import OperationFailure +from pymongo import common +from pymongo.errors import ConfigurationError, OperationFailure from pymongo.typings import _Address from test.asynchronous import AsyncIntegrationTest, unittest @@ -294,5 +295,55 @@ async def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertNotIn("a" * 500, query_text) +# TODO(PYTHON-5947): superseded once the unified test format's +# expectTracingMessages/observeTracingMessages tests exercise this validator +# indirectly through real client construction; remove this class then. +class TestValidateTracingOrNone(unittest.TestCase): + def test_none(self): + self.assertIsNone(common.validate_tracing_or_none("tracing", None)) + + def test_defaults(self): + self.assertEqual( + common.validate_tracing_or_none("tracing", {}), + {"enabled": False, "query_text_max_length": None}, + ) + + def test_enabled_and_query_text_max_length(self): + self.assertEqual( + common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 500} + ), + {"enabled": True, "query_text_max_length": 500}, + ) + + def test_explicit_zero_query_text_max_length_preserved(self): + # 0 must stay distinct from "unset" (None) so it can override the + # environment variable instead of being treated as not configured. + result = common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 0} + ) + self.assertEqual(result["query_text_max_length"], 0) + + def test_rejects_non_mapping(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", "enabled") + + def test_rejects_unknown_option(self): + with self.assertRaisesRegex(ConfigurationError, "Unknown tracing option"): + common.validate_tracing_or_none("tracing", {"bogus": True}) + + def test_rejects_non_boolean_enabled(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"enabled": "yes"}) + + def test_rejects_non_integer_query_text_max_length(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": [1]}) + + def test_rejects_negative_query_text_max_length(self): + with self.assertRaises(ValueError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_common.py b/test/test_common.py index b8049e3b73..b0c706b9f2 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -24,8 +24,7 @@ from bson.binary import PYTHON_LEGACY, STANDARD, Binary, UuidRepresentation from bson.codec_options import CodecOptions from bson.objectid import ObjectId -from pymongo import common -from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.errors import OperationFailure from pymongo.write_concern import WriteConcern from test import IntegrationTest, client_context, connected, unittest @@ -180,55 +179,5 @@ def test_validate_boolean(self): self.db.test.update_one({}, {"$set": {"total": 1}}, {"upsert": True}) # type: ignore -# TODO(PYTHON-5947): superseded once the unified test format's -# expectTracingMessages/observeTracingMessages tests exercise this validator -# indirectly through real client construction; remove this class then. -class TestValidateTracingOrNone(unittest.TestCase): - def test_none(self): - self.assertIsNone(common.validate_tracing_or_none("tracing", None)) - - def test_defaults(self): - self.assertEqual( - common.validate_tracing_or_none("tracing", {}), - {"enabled": False, "query_text_max_length": None}, - ) - - def test_enabled_and_query_text_max_length(self): - self.assertEqual( - common.validate_tracing_or_none( - "tracing", {"enabled": True, "query_text_max_length": 500} - ), - {"enabled": True, "query_text_max_length": 500}, - ) - - def test_explicit_zero_query_text_max_length_preserved(self): - # 0 must stay distinct from "unset" (None) so it can override the - # environment variable instead of being treated as not configured. - result = common.validate_tracing_or_none( - "tracing", {"enabled": True, "query_text_max_length": 0} - ) - self.assertEqual(result["query_text_max_length"], 0) - - def test_rejects_non_mapping(self): - with self.assertRaises(TypeError): - common.validate_tracing_or_none("tracing", "enabled") - - def test_rejects_unknown_option(self): - with self.assertRaisesRegex(ConfigurationError, "Unknown tracing option"): - common.validate_tracing_or_none("tracing", {"bogus": True}) - - def test_rejects_non_boolean_enabled(self): - with self.assertRaises(TypeError): - common.validate_tracing_or_none("tracing", {"enabled": "yes"}) - - def test_rejects_non_integer_query_text_max_length(self): - with self.assertRaises(TypeError): - common.validate_tracing_or_none("tracing", {"query_text_max_length": [1]}) - - def test_rejects_negative_query_text_max_length(self): - with self.assertRaises(ValueError): - common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) - - if __name__ == "__main__": unittest.main() diff --git a/test/test_otel.py b/test/test_otel.py index e3a907ad14..e651183e3d 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -26,7 +26,8 @@ import pytest import pymongo._otel as _otel -from pymongo.errors import OperationFailure +from pymongo import common +from pymongo.errors import ConfigurationError, OperationFailure from pymongo.typings import _Address from test import IntegrationTest, unittest @@ -288,5 +289,55 @@ def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertNotIn("a" * 500, query_text) +# TODO(PYTHON-5947): superseded once the unified test format's +# expectTracingMessages/observeTracingMessages tests exercise this validator +# indirectly through real client construction; remove this class then. +class TestValidateTracingOrNone(unittest.TestCase): + def test_none(self): + self.assertIsNone(common.validate_tracing_or_none("tracing", None)) + + def test_defaults(self): + self.assertEqual( + common.validate_tracing_or_none("tracing", {}), + {"enabled": False, "query_text_max_length": None}, + ) + + def test_enabled_and_query_text_max_length(self): + self.assertEqual( + common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 500} + ), + {"enabled": True, "query_text_max_length": 500}, + ) + + def test_explicit_zero_query_text_max_length_preserved(self): + # 0 must stay distinct from "unset" (None) so it can override the + # environment variable instead of being treated as not configured. + result = common.validate_tracing_or_none( + "tracing", {"enabled": True, "query_text_max_length": 0} + ) + self.assertEqual(result["query_text_max_length"], 0) + + def test_rejects_non_mapping(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", "enabled") + + def test_rejects_unknown_option(self): + with self.assertRaisesRegex(ConfigurationError, "Unknown tracing option"): + common.validate_tracing_or_none("tracing", {"bogus": True}) + + def test_rejects_non_boolean_enabled(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"enabled": "yes"}) + + def test_rejects_non_integer_query_text_max_length(self): + with self.assertRaises(TypeError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": [1]}) + + def test_rejects_negative_query_text_max_length(self): + with self.assertRaises(ValueError): + common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) + + if __name__ == "__main__": unittest.main() From 2801d222b23888a32a69692d803b489ce54346be Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 21 Jul 2026 19:10:24 -0500 Subject: [PATCH 18/23] Fix handling of unix sockets Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pymongo/_otel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index ef3f9e5c5d..3eb034253c 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -206,13 +206,14 @@ def start_command_span( collection = _extract_collection_name(command_name, dbname, cmd) address = conn.address + transport = "unix" if address[1] is None else "tcp" attributes: dict[str, Any] = { "db.system.name": "mongodb", "db.namespace": dbname, "db.command.name": command_name, "db.query.summary": _build_query_summary(command_name, dbname, collection), "server.address": address[0], - "network.transport": "unix" if address[0].endswith(".sock") else "tcp", + "network.transport": transport, "db.mongodb.driver_connection_id": conn.id, } # Unix domain socket addresses have no port. From df512e2e7c9c04dc471d9f96f8b1048babea28fa Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 22 Jul 2026 06:10:52 -0500 Subject: [PATCH 19/23] PYTHON-5945 Fix issues flagged by Copilot review - network.transport: derive from address[1] is None (the same signal _Address already uses for "no port"), not a ".sock" suffix check on the host string, which can misclassify. - test_otel.py: guard opentelemetry.sdk imports with their own try/except, since opentelemetry-api can be installed without opentelemetry-sdk (that's the actual shape of the pymongo[opentelemetry] extra). Gate the test class on a separate _HAS_OTEL_TEST_DEPS flag so that case is skipped instead of crashing test collection. - validate_tracing_or_none: reject bool explicitly for query_text_max_length, since bool is an int subclass and would otherwise silently pass validation as 0/1. - _build_query_text: pass default=repr to json_util.dumps so tracing stays best-effort and doesn't raise for commands containing custom/codec-managed Python types. - start_command_span: guard lsid with isinstance(..., Mapping) before formatting it, so a malformed raw command can't crash span creation. --- pymongo/_otel.py | 10 ++++++---- pymongo/common.py | 4 ++++ test/asynchronous/test_otel.py | 16 +++++++++++----- test/test_otel.py | 16 +++++++++++----- 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index ef3f9e5c5d..95969982d3 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -132,7 +132,9 @@ def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: """ filtered = {k: v for k, v in cmd.items() if k not in _QUERY_TEXT_EXCLUDED_FIELDS} truncated_cmd = _truncate_documents(filtered, max_length)[0] - text = json_util.dumps(truncated_cmd, json_options=_JSON_OPTIONS) + # default=repr mirrors the structured logger: tracing is best-effort and must + # not raise for commands containing custom/codec-managed Python types. + text = json_util.dumps(truncated_cmd, json_options=_JSON_OPTIONS, default=repr) if len(text) > max_length: suffix = "..." text = text[: max(0, max_length - len(suffix))] + suffix @@ -212,10 +214,10 @@ def start_command_span( "db.command.name": command_name, "db.query.summary": _build_query_summary(command_name, dbname, collection), "server.address": address[0], - "network.transport": "unix" if address[0].endswith(".sock") else "tcp", + # Unix domain socket addresses have no port; _Address encodes that as None. + "network.transport": "unix" if address[1] is None else "tcp", "db.mongodb.driver_connection_id": conn.id, } - # Unix domain socket addresses have no port. if address[1] is not None: attributes["server.port"] = address[1] if collection: @@ -223,7 +225,7 @@ def start_command_span( if conn.server_connection_id is not None: attributes["db.mongodb.server_connection_id"] = conn.server_connection_id lsid = cmd.get("lsid") - if lsid: + if isinstance(lsid, Mapping): formatted_lsid = _format_lsid(lsid) if formatted_lsid is not None: attributes["db.mongodb.lsid"] = formatted_lsid diff --git a/pymongo/common.py b/pymongo/common.py index bf9a85620a..053ddded76 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -620,6 +620,10 @@ def validate_tracing_or_none(option: str, value: Any) -> Optional[_otel.TracingO validate_boolean("tracing.enabled", enabled) query_text_max_length = value.get("query_text_max_length") if query_text_max_length is not None: + # bool is a subclass of int; reject it explicitly rather than silently + # treating True/False as 1/0. + if isinstance(query_text_max_length, bool): + raise TypeError("tracing.query_text_max_length must be an integer, not a boolean") validate_non_negative_integer("tracing.query_text_max_length", query_text_max_length) return {"enabled": enabled, "query_text_max_length": query_text_max_length} diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index a5eb5a4d8e..7dacc19969 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -31,11 +31,17 @@ from pymongo.typings import _Address from test.asynchronous import AsyncIntegrationTest, unittest +_HAS_OTEL_TEST_DEPS = False if _otel._HAS_OPENTELEMETRY: - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + _HAS_OTEL_TEST_DEPS = True + except ImportError: + pass _IS_SYNC = False @@ -57,7 +63,7 @@ def _shared_test_provider() -> TracerProvider: return provider -@unittest.skipUnless(_otel._HAS_OPENTELEMETRY, "opentelemetry is not installed") +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") class TestOTelSpans(AsyncIntegrationTest): @classmethod def setUpClass(cls): diff --git a/test/test_otel.py b/test/test_otel.py index e651183e3d..7e5ca3439f 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -31,11 +31,17 @@ from pymongo.typings import _Address from test import IntegrationTest, unittest +_HAS_OTEL_TEST_DEPS = False if _otel._HAS_OPENTELEMETRY: - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + _HAS_OTEL_TEST_DEPS = True + except ImportError: + pass _IS_SYNC = True @@ -57,7 +63,7 @@ def _shared_test_provider() -> TracerProvider: return provider -@unittest.skipUnless(_otel._HAS_OPENTELEMETRY, "opentelemetry is not installed") +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") class TestOTelSpans(IntegrationTest): @classmethod def setUpClass(cls): From bf54c311573cb0467cdad8a6d8f99c87c1cedf15 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 22 Jul 2026 10:30:30 -0500 Subject: [PATCH 20/23] PYTHON-5945 Re-apply the changelog entry and MongoClient docstring for tracing Now that this PR targets the shared "otel" integration branch rather than main directly, the command-span feature doesn't need to wait for PYTHON-5947 to be documented - restore the changelog entry and the MongoClient/AsyncMongoClient tracing option docstring that were previously deferred. This reverts commits 4fb56bb5 and 1450f05a. --- doc/changelog.rst | 6 ++++++ pymongo/asynchronous/mongo_client.py | 19 +++++++++++++++++++ pymongo/synchronous/mongo_client.py | 19 +++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/doc/changelog.rst b/doc/changelog.rst index aa4feb0af9..de33220b31 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -17,6 +17,12 @@ Changes in Version 4.18.0 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 + `OpenTelemetry driver specification `_. + 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. Changes in Version 4.17.0 (2026/04/20) -------------------------------------- diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index cd1cefea13..90151acf3e 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -615,8 +615,27 @@ def __init__( If enabled, server overload errors will cause retry attempts to select a server that has not yet returned an overload error, if possible. Defaults to ``False``. + | **OpenTelemetry options:** + | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) + + - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: + + - ``enabled``: (boolean) Whether to create spans for server commands issued by + this client. Defaults to ``False``. Also controlled by the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either + being enabled is sufficient. + - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` + span attribute. Unset by default, which defers to the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment + variable (itself defaulting to ``0``, which omits the attribute). Setting + this explicitly, including to ``0``, always overrides the environment + variable. + .. seealso:: The MongoDB documentation on `connections `_. + .. versionchanged:: 4.18 + Added the ``tracing`` keyword argument. + .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index d3f6a1e217..4f01e03a7e 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -616,8 +616,27 @@ def __init__( If enabled, server overload errors will cause retry attempts to select a server that has not yet returned an overload error, if possible. Defaults to ``False``. + | **OpenTelemetry options:** + | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) + + - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: + + - ``enabled``: (boolean) Whether to create spans for server commands issued by + this client. Defaults to ``False``. Also controlled by the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either + being enabled is sufficient. + - ``query_text_max_length``: (int) The maximum length of the ``db.query.text`` + span attribute. Unset by default, which defers to the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment + variable (itself defaulting to ``0``, which omits the attribute). Setting + this explicitly, including to ``0``, always overrides the environment + variable. + .. seealso:: The MongoDB documentation on `connections `_. + .. versionchanged:: 4.18 + Added the ``tracing`` keyword argument. + .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. From 936cf7e942cef01d2b33642a569badd3c57f57a9 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 27 Jul 2026 09:23:12 -0500 Subject: [PATCH 21/23] PYTHON-5945 Cache the OpenTelemetry tracer instead of fetching it per command trace.get_tracer() was being called on every command span. Even on an SDK cache hit this allocates two objects, takes a process-wide lock, and mutates the global warnings filter list, ~300x slower than a cached attribute read. Caching at import time is safe because get_tracer() returns a self-updating ProxyTracer when no real TracerProvider is registered yet. --- pymongo/_otel.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 6c01b22d51..713f963bbc 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -35,8 +35,15 @@ from opentelemetry.trace import SpanKind, Status, StatusCode _HAS_OPENTELEMETRY = True + # Safe to cache at import time: opentelemetry.trace.get_tracer() returns a + # ProxyTracer when no real TracerProvider is registered yet, and that proxy + # transparently starts delegating to the real tracer once the application + # calls trace.set_tracer_provider() later, so this doesn't bind us to a + # permanently-inert no-op tracer. + _TRACER: Optional[Tracer] = trace.get_tracer("PyMongo", __version__) except ImportError: _HAS_OPENTELEMETRY = False + _TRACER = None if TYPE_CHECKING: from opentelemetry.trace import Span, Tracer @@ -99,11 +106,6 @@ def _is_tracing_enabled(tracing_options: Optional[TracingOptions]) -> bool: return _env_truthy(_OTEL_ENABLED_ENV) -def _get_tracer() -> Tracer: - """Return a Tracer scoped to this driver's name and version.""" - return trace.get_tracer("PyMongo", __version__) - - def _get_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int: """Return the configured db.query.text truncation length, or 0 to omit the attribute. @@ -236,8 +238,8 @@ def start_command_span( if max_query_text_length > 0: attributes["db.query.text"] = _build_query_text(cmd, max_query_text_length) - tracer = _get_tracer() - return tracer.start_span(command_name, kind=SpanKind.CLIENT, attributes=attributes) + assert _TRACER is not None # _is_tracing_enabled already checked _HAS_OPENTELEMETRY + return _TRACER.start_span(command_name, kind=SpanKind.CLIENT, attributes=attributes) def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None: From d3071db03696cabe4fe35f65abc53068681fa44a Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 27 Jul 2026 09:38:21 -0500 Subject: [PATCH 22/23] PYTHON-5945 Add regression test for OpenTelemetry tracer caching Locks in that pymongo._otel only calls opentelemetry's get_tracer() once, at import, rather than per command. --- test/asynchronous/test_otel.py | 33 +++++++++++++++++++++++++++++++++ test/test_otel.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 7dacc19969..f0062422f4 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -351,5 +351,38 @@ def test_rejects_negative_query_text_max_length(self): common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) +class TestOTelTracerCaching(unittest.TestCase): + """Regression test for the tracer-caching fix in ``pymongo/_otel.py``. + + ``opentelemetry.trace.get_tracer()`` must only be called once, at import + time (cached as module-level ``_otel._TRACER``). Calling it per command + allocates two objects, takes a process-wide lock, and mutates the global + ``warnings`` filter list on every call, even on a cache hit. + """ + + @unittest.skipUnless(_otel._HAS_OPENTELEMETRY, "opentelemetry is not installed") + def test_start_command_span_does_not_call_get_tracer(self): + class _FakeConn: + id = 1 + server_connection_id: Optional[int] = None + address: _Address = ("localhost", 27017) + service_id = None + + with patch.object(_otel, "trace") as mock_trace: + for _ in range(3): + span = _otel.start_command_span( + {"enabled": True, "query_text_max_length": None}, + _FakeConn(), + {"ping": 1}, + "admin", + "ping", + False, + ) + self.assertIsNotNone(span) + span.end() + + mock_trace.get_tracer.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/test/test_otel.py b/test/test_otel.py index 7e5ca3439f..44d8227265 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -345,5 +345,38 @@ def test_rejects_negative_query_text_max_length(self): common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) +class TestOTelTracerCaching(unittest.TestCase): + """Regression test for the tracer-caching fix in ``pymongo/_otel.py``. + + ``opentelemetry.trace.get_tracer()`` must only be called once, at import + time (cached as module-level ``_otel._TRACER``). Calling it per command + allocates two objects, takes a process-wide lock, and mutates the global + ``warnings`` filter list on every call, even on a cache hit. + """ + + @unittest.skipUnless(_otel._HAS_OPENTELEMETRY, "opentelemetry is not installed") + def test_start_command_span_does_not_call_get_tracer(self): + class _FakeConn: + id = 1 + server_connection_id: Optional[int] = None + address: _Address = ("localhost", 27017) + service_id = None + + with patch.object(_otel, "trace") as mock_trace: + for _ in range(3): + span = _otel.start_command_span( + {"enabled": True, "query_text_max_length": None}, + _FakeConn(), + {"ping": 1}, + "admin", + "ping", + False, + ) + self.assertIsNotNone(span) + span.end() + + mock_trace.get_tracer.assert_not_called() + + if __name__ == "__main__": unittest.main() From f273c945ef12439edc7bfee41a2c363ef35f73f3 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 27 Jul 2026 09:40:59 -0500 Subject: [PATCH 23/23] PYTHON-5945 Fix wording in tracer-caching regression test docstring --- test/asynchronous/test_otel.py | 2 +- test/test_otel.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index f0062422f4..7eaeafe734 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -352,7 +352,7 @@ def test_rejects_negative_query_text_max_length(self): class TestOTelTracerCaching(unittest.TestCase): - """Regression test for the tracer-caching fix in ``pymongo/_otel.py``. + """Regression test for the tracer-caching implementation in ``pymongo/_otel.py``. ``opentelemetry.trace.get_tracer()`` must only be called once, at import time (cached as module-level ``_otel._TRACER``). Calling it per command diff --git a/test/test_otel.py b/test/test_otel.py index 44d8227265..d0e3a55fe5 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -346,7 +346,7 @@ def test_rejects_negative_query_text_max_length(self): class TestOTelTracerCaching(unittest.TestCase): - """Regression test for the tracer-caching fix in ``pymongo/_otel.py``. + """Regression test for the tracer-caching implementation in ``pymongo/_otel.py``. ``opentelemetry.trace.get_tracer()`` must only be called once, at import time (cached as module-level ``_otel._TRACER``). Calling it per command