From 1ee9e2be8904a718076d155b7f97e3a65a48c0e5 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Mon, 27 Jul 2026 14:57:13 -0500 Subject: [PATCH 1/2] PYTHON-5945 Add OpenTelemetry Simple Command Support --- .evergreen/generated_configs/variants.yml | 12 + .evergreen/scripts/generate_config.py | 15 + .evergreen/scripts/setup_tests.py | 11 +- .evergreen/scripts/utils.py | 1 + doc/changelog.rst | 6 + justfile | 2 +- pymongo/_otel.py | 268 +++++++++++++++ pymongo/_telemetry.py | 38 ++- pymongo/asynchronous/command_runner.py | 28 +- pymongo/asynchronous/mongo_client.py | 19 ++ pymongo/client_options.py | 13 + pymongo/common.py | 22 ++ pymongo/pool_shared.py | 2 +- pymongo/synchronous/command_runner.py | 28 +- pymongo/synchronous/mongo_client.py | 19 ++ pyproject.toml | 2 + requirements/opentelemetry.txt | 1 + test/asynchronous/test_otel.py | 388 ++++++++++++++++++++++ test/test_otel.py | 382 +++++++++++++++++++++ uv.lock | 340 ++++++++++--------- 20 files changed, 1421 insertions(+), 176 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/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 26027888fe..b9b054bda0 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -468,6 +468,18 @@ 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: + - rhel8.10-small + expansions: + TEST_NAME: otel + COVERAGE: "1" + tags: [pr] + # Perf tests - name: performance-benchmarks tasks: diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 97fd9b0176..a3979472e4 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -513,6 +513,21 @@ def create_doctests_variants(): ] +def create_otel_variants(): + host = DEFAULT_HOST + # 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"], + get_variant_name("OTel", host), + host=host, + tags=["pr"], + 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 5a3f441f58..4f6833e289 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", } @@ -502,6 +503,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(): @@ -523,9 +529,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") diff --git a/.evergreen/scripts/utils.py b/.evergreen/scripts/utils.py index a7decdfaa8..880e28cec1 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", "sfp": "sfp", "numpy": "", diff --git a/doc/changelog.rst b/doc/changelog.rst index 08d3908e62..dde6388937 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -152,6 +152,12 @@ PyMongo 4.18 brings a number of changes including: :meth:`~pymongo.synchronous.database.Database.aggregate`, and :meth:`~pymongo.asynchronous.collection.AsyncCollection.list_search_indexes` and :meth:`~pymongo.synchronous.collection.Collection.list_search_indexes`. +- 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/justfile b/justfile index df678cdbac..b6156fe72c 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ set shell := ["bash", "-c"] # 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/pymongo/_otel.py b/pymongo/_otel.py new file mode 100644 index 0000000000..713f963bbc --- /dev/null +++ b/pymongo/_otel.py @@ -0,0 +1,268 @@ +# 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. + +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 bson.json_util import _truncate_documents +from pymongo._version import __version__ +from pymongo.logger import _HELLO_COMMANDS, _JSON_OPTIONS, _SENSITIVE_COMMANDS + +try: + from opentelemetry import trace + 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 + + from pymongo.pool_shared import _ConnectionTelemetryInfo + from pymongo.typings import _DocumentOut + + +class TracingOptions(TypedDict): + """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: Optional[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" + +# 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" + + +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 + + +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_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int: + """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: + return 0 + + +def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: + """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 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] + # 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 + return text + + +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 + 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 + + +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}" + + +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]: + """Return the ``db.mongodb.lsid`` attribute value for a session id document.""" + 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_command_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). + """ + 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, 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": transport, + "db.mongodb.driver_connection_id": conn.id, + } + 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: + attributes["db.mongodb.server_connection_id"] = conn.server_connection_id + lsid = cmd.get("lsid") + if isinstance(lsid, Mapping): + 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) + + 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: + """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_command_span_failure( + span: Optional[Span], + failure: _DocumentOut, + exc: BaseException, +) -> None: + """Record the exception, set the error status, and end the span.""" + if span is None: + return + 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 499309b336..84b11a7408 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -22,7 +22,8 @@ from collections.abc import MutableMapping from typing import TYPE_CHECKING, Any, Optional -from pymongo import _op_id +from pymongo import _op_id, _otel +from pymongo.errors import OperationFailure from pymongo.logger import ( _COMMAND_LOGGER, _CONNECTION_LOGGER, @@ -94,8 +95,12 @@ class _CommandTelemetry: "_publish", "_request_id", "_should_log", + "_span", + "_speculative_hello", "_start", "_topology_id", + "_tracing_enabled", + "_tracing_options", ) def __init__( @@ -107,13 +112,18 @@ def __init__( dbname: str, request_id: int, op_id: Optional[int], + tracing_options: Optional[_otel.TracingOptions] = None, + speculative_hello: bool = False, name: Optional[str] = None, ) -> None: # NOTE: the _run_command fast path in command_runner.py inline this gate for performance # They must be kept in sync with any gating changes self._should_log = topology_id is not None and _is_debug_enabled(_COMMAND_LOGGER) 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._span: Optional[Any] = None + self._active = self._should_log or self._publish or self._tracing_enabled self._start = 0.0 self._duration_s = 0.0 if not self._active: @@ -126,6 +136,7 @@ def __init__( self._dbname = dbname self._request_id = request_id self._op_id = op_id if op_id is not None else _op_id.OP_ID.get() + self._speculative_hello = speculative_hello def _emit_log(self, message: _CommandStatusMessage, **extra: Any) -> None: _debug_log( @@ -145,7 +156,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 = time.monotonic() if not self._active: return @@ -164,6 +175,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_command_span( + self._tracing_options, + self._conn, + self._cmd, + self._dbname, + self._name, + self._speculative_hello, + ) @property def duration_s(self) -> float: @@ -176,7 +196,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_s = _monotonic_duration(self._start) if not self._active: return @@ -202,14 +222,16 @@ def succeeded( speculative_hello=speculative_hello, database_name=self._dbname, ) + if self._span is not None: + _otel.end_command_span_success(self._span, reply) def failed( self, failure: _DocumentOut, command_name: str, - is_server_side_error: bool, + exc: BaseException, ) -> None: - """Emit the FAILED log entry and APM event.""" + """Emit the FAILED log entry and APM event, and end the span.""" self._duration_s = _monotonic_duration(self._start) if not self._active: return @@ -219,7 +241,7 @@ def failed( _CommandStatusMessage.FAILED, durationMS=duration, failure=failure, - isServerSideError=is_server_side_error, + isServerSideError=isinstance(exc, OperationFailure), ) if self._publish: assert self._listeners is not None @@ -234,6 +256,8 @@ def failed( service_id=self._conn.service_id, database_name=self._dbname, ) + if self._span is not None: + _otel.end_command_span_failure(self._span, failure, exc) class _CmapTelemetry: diff --git a/pymongo/asynchronous/command_runner.py b/pymongo/asynchronous/command_runner.py index aa0839fa4a..f13d893dbf 100644 --- a/pymongo/asynchronous/command_runner.py +++ b/pymongo/asynchronous/command_runner.py @@ -48,7 +48,7 @@ ) from bson import _decode_all_selective -from pymongo import _csot, helpers_shared, message +from pymongo import _csot, _otel, helpers_shared, message from pymongo._telemetry import _CommandTelemetry from pymongo.compression_support import _NO_COMPRESSION from pymongo.errors import NotPrimaryError, OperationFailure @@ -163,14 +163,28 @@ async def _run_command( if orig is None: orig = cmd - # Fast path: skip telemetry construction when logging and APM are disabled - # Inline enabled check here for performance + # Fast path: skip telemetry construction when logging, APM, and tracing are all + # disabled. Inline enabled check here for performance; it must stay in sync with + # _CommandTelemetry.__init__'s own gate, including its tracing term, or a + # tracing-only client would build no telemetry and so emit no command spans. + tracing_options = client.options.tracing if client is not None else None telemetry: Optional[_CommandTelemetry] = None - if (topology_id is not None and _is_debug_enabled(_COMMAND_LOGGER)) or ( - listeners is not None and listeners.enabled_for_commands + if ( + (topology_id is not None and _is_debug_enabled(_COMMAND_LOGGER)) + or (listeners is not None and listeners.enabled_for_commands) + or _otel._is_tracing_enabled(tracing_options) ): telemetry = _CommandTelemetry( - topology_id, conn, listeners, cmd, dbname, request_id, op_id, name=name + topology_id, + conn, + listeners, + cmd, + dbname, + request_id, + op_id, + tracing_options=tracing_options, + speculative_hello=speculative_hello, + name=name, ) telemetry.started(orig, ensure_db) start = 0.0 @@ -224,7 +238,7 @@ async def _run_command( else: failure = _convert_exception(exc) if telemetry is not None: - telemetry.failed(failure, command_name, isinstance(exc, OperationFailure)) + telemetry.failed(failure, command_name, exc) raise if telemetry is not None: diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index a0f1397a4d..af21740385 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -635,8 +635,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/client_options.py b/pymongo/client_options.py index 99a2bcb4e4..d2d33f6805 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": None}, + ) @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 dc18b937e5..2b8367429a 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 @@ -610,6 +611,26 @@ 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") + 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} + + def validate_is_callable_or_none(option: Any, value: Any) -> Optional[Callable[..., Any]]: """Validates that 'value' is a callable.""" if value is None: @@ -788,6 +809,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/pool_shared.py b/pymongo/pool_shared.py index 8cd546bda6..63a71f3b79 100644 --- a/pymongo/pool_shared.py +++ b/pymongo/pool_shared.py @@ -57,7 +57,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/command_runner.py b/pymongo/synchronous/command_runner.py index 21da51e2fc..27380eb068 100644 --- a/pymongo/synchronous/command_runner.py +++ b/pymongo/synchronous/command_runner.py @@ -48,7 +48,7 @@ ) from bson import _decode_all_selective -from pymongo import _csot, helpers_shared, message +from pymongo import _csot, _otel, helpers_shared, message from pymongo._telemetry import _CommandTelemetry from pymongo.compression_support import _NO_COMPRESSION from pymongo.errors import NotPrimaryError, OperationFailure @@ -163,14 +163,28 @@ def _run_command( if orig is None: orig = cmd - # Fast path: skip telemetry construction when logging and APM are disabled - # Inline enabled check here for performance + # Fast path: skip telemetry construction when logging, APM, and tracing are all + # disabled. Inline enabled check here for performance; it must stay in sync with + # _CommandTelemetry.__init__'s own gate, including its tracing term, or a + # tracing-only client would build no telemetry and so emit no command spans. + tracing_options = client.options.tracing if client is not None else None telemetry: Optional[_CommandTelemetry] = None - if (topology_id is not None and _is_debug_enabled(_COMMAND_LOGGER)) or ( - listeners is not None and listeners.enabled_for_commands + if ( + (topology_id is not None and _is_debug_enabled(_COMMAND_LOGGER)) + or (listeners is not None and listeners.enabled_for_commands) + or _otel._is_tracing_enabled(tracing_options) ): telemetry = _CommandTelemetry( - topology_id, conn, listeners, cmd, dbname, request_id, op_id, name=name + topology_id, + conn, + listeners, + cmd, + dbname, + request_id, + op_id, + tracing_options=tracing_options, + speculative_hello=speculative_hello, + name=name, ) telemetry.started(orig, ensure_db) start = 0.0 @@ -224,7 +238,7 @@ def _run_command( else: failure = _convert_exception(exc) if telemetry is not None: - telemetry.failed(failure, command_name, isinstance(exc, OperationFailure)) + telemetry.failed(failure, command_name, exc) raise if telemetry is not None: diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 574fbc00de..1321e2139f 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -636,8 +636,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/pyproject.toml b/pyproject.toml index 2f7a68c837..fce3ad3445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,6 +103,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"] @@ -151,6 +152,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/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/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py new file mode 100644 index 0000000000..7eaeafe734 --- /dev/null +++ b/test/asynchronous/test_otel.py @@ -0,0 +1,388 @@ +# 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 typing import Optional +from unittest.mock import patch + +sys.path[0:0] = [""] + +import pytest + +import pymongo._otel as _otel +from pymongo import common +from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.typings import _Address +from test.asynchronous import AsyncIntegrationTest, unittest + +_HAS_OTEL_TEST_DEPS = False +if _otel._HAS_OPENTELEMETRY: + 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 + +pytestmark = pytest.mark.otel + + +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(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk 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] + + # 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 + 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_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_command_span( + {"enabled": True, "query_text_max_length": None}, + _FakeUnixConn(), + {"ping": 1}, + "admin", + "ping", + False, + ) + _otel.end_command_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() + 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_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() + 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(), []) + + # 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"}): + 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()]) + + # 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 = { + "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) + + # 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} + ) + 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"]) + + 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), 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) + + +# 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}) + + +class TestOTelTracerCaching(unittest.TestCase): + """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 + 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 new file mode 100644 index 0000000000..d0e3a55fe5 --- /dev/null +++ b/test/test_otel.py @@ -0,0 +1,382 @@ +# 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 typing import Optional +from unittest.mock import patch + +sys.path[0:0] = [""] + +import pytest + +import pymongo._otel as _otel +from pymongo import common +from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.typings import _Address +from test import IntegrationTest, unittest + +_HAS_OTEL_TEST_DEPS = False +if _otel._HAS_OPENTELEMETRY: + 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 + +pytestmark = pytest.mark.otel + + +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(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk 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] + + # 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 + 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_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_command_span( + {"enabled": True, "query_text_max_length": None}, + _FakeUnixConn(), + {"ping": 1}, + "admin", + "ping", + False, + ) + _otel.end_command_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() + 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_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() + 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(), []) + + # 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"}): + 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()]) + + # 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 = { + "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) + + # 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 + 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"]) + + 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), 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) + + +# 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}) + + +class TestOTelTracerCaching(unittest.TestCase): + """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 + 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/uv.lock b/uv.lock index f2ae10151d..add1dd9fdf 100644 --- a/uv.lock +++ b/uv.lock @@ -58,9 +58,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -76,9 +76,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.15'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ @@ -358,9 +358,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath" }, + { name = "s3transfer", version = "0.16.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5c6fa0bb9fd5caf865b9356411793900304328bcd0bc1eda96a32a1368a6/boto3-1.42.97.tar.gz", hash = "sha256:2833dbeda3670ea610ad48dff7d27cdc829dbbfcdfbc6b750b673948e949b6f0", size = 113217, upload-time = "2026-04-27T20:39:17.646Z" } wheels = [ @@ -376,9 +376,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" } }, + { name = "jmespath" }, + { name = "s3transfer", version = "0.19.2", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/d5/0f/a1d691e9c51ae5dce5a7394e550d7e36a995494e1c2795ce210d3520b257/boto3-1.43.94.tar.gz", hash = "sha256:aa832d88e85035cebb83f04700c65623328b88fe828ff7ed9539483bd9d608c5", size = 112663, upload-time = "2026-09-14T20:10:05.902Z" } wheels = [ @@ -394,9 +394,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "jmespath", marker = "python_full_version < '3.10'" }, - { name = "python-dateutil", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c6/95/c37edb602948fad2253ffd1bb3dba5b938645bd1845ee4160350136a0f41/botocore-1.42.97.tar.gz", hash = "sha256:5c0bb00e32d16ff6d278cc8c9e10dc3672d9c1d569031635ac3c908a60de8310", size = 15269348, upload-time = "2026-04-27T20:39:05.625Z" } wheels = [ @@ -412,9 +412,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "jmespath", marker = "python_full_version >= '3.10'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/fa/5b/5a7cc096db8ddb5f6c221112d5a4a46888d9e38c1f5d5a06ae7e3e0755f2/botocore-1.43.94.tar.gz", hash = "sha256:cb97cedec5fceee3261f7fff75d0b0aa2cf68b32eeb2e8c2649e4657419773a6", size = 16104907, upload-time = "2026-09-14T20:10:00.315Z" } wheels = [ @@ -439,7 +439,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "2.23", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -537,7 +537,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.0", source = { registry = "https://pypi.org/simple" }, marker = "implementation_name != 'PyPy'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -830,7 +830,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ @@ -976,7 +976,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "tomli" }, ] [[package]] @@ -1083,7 +1083,7 @@ wheels = [ [package.optional-dependencies] toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, + { name = "tomli", marker = "python_full_version <= '3.11'" }, ] [[package]] @@ -1353,8 +1353,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } wheels = [ @@ -1418,9 +1418,9 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation != 'PyPy'" }, { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -1520,7 +1520,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1554,10 +1554,10 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "zope-interface", version = "8.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/5c/92002455a57cb3634383e2b822e3bccf409f43cde34528e46428971475cf/gevent-26.7.0.tar.gz", hash = "sha256:5b333a556e38a302b1b8c80525bef16d437e16f1e7767947789406841856a102", size = 6729213, upload-time = "2026-07-22T20:16:04.713Z" } wheels = [ @@ -1617,10 +1617,10 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", version = "3.5.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, - { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "zope-interface", version = "8.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "cffi", version = "2.1.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", version = "3.5.6", source = { registry = "https://pypi.org/simple" }, marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event", version = "6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "zope-interface", version = "8.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/b8/eb/5f2db8013f1a4a6df2c23201f384a066f13ff5764a9f62a608c8a50ac8cc/gevent-26.8.0.tar.gz", hash = "sha256:96039f41bbde6dcd72559e5ffbd408a04f46774b47d991d4cf032da8fa79e5a0", size = 6625998, upload-time = "2026-08-10T18:02:28.038Z" } wheels = [ @@ -1903,7 +1903,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "zipp", version = "3.23.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1919,7 +1919,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "zipp", version = "4.1.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } wheels = [ @@ -2295,6 +2295,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.41.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +dependencies = [ + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -2432,6 +2465,10 @@ ocsp = [ { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "service-identity" }, ] +opentelemetry = [ + { name = "opentelemetry-api", version = "1.41.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-api", version = "1.44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] snappy = [ { name = "python-snappy" }, ] @@ -2491,6 +2528,7 @@ requires-dist = [ { name = "dnspython", specifier = ">=2.7.0,<3.0.0" }, { name = "furo", marker = "extra == 'docs'", specifier = "==2025.12.19" }, { name = "importlib-metadata", marker = "python_full_version < '3.13' and extra == 'test'", specifier = ">=7.0" }, + { name = "opentelemetry-api", marker = "extra == 'opentelemetry'", specifier = ">=1.20.0" }, { name = "pykerberos", marker = "os_name != 'nt' and extra == 'gssapi'", specifier = ">=1.2.4" }, { name = "pymongo-auth-aws", marker = "extra == 'aws'", specifier = ">=1.3.0,<2.0.0" }, { name = "pymongo-auth-aws", marker = "extra == 'encryption'", specifier = ">=1.3.0,<2.0.0" }, @@ -2508,7 +2546,7 @@ requires-dist = [ { name = "sphinxcontrib-shellcheck", marker = "extra == 'docs'", specifier = ">=1.1.2,<2" }, { name = "winkerberos", marker = "os_name == 'nt' and extra == 'gssapi'", specifier = ">=0.12.2" }, ] -provides-extras = ["aws", "docs", "encryption", "gssapi", "ocsp", "snappy", "test", "zstd"] +provides-extras = ["aws", "docs", "encryption", "gssapi", "ocsp", "opentelemetry", "snappy", "test", "zstd"] [package.metadata.requires-dev] coverage = [{ name = "coverage", extras = ["toml"], specifier = ">=5,<=7.15.0" }] @@ -2572,8 +2610,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9'" }, - { name = "typing-extensions", marker = "python_full_version <= '3.9'" }, + { name = "cryptography", version = "47.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } wheels = [ @@ -2590,8 +2628,8 @@ resolution-markers = [ "python_full_version > '3.9' and python_full_version < '3.10'", ] dependencies = [ - { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9'" }, - { name = "typing-extensions", marker = "python_full_version > '3.9' and python_full_version < '3.13'" }, + { name = "cryptography", version = "50.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } wheels = [ @@ -2620,13 +2658,13 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -2642,13 +2680,13 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -2664,9 +2702,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "backports-asyncio-runner" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } wheels = [ @@ -2682,9 +2720,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, - { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ @@ -2734,10 +2772,10 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "certifi", marker = "python_full_version < '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.10'" }, - { name = "idna", marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ @@ -2753,10 +2791,10 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.10'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.10'" }, - { name = "idna", marker = "python_full_version >= '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ @@ -2777,7 +2815,7 @@ name = "roman-numerals-py" version = "4.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "roman-numerals", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/de96fca640f4f656eb79bbee0e79aeec52e3e0e359f8a3e6a0d366378b64/roman_numerals_py-4.1.0.tar.gz", hash = "sha256:f5d7b2b4ca52dd855ef7ab8eb3590f428c0b1ea480736ce32b01fef2a5f8daf9", size = 4274, upload-time = "2025-12-17T18:25:41.153Z" } wheels = [ @@ -2818,7 +2856,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "botocore", version = "1.42.97", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/46/29/af14f4ef3c11a50435308660e2cc68761c9a7742475e0585cd4396b91777/s3transfer-0.16.1.tar.gz", hash = "sha256:8e424355754b9ccb32467bdc568edf55be82692ef2002d934b1311dbb3b9e524", size = 154801, upload-time = "2026-04-22T20:36:06.475Z" } wheels = [ @@ -2834,7 +2872,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "botocore", version = "1.43.94", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ @@ -3020,24 +3058,24 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "babel", marker = "python_full_version < '3.10'" }, - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.10'" }, - { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "jinja2", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.10'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "alabaster", version = "0.7.16", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "1.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" } }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" } wheels = [ @@ -3052,23 +3090,23 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "babel", marker = "python_full_version == '3.10.*'" }, - { name = "colorama", marker = "python_full_version == '3.10.*' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version == '3.10.*'" }, - { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "jinja2", marker = "python_full_version == '3.10.*'" }, - { name = "packaging", marker = "python_full_version == '3.10.*'" }, - { name = "pygments", marker = "python_full_version == '3.10.*'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "snowballstemmer", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version == '3.10.*'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version == '3.10.*'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -3083,23 +3121,23 @@ resolution-markers = [ "python_full_version >= '3.11'", ] dependencies = [ - { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "babel", marker = "python_full_version >= '3.11'" }, - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.11'" }, - { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jinja2", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, + { name = "alabaster", version = "1.0.0", source = { registry = "https://pypi.org/simple" } }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize", version = "2.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" } }, + { name = "roman-numerals-py" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ @@ -3116,16 +3154,16 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11'" }, - { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "colorama" }, + { name = "sphinx", version = "7.4.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "uvicorn", version = "0.53.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "watchfiles", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "watchfiles", version = "1.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, - { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "websockets", version = "15.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" }, { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a5/2c/155e1de2c1ba96a72e5dba152c509a8b41e047ee5c2def9e9f0d812f8be7/sphinx_autobuild-2024.10.3.tar.gz", hash = "sha256:248150f8f333e825107b6d4b86113ab28fa51750e5f9ae63b59dc339be951fb1", size = 14023, upload-time = "2024-10-02T23:15:30.172Z" } @@ -3141,12 +3179,12 @@ resolution-markers = [ "python_full_version >= '3.11'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11'" }, - { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "uvicorn", version = "0.53.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "colorama" }, + { name = "sphinx", version = "8.2.3", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette", version = "1.6.0", source = { registry = "https://pypi.org/simple" } }, + { name = "uvicorn", version = "0.53.0", source = { registry = "https://pypi.org/simple" } }, + { name = "watchfiles", version = "1.2.0", source = { registry = "https://pypi.org/simple" } }, + { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/3c/a59a3a453d4133777f7ed2e83c80b7dc817d43c74b74298ca0af869662ad/sphinx_autobuild-2025.8.25.tar.gz", hash = "sha256:9cf5aab32853c8c31af572e4fecdc09c997e2b8be5a07daf2a389e270e85b213", size = 15200, upload-time = "2025-08-25T18:44:55.436Z" } wheels = [ @@ -3280,8 +3318,8 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } wheels = [ @@ -3297,8 +3335,8 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } wheels = [ @@ -3426,9 +3464,9 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "h11", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" } wheels = [ @@ -3444,9 +3482,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "h11", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, + { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } wheels = [ @@ -3462,7 +3500,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ @@ -3585,7 +3623,7 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ @@ -4135,7 +4173,7 @@ resolution-markers = [ "python_full_version <= '3.9'", ] dependencies = [ - { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", version = "82.0.1", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/d8/9c8b0c6bb1db09725395618f68d3b8a08089fca0aed28437500caaf713ee/zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92", size = 18731, upload-time = "2025-09-12T07:10:13.551Z" } wheels = [ From 61da46892a205d4a7b8853d03f0fd6bad0f52595 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 25 Sep 2026 06:19:09 -0500 Subject: [PATCH 2/2] PYTHON-5945 Address review feedback on tracing options and span attributes Fix tracing.enabled so an explicit client value overrides the OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED environment variable, fix db.query.text truncation so budgets smaller than the "..." marker still honor the bound, and fix collection-name extraction so user and role management commands do not expose usernames as db.collection.name. --- pymongo/_otel.py | 70 +++++++++++--- pymongo/_telemetry.py | 2 +- pymongo/asynchronous/command_runner.py | 5 +- pymongo/asynchronous/mongo_client.py | 7 +- pymongo/client_options.py | 2 +- pymongo/common.py | 11 ++- pymongo/message.py | 8 +- pymongo/synchronous/command_runner.py | 5 +- pymongo/synchronous/mongo_client.py | 7 +- test/asynchronous/test_otel.py | 129 ++++++++++++++++++++++++- test/test_otel.py | 129 ++++++++++++++++++++++++- 11 files changed, 343 insertions(+), 32 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 713f963bbc..d3719050f1 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -55,12 +55,13 @@ class TracingOptions(TypedDict): """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`` and ``query_text_max_length`` are None when the client didn't + configure them, so the environment variables can be consulted; any explicit + value (including ``False``, to force tracing off, or 0, to force + ``db.query.text`` off) overrides the environment variable. """ - enabled: bool + enabled: Optional[bool] query_text_max_length: Optional[int] @@ -68,6 +69,12 @@ class TracingOptions(TypedDict): _OTEL_QUERY_TEXT_MAX_LENGTH_ENV = "OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH" _TRUTHY = frozenset({"1", "true", "yes"}) +# Redaction compares command names case-insensitively, mirroring the +# normalization in command monitoring, so a differently-cased sensitive +# command still gets no span. +_SENSITIVE_COMMANDS_LOWER = frozenset(name.lower() for name in _SENSITIVE_COMMANDS) +_HELLO_COMMANDS_LOWER = frozenset(name.lower() for name in _HELLO_COMMANDS) + # 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"}) @@ -86,6 +93,33 @@ class TracingOptions(TypedDict): # never have a real collection name, even when their command value is a string. _ADMIN_DB = "admin" +# Commands whose string command value names a user or role, not a collection +# (against any database): db.collection.name must be omitted rather than +# expose the username or role name as a collection. +_NOT_COLLECTION_COMMANDS = frozenset( + { + "createUser", + "dropAllRolesFromDatabase", + "dropAllUsersFromDatabase", + "dropRole", + "dropUser", + "grantPrivilegesToRole", + "grantPrivilegesToUser", + "grantRolesToRole", + "grantRolesToUser", + "invalidateUserCache", + "revokePrivilegesFromRole", + "revokePrivilegesFromUser", + "revokeRolesFromRole", + "revokeRolesFromUser", + "rolesInfo", + "createRole", + "updateRole", + "updateUser", + "usersInfo", + } +) + def _env_truthy(name: str) -> bool: """Return True if the environment variable ``name`` is set to "1", "true", or "yes".""" @@ -95,14 +129,19 @@ def _env_truthy(name: str) -> bool: 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. + An explicit ``MongoClient`` ``tracing.enabled`` value overrides the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; + when it isn't configured, the environment variable decides. ``None`` + means there is no client context (connection handshakes, server + monitoring), which is never traced. """ if not _HAS_OPENTELEMETRY: return False - if tracing_options and tracing_options.get("enabled"): - return True + if tracing_options is None: + return False + enabled = tracing_options.get("enabled") + if enabled is not None: + return enabled return _env_truthy(_OTEL_ENABLED_ENV) @@ -138,8 +177,10 @@ def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str: # 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 + # A budget smaller than the marker truncates without it so the result + # still never exceeds max_length. + suffix = "..." if max_length >= 3 else "" + text = text[: max_length - len(suffix)] + suffix return text @@ -154,6 +195,8 @@ def _extract_collection_name( """ if dbname == _ADMIN_DB: return None + if command_name in _NOT_COLLECTION_COMMANDS: + return None if command_name == _EXPLAIN: inner = cmd.get(_EXPLAIN) if not isinstance(inner, Mapping) or not inner: @@ -174,9 +217,10 @@ def _build_query_summary(command_name: str, dbname: str, collection: Optional[st 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: + name = command_name.lower() + if name in _SENSITIVE_COMMANDS_LOWER: return True - return command_name in _HELLO_COMMANDS and speculative_hello + return name in _HELLO_COMMANDS_LOWER and speculative_hello def _format_lsid(lsid: Mapping[str, Any]) -> Optional[str]: diff --git a/pymongo/_telemetry.py b/pymongo/_telemetry.py index 84b11a7408..8df71ef56f 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -179,7 +179,7 @@ def started(self, orig: MutableMapping[str, Any], ensure_db: bool) -> None: self._span = _otel.start_command_span( self._tracing_options, self._conn, - self._cmd, + orig, self._dbname, self._name, self._speculative_hello, diff --git a/pymongo/asynchronous/command_runner.py b/pymongo/asynchronous/command_runner.py index f13d893dbf..9e94f9b0b7 100644 --- a/pymongo/asynchronous/command_runner.py +++ b/pymongo/asynchronous/command_runner.py @@ -232,7 +232,10 @@ async def _run_command( parse_write_concern_error=parse_write_concern_error, pool_opts=pool_opts, ) - except Exception as exc: + except BaseException as exc: + # CancelledError (a BaseException) must also end the span: task + # cancellation lands here mid-command, and the span is ended with an + # error status before the cancellation propagates unmasked. if isinstance(exc, (NotPrimaryError, OperationFailure)): failure: _DocumentOut = exc.details # type: ignore[assignment] else: diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index af21740385..5f8f2578f3 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -641,9 +641,10 @@ def __init__( - `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. + this client. Unset by default, which defers to the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable. When set + explicitly, including to ``False``, it overrides the environment variable, so + an application can reliably opt out. - ``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 diff --git a/pymongo/client_options.py b/pymongo/client_options.py index d2d33f6805..e2d53b50cc 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": None}, + options.get("tracing") or {"enabled": None, "query_text_max_length": None}, ) @property diff --git a/pymongo/common.py b/pymongo/common.py index 2b8367429a..c068758b04 100644 --- a/pymongo/common.py +++ b/pymongo/common.py @@ -619,15 +619,20 @@ def validate_tracing_or_none(option: str, value: Any) -> Optional[_otel.TracingO 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) + enabled = value.get("enabled") + if enabled is not None: + 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) + # Keep the validator's converted value: it accepts numeric strings and + # the stored option must be an int. + query_text_max_length = 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/message.py b/pymongo/message.py index df7d940532..0a7ee2a934 100644 --- a/pymongo/message.py +++ b/pymongo/message.py @@ -127,8 +127,12 @@ def _maybe_add_read_preference( return spec -def _convert_exception(exception: Exception) -> dict[str, Any]: - """Convert an Exception into a failure document for publishing.""" +def _convert_exception(exception: BaseException) -> dict[str, Any]: + """Convert an Exception into a failure document for publishing. + + Accepts BaseException so task cancellation (CancelledError) can be + recorded on a command span's failure path. + """ return {"errmsg": str(exception), "errtype": exception.__class__.__name__} diff --git a/pymongo/synchronous/command_runner.py b/pymongo/synchronous/command_runner.py index 27380eb068..88d66fa78a 100644 --- a/pymongo/synchronous/command_runner.py +++ b/pymongo/synchronous/command_runner.py @@ -232,7 +232,10 @@ def _run_command( parse_write_concern_error=parse_write_concern_error, pool_opts=pool_opts, ) - except Exception as exc: + except BaseException as exc: + # CancelledError (a BaseException) must also end the span: task + # cancellation lands here mid-command, and the span is ended with an + # error status before the cancellation propagates unmasked. if isinstance(exc, (NotPrimaryError, OperationFailure)): failure: _DocumentOut = exc.details # type: ignore[assignment] else: diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 1321e2139f..391eeeb26b 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -642,9 +642,10 @@ def __init__( - `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. + this client. Unset by default, which defers to the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable. When set + explicitly, including to ``False``, it overrides the environment variable, so + an application can reliably opt out. - ``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 diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 7eaeafe734..7ae37bf198 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -16,8 +16,10 @@ from __future__ import annotations +import asyncio import os import sys +import time from typing import Optional from unittest.mock import patch @@ -28,8 +30,9 @@ import pymongo._otel as _otel from pymongo import common from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.operations import InsertOne from pymongo.typings import _Address -from test.asynchronous import AsyncIntegrationTest, unittest +from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest _HAS_OTEL_TEST_DEPS = False if _otel._HAS_OPENTELEMETRY: @@ -141,6 +144,20 @@ async def test_explain_retains_collection_name(self): self.assertEqual(attrs["db.collection.name"], "test_otel") self.assertEqual(attrs["db.query.summary"], f"explain {self.db.name}.test_otel") + async def test_user_management_commands_omit_collection_name(self): + # usersInfo's string command value names a user, not a collection: + # db.collection.name must be omitted so usernames aren't mislabeled + # (and exposed) as collections. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + await client[self.db.name].command("usersInfo", "someuser") + + spans = self.spans("usersInfo") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("db.collection.name", attrs) + self.assertEqual(attrs["db.query.summary"], f"usersInfo {self.db.name}") + async def test_server_port_omitted_for_unix_socket(self): class _FakeUnixConn: id = 1 @@ -282,6 +299,27 @@ async def test_explicit_query_text_max_length_zero_overrides_env_var(self): self.assertEqual(len(spans), 1) self.assertNotIn("db.query.text", spans[0].attributes) + async def test_explicit_enabled_false_overrides_env_var(self): + # An explicit client-side disable must win over the environment + # variable, unlike unset (which defers to it) - otherwise an app + # can't reliably opt out. + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = await self.async_rs_or_single_client(tracing={"enabled": False}) + self.exporter.clear() + await client.admin.command("ping") + # Only the client's own commands are gated by the client option; + # connection handshakes consult the environment variable directly. + self.assertNotIn("ping", [s.name for s in self.spans()]) + + async def test_unset_enabled_defers_to_env_var(self): + # ``tracing={}`` leaves enabled unconfigured, so the environment + # variable decides. + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = await self.async_rs_or_single_client(tracing={}) + self.exporter.clear() + await client.admin.command("ping") + self.assertIn("ping", [s.name for s in self.spans()]) + 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} @@ -300,6 +338,81 @@ async def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertLessEqual(len(query_text), 200) self.assertNotIn("a" * 500, query_text) + def test_query_text_tiny_max_length_truncates_without_suffix(self): + # Budgets smaller than the "..." marker must truncate without it so + # the result still honors the configured bound. + for max_length in (1, 2): + text = _otel._build_query_text({"ping": 1}, max_length) + self.assertEqual(len(text), max_length) + + def test_is_sensitive_command_case_insensitive(self): + # Redaction normalizes the command name, mirroring the comparison in + # command monitoring: a differently-cased sensitive command gets no + # span, so its payload cannot leak through db.query.text. + for name in ("saslStart", "SASLSTART", "saslstart", "CreateUser", "createuser"): + self.assertTrue(_otel._is_sensitive_command(name, False)) + self.assertFalse(_otel._is_sensitive_command("find", False)) + + def test_no_client_options_is_never_traced(self): + # ``None`` tracing options mean no client context (connection + # handshakes, server monitoring): never traced, even when the + # environment variable enables driver-level tracing. + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + self.assertFalse(_otel._is_tracing_enabled(None)) + + # Client-level bulk_write requires MongoDB 8.0+ (wire version 25). + @async_client_context.require_version_min(8, 0, 0, -24) + async def test_unacknowledged_bulk_write_query_text_includes_documents(self): + # db.query.text is built from the document published in + # CommandStartedEvent, which carries the write documents that the + # wire command omits for unacknowledged bulk writes. + client = await self.async_rs_or_single_client( + tracing={"enabled": True, "query_text_max_length": 1000}, w=0 + ) + self.exporter.clear() + await client.bulk_write( + [InsertOne(namespace=f"{self.db.name}.test_otel", document={"x": 1})], ordered=False + ) + (span,) = self.spans("bulkWrite") + self.assertIn('"x": 1', span.attributes["db.query.text"]) + + @async_client_context.require_failCommand_blockConnection + @async_client_context.require_async + async def test_span_ended_on_task_cancellation(self): + # Task cancellation raises CancelledError (a BaseException), which the + # command runner's cleanup must handle: the span ends with an error + # status and the cancellation still propagates. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test_otel + await coll.drop() + await coll.insert_many([{"x": i} for i in range(5)]) + self.exporter.clear() + + fail_command = { + "configureFailPoint": "failCommand", + "mode": "alwaysOn", + "data": {"failCommands": ["getMore"], "blockConnection": True, "blockTimeMS": 5000}, + } + + async def task(): + cursor = coll.find({}, batch_size=1) + await cursor.next() + async with self.fail_point(fail_command): + await cursor.next() + + running = asyncio.create_task(task()) + await asyncio.sleep(0.1) + start = time.monotonic() + running.cancel() + with self.assertRaises(asyncio.CancelledError): + await running + # The cancellation surfaces once the failPoint's block on the + # connection releases; it must not hang indefinitely. + self.assertLess(time.monotonic() - start, 7) + + (span,) = self.spans("getMore") + self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) + # TODO(PYTHON-5947): superseded once the unified test format's # expectTracingMessages/observeTracingMessages tests exercise this validator @@ -311,9 +424,15 @@ def test_none(self): def test_defaults(self): self.assertEqual( common.validate_tracing_or_none("tracing", {}), - {"enabled": False, "query_text_max_length": None}, + {"enabled": None, "query_text_max_length": None}, ) + def test_explicit_enabled_false_preserved(self): + # False must stay distinct from "unset" (None) so it can override the + # environment variable instead of deferring to it. + result = common.validate_tracing_or_none("tracing", {"enabled": False}) + self.assertIs(result["enabled"], False) + def test_enabled_and_query_text_max_length(self): self.assertEqual( common.validate_tracing_or_none( @@ -350,6 +469,12 @@ def test_rejects_negative_query_text_max_length(self): with self.assertRaises(ValueError): common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) + def test_numeric_string_query_text_max_length_converted(self): + # The validator's converted value must be kept: a numeric string that + # passes validation cannot flow into max(0, value) at command time. + result = common.validate_tracing_or_none("tracing", {"query_text_max_length": "100"}) + self.assertEqual(result["query_text_max_length"], 100) + class TestOTelTracerCaching(unittest.TestCase): """Regression test for the tracer-caching implementation in ``pymongo/_otel.py``. diff --git a/test/test_otel.py b/test/test_otel.py index d0e3a55fe5..0186588991 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -16,8 +16,10 @@ from __future__ import annotations +import asyncio import os import sys +import time from typing import Optional from unittest.mock import patch @@ -28,8 +30,9 @@ import pymongo._otel as _otel from pymongo import common from pymongo.errors import ConfigurationError, OperationFailure +from pymongo.operations import InsertOne from pymongo.typings import _Address -from test import IntegrationTest, unittest +from test import IntegrationTest, client_context, unittest _HAS_OTEL_TEST_DEPS = False if _otel._HAS_OPENTELEMETRY: @@ -141,6 +144,20 @@ def test_explain_retains_collection_name(self): self.assertEqual(attrs["db.collection.name"], "test_otel") self.assertEqual(attrs["db.query.summary"], f"explain {self.db.name}.test_otel") + def test_user_management_commands_omit_collection_name(self): + # usersInfo's string command value names a user, not a collection: + # db.collection.name must be omitted so usernames aren't mislabeled + # (and exposed) as collections. + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + client[self.db.name].command("usersInfo", "someuser") + + spans = self.spans("usersInfo") + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("db.collection.name", attrs) + self.assertEqual(attrs["db.query.summary"], f"usersInfo {self.db.name}") + def test_server_port_omitted_for_unix_socket(self): class _FakeUnixConn: id = 1 @@ -278,6 +295,27 @@ def test_explicit_query_text_max_length_zero_overrides_env_var(self): self.assertEqual(len(spans), 1) self.assertNotIn("db.query.text", spans[0].attributes) + def test_explicit_enabled_false_overrides_env_var(self): + # An explicit client-side disable must win over the environment + # variable, unlike unset (which defers to it) - otherwise an app + # can't reliably opt out. + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = self.rs_or_single_client(tracing={"enabled": False}) + self.exporter.clear() + client.admin.command("ping") + # Only the client's own commands are gated by the client option; + # connection handshakes consult the environment variable directly. + self.assertNotIn("ping", [s.name for s in self.spans()]) + + def test_unset_enabled_defers_to_env_var(self): + # ``tracing={}`` leaves enabled unconfigured, so the environment + # variable decides. + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + client = self.rs_or_single_client(tracing={}) + self.exporter.clear() + client.admin.command("ping") + self.assertIn("ping", [s.name for s in self.spans()]) + 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 @@ -294,6 +332,81 @@ def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertLessEqual(len(query_text), 200) self.assertNotIn("a" * 500, query_text) + def test_query_text_tiny_max_length_truncates_without_suffix(self): + # Budgets smaller than the "..." marker must truncate without it so + # the result still honors the configured bound. + for max_length in (1, 2): + text = _otel._build_query_text({"ping": 1}, max_length) + self.assertEqual(len(text), max_length) + + def test_is_sensitive_command_case_insensitive(self): + # Redaction normalizes the command name, mirroring the comparison in + # command monitoring: a differently-cased sensitive command gets no + # span, so its payload cannot leak through db.query.text. + for name in ("saslStart", "SASLSTART", "saslstart", "CreateUser", "createuser"): + self.assertTrue(_otel._is_sensitive_command(name, False)) + self.assertFalse(_otel._is_sensitive_command("find", False)) + + def test_no_client_options_is_never_traced(self): + # ``None`` tracing options mean no client context (connection + # handshakes, server monitoring): never traced, even when the + # environment variable enables driver-level tracing. + with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true"}): + self.assertFalse(_otel._is_tracing_enabled(None)) + + # Client-level bulk_write requires MongoDB 8.0+ (wire version 25). + @client_context.require_version_min(8, 0, 0, -24) + def test_unacknowledged_bulk_write_query_text_includes_documents(self): + # db.query.text is built from the document published in + # CommandStartedEvent, which carries the write documents that the + # wire command omits for unacknowledged bulk writes. + client = self.rs_or_single_client( + tracing={"enabled": True, "query_text_max_length": 1000}, w=0 + ) + self.exporter.clear() + client.bulk_write( + [InsertOne(namespace=f"{self.db.name}.test_otel", document={"x": 1})], ordered=False + ) + (span,) = self.spans("bulkWrite") + self.assertIn('"x": 1', span.attributes["db.query.text"]) + + @client_context.require_failCommand_blockConnection + @client_context.require_async + def test_span_ended_on_task_cancellation(self): + # Task cancellation raises CancelledError (a BaseException), which the + # command runner's cleanup must handle: the span ends with an error + # status and the cancellation still propagates. + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test_otel + coll.drop() + coll.insert_many([{"x": i} for i in range(5)]) + self.exporter.clear() + + fail_command = { + "configureFailPoint": "failCommand", + "mode": "alwaysOn", + "data": {"failCommands": ["getMore"], "blockConnection": True, "blockTimeMS": 5000}, + } + + def task(): + cursor = coll.find({}, batch_size=1) + cursor.next() + with self.fail_point(fail_command): + cursor.next() + + running = asyncio.create_task(task()) + time.sleep(0.1) + start = time.monotonic() + running.cancel() + with self.assertRaises(asyncio.CancelledError): + running + # The cancellation surfaces once the failPoint's block on the + # connection releases; it must not hang indefinitely. + self.assertLess(time.monotonic() - start, 7) + + (span,) = self.spans("getMore") + self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) + # TODO(PYTHON-5947): superseded once the unified test format's # expectTracingMessages/observeTracingMessages tests exercise this validator @@ -305,9 +418,15 @@ def test_none(self): def test_defaults(self): self.assertEqual( common.validate_tracing_or_none("tracing", {}), - {"enabled": False, "query_text_max_length": None}, + {"enabled": None, "query_text_max_length": None}, ) + def test_explicit_enabled_false_preserved(self): + # False must stay distinct from "unset" (None) so it can override the + # environment variable instead of deferring to it. + result = common.validate_tracing_or_none("tracing", {"enabled": False}) + self.assertIs(result["enabled"], False) + def test_enabled_and_query_text_max_length(self): self.assertEqual( common.validate_tracing_or_none( @@ -344,6 +463,12 @@ def test_rejects_negative_query_text_max_length(self): with self.assertRaises(ValueError): common.validate_tracing_or_none("tracing", {"query_text_max_length": -1}) + def test_numeric_string_query_text_max_length_converted(self): + # The validator's converted value must be kept: a numeric string that + # passes validation cannot flow into max(0, value) at command time. + result = common.validate_tracing_or_none("tracing", {"query_text_max_length": "100"}) + self.assertEqual(result["query_text_max_length"], 100) + class TestOTelTracerCaching(unittest.TestCase): """Regression test for the tracer-caching implementation in ``pymongo/_otel.py``.