diff --git a/.evergreen/generated_configs/variants.yml b/.evergreen/generated_configs/variants.yml index 6f475761ce..128b728b24 100644 --- a/.evergreen/generated_configs/variants.yml +++ b/.evergreen/generated_configs/variants.yml @@ -445,6 +445,7 @@ buildvariants: - name: otel-rhel8 tasks: - name: .test-non-standard .standalone-noauth-nossl + - name: .test-non-standard .replica_set-noauth-ssl display_name: OTel RHEL8 run_on: - rhel87-small diff --git a/.evergreen/resync-specs.sh b/.evergreen/resync-specs.sh index 6a2133cb64..984b16d8e6 100755 --- a/.evergreen/resync-specs.sh +++ b/.evergreen/resync-specs.sh @@ -127,6 +127,9 @@ do cpjson command-logging-and-monitoring/tests/logging command_logging cpjson command-logging-and-monitoring/tests/monitoring command_monitoring ;; + open-telemetry|otel|open_telemetry) + cpjson open-telemetry/tests open_telemetry + ;; crud|CRUD) cpjson crud/tests/ crud ;; diff --git a/.evergreen/scripts/generate_config.py b/.evergreen/scripts/generate_config.py index 78ba7a5244..7562414bb1 100644 --- a/.evergreen/scripts/generate_config.py +++ b/.evergreen/scripts/generate_config.py @@ -457,7 +457,16 @@ def create_otel_variants(): expansions = dict(TEST_NAME="otel", COVERAGE="1") return [ create_variant( - [".test-non-standard .standalone-noauth-nossl"], + [ + ".test-non-standard .standalone-noauth-nossl", + # Transaction spans (test/open_telemetry/transaction/*.json, + # test_otel.py's @require_transactions tests) need a replica set + # (they're skipped entirely on a standalone topology), so also + # run against one, mirroring how other variants in this file + # (e.g. PyOpenSSL's ".replica_set-noauth-ssl") pair a + # standalone/standard selector with a replica-set one. + ".test-non-standard .replica_set-noauth-ssl", + ], get_variant_name("OTel", host), host=host, tags=["pr"], diff --git a/.gitignore b/.gitignore index f8f9bd6bcf..d3a34b346e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ server.log # uv lockfiles uv.lock + +# Local git worktrees +.worktrees/ diff --git a/bson/json_util.py b/bson/json_util.py index 098a4b18ce..1928405239 100644 --- a/bson/json_util.py +++ b/bson/json_util.py @@ -1109,20 +1109,18 @@ def _truncate_documents(obj: Any, max_length: int) -> tuple[Any, int]: if hasattr(obj, "items"): truncated: Any = {} for k, v in obj.items(): - truncated_v, remaining = _truncate_documents(v, remaining) - if truncated_v: - truncated[k] = truncated_v if remaining <= 0: break + truncated_v, remaining = _truncate_documents(v, remaining) + truncated[k] = truncated_v return truncated, remaining elif hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes)): truncated: Any = [] # type:ignore[no-redef] for v in obj: - truncated_v, remaining = _truncate_documents(v, remaining) - if truncated_v: - truncated.append(truncated_v) if remaining <= 0: break + truncated_v, remaining = _truncate_documents(v, remaining) + truncated.append(truncated_v) return truncated, remaining else: return _truncate(obj, remaining) diff --git a/doc/changelog.rst b/doc/changelog.rst index b1c0bcd32f..6b8a2a5c7b 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -20,11 +20,14 @@ PyMongo 4.18 brings a number of changes including: attempts, so consumers can correlate a retried operation's events. As a result, ``operation_id`` is no longer equal to the per-attempt ``request_id`` for these operations. -- Added optional OpenTelemetry command-span support, conforming to the +- Added optional OpenTelemetry tracing support, conforming to the `OpenTelemetry driver specification `_. - Enable it with the ``tracing`` :class:`~pymongo.mongo_client.MongoClient` - option or the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment - variable. Install the ``opentelemetry-api`` package, or use the + Every public API call produces an operation span, which contains one span + per command sent to the server. Inside a transaction, those operation spans + nest under a ``transaction`` span. Enable it with the + ``tracing`` :class:`~pymongo.mongo_client.MongoClient` option or the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable. + Install the ``opentelemetry-api`` package, or use the ``pymongo[opentelemetry]`` extra, to enable this feature. - Fixed a potential out-of-bounds read in the C extension when decoding an array of BSON documents. An embedded document whose declared length exceeds diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 713f963bbc..d515289625 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -21,8 +21,11 @@ from __future__ import annotations +import contextlib import os -from collections.abc import Mapping, MutableMapping +import traceback +from collections.abc import Iterator, Mapping, MutableMapping +from contextvars import ContextVar from typing import TYPE_CHECKING, Any, Optional, TypedDict from bson import json_util @@ -31,7 +34,7 @@ from pymongo.logger import _HELLO_COMMANDS, _JSON_OPTIONS, _SENSITIVE_COMMANDS try: - from opentelemetry import trace + from opentelemetry import context, trace from opentelemetry.trace import SpanKind, Status, StatusCode _HAS_OPENTELEMETRY = True @@ -45,6 +48,14 @@ _HAS_OPENTELEMETRY = False _TRACER = None +# The operation name of whichever operation span is currently active (entered +# by start_operation_span), so start_command_span can backfill the operation +# span's name/namespace attributes from the first command executed inside it +# (dbname/collection aren't known until then; see start_operation_span). +_CURRENT_OPERATION_NAME: ContextVar[Optional[str]] = ContextVar( + "_CURRENT_OPERATION_NAME", default=None +) + if TYPE_CHECKING: from opentelemetry.trace import Span, Tracer @@ -205,10 +216,27 @@ def start_command_span( """ if not _is_tracing_enabled(tracing_options): return None + + collection = _extract_collection_name(command_name, dbname, cmd) + # Backfill the ambient operation span's name/namespace/summary from the + # first command built inside it, before the sensitive-command early return + # below: the operation span still needs its (Required, per the OTel spec) + # db.namespace/db.operation.summary attributes even when the command + # itself is sensitive and gets no command span of its own. + current_operation = _CURRENT_OPERATION_NAME.get() + if current_operation is not None: + current_span = trace.get_current_span() + if current_span.is_recording(): + summary = _build_query_summary(current_operation, dbname, collection) + current_span.update_name(summary) + current_span.set_attribute("db.namespace", dbname) + current_span.set_attribute("db.operation.summary", summary) + if collection: + current_span.set_attribute("db.collection.name", collection) + if _is_sensitive_command(command_name, speculative_hello): return None - collection = _extract_collection_name(command_name, dbname, cmd) address = conn.address transport = "unix" if address[1] is None else "tcp" attributes: dict[str, Any] = { @@ -243,15 +271,40 @@ def start_command_span( def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None: - """Set the cursor id (if any) and end the span.""" + """Set the cursor id (if any open cursor) and end the span.""" if span is None: return cursor = reply.get("cursor") - if isinstance(cursor, Mapping) and "id" in cursor: + if isinstance(cursor, Mapping) and cursor.get("id"): + # A cursor id of 0 means the cursor is already exhausted, i.e. there is + # no cursor left to track, so per the OTel spec ("If the command + # returns a cursor, or uses a cursor, the cursor_id attribute SHOULD + # be added") the attribute is only meaningful, and only added, when + # id is nonzero. span.set_attribute("db.mongodb.cursor_id", cursor["id"]) span.end() +def _set_exception_attributes(span: Span, exc: BaseException) -> None: + """Set exception.type/exception.message/exception.stacktrace span attributes. + + ``span.record_exception`` only attaches these to an "exception" *event*, + but the OTel spec requires them as span *attributes* too ("drivers SHOULD + add the following attributes to the span"); mirror record_exception's own + formatting for consistency. Shared by the command-span and operation-span + failure paths, since the spec states the same requirement for both. + """ + module = type(exc).__module__ + qualname = type(exc).__qualname__ + exception_type = f"{module}.{qualname}" if module and module != "builtins" else qualname + span.set_attribute("exception.type", exception_type) + span.set_attribute("exception.message", str(exc)) + span.set_attribute( + "exception.stacktrace", + "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), + ) + + def end_command_span_failure( span: Optional[Span], failure: _DocumentOut, @@ -261,8 +314,190 @@ def end_command_span_failure( if span is None: return span.record_exception(exc) + _set_exception_attributes(span, exc) code = failure.get("code") if code is not None: span.set_attribute("db.response.status_code", str(code)) span.set_status(Status(StatusCode.ERROR, description=failure.get("errmsg"))) span.end() + + +class _OperationSpanHandle: + """Bundles an operation span with what's needed to end it later. + + ``_cm`` is the ``start_as_current_span`` context manager when the span was + made current at creation, or None in detached mode (see + ``start_operation_span``'s ``set_current``), where the span is made current + per-use by ``use_operation_span`` instead. + """ + + __slots__ = ("_cm", "_name_token", "operation_name", "span") + + def __init__( + self, + span: Span, + cm: Any, + name_token: Any, + operation_name: str, + ) -> None: + self.span = span + self._cm = cm + self._name_token = name_token + self.operation_name = operation_name + + +def start_operation_span( + tracing_options: Optional[TracingOptions], + operation: str, + parent_span: Optional[Span], + dbname: Optional[str] = None, + collection: Optional[str] = None, + set_current: bool = True, +) -> Optional[_OperationSpanHandle]: + """Start a CLIENT-kind span for one logical operation, or None. + + Spans all retry attempts of one call to _retry_internal. The (Required, + per the OTel spec) ``db.operation.summary`` is always set immediately, + to the bare operation name unless ``dbname`` is given, in which case it + (and, when ``collection`` is also given, the "if available" + ``db.namespace``/``db.collection.name``) are built from those instead. + This guarantees a conformant span even for an operation that fails before + any command is ever built, e.g. server selection timing out. + ``start_command_span`` still backfills these from the real command once + one is built, overwriting these values with the authoritative ones. + + ``parent_span`` (the active transaction span, if any) becomes this span's + *explicit* parent; it is deliberately not read from ambient context, to + avoid a concurrently-running unrelated session's operations picking up + this transaction by accident. Pass None outside of a transaction. + + With ``set_current=False`` the span is created but not made current, and + the operation-name contextvar is left alone. That suits a span whose + lifetime covers several ``_retry_internal`` calls (cursor getMores), where + the caller makes it current per call with ``use_operation_span``. + """ + if not _is_tracing_enabled(tracing_options): + return None + assert _TRACER is not None # _is_tracing_enabled already checked _HAS_OPENTELEMETRY + context = trace.set_span_in_context(parent_span) if parent_span is not None else None + attributes: dict[str, Any] = { + "db.system.name": "mongodb", + "db.operation.name": operation, + } + name = operation + if dbname is not None: + name = _build_query_summary(operation, dbname, collection) + attributes["db.namespace"] = dbname + if collection: + attributes["db.collection.name"] = collection + attributes["db.operation.summary"] = name + if not set_current: + span = _TRACER.start_span( + name, kind=SpanKind.CLIENT, context=context, attributes=attributes + ) + return _OperationSpanHandle(span, None, None, operation) + cm = _TRACER.start_as_current_span( + name, + kind=SpanKind.CLIENT, + context=context, + attributes=attributes, + ) + span = cm.__enter__() + name_token = _CURRENT_OPERATION_NAME.set(operation) + return _OperationSpanHandle(span, cm, name_token, operation) + + +@contextlib.contextmanager +def use_operation_span(handle: Optional[_OperationSpanHandle]) -> Iterator[None]: + """Make a detached operation span current for the duration of the block. + + Does not end the span; the owner (e.g. a cursor, across all of its + getMore calls) ends it explicitly. A no-op when ``handle`` is None. + """ + if handle is None: + yield + return + token = _CURRENT_OPERATION_NAME.set(handle.operation_name) + try: + # record_exception/set_status_on_exception default to True, which would + # auto-record any exception propagating out of the block and set ERROR + # status here, duplicating what the caller's own + # end_operation_span_failure does explicitly once the operation's + # final outcome is known. Disabled for the same reason the + # attached-mode path passes hardcoded Nones to cm.__exit__. + with trace.use_span( + handle.span, + end_on_exit=False, + record_exception=False, + set_status_on_exception=False, + ): + yield + finally: + _CURRENT_OPERATION_NAME.reset(token) + + +def reset_context() -> None: + """Clear the OTel ambient span and operation-name contextvar. + + For long-lived background tasks whose context was copied from whatever + happened to be running when they were created (``asyncio.create_task`` + freezes the caller's ``contextvars.Context``). Without this, spans the task + emits are parented under an unrelated, long-since-ended operation and share + its trace id. Attaching an empty context makes ``get_current_span()`` return + the non-recording invalid span, so spans started afterwards become trace + roots. Deliberately does not detach: the task's context is wrong for its + whole life, and it dies with the task. + """ + if not _HAS_OPENTELEMETRY: + return + _CURRENT_OPERATION_NAME.set(None) + context.attach(context.Context()) + + +def end_operation_span_success(handle: Optional[_OperationSpanHandle]) -> None: + """End the operation span with no error status.""" + if handle is None: + return + if handle._cm is None: + handle.span.end() + return + _CURRENT_OPERATION_NAME.reset(handle._name_token) + handle._cm.__exit__(None, None, None) + + +def end_operation_span_failure(handle: Optional[_OperationSpanHandle], exc: BaseException) -> None: + """Record the exception, set the error status, and end the operation span.""" + if handle is None: + return + handle.span.record_exception(exc) + _set_exception_attributes(handle.span, exc) + handle.span.set_status(Status(StatusCode.ERROR, description=str(exc))) + if handle._cm is None: + handle.span.end() + return + _CURRENT_OPERATION_NAME.reset(handle._name_token) + handle._cm.__exit__(None, None, None) + + +def start_transaction_span(tracing_options: Optional[TracingOptions]) -> Optional[Span]: + """Start (but do not make current) the ``"transaction"`` pseudo-span, or None. + + Not pushed as ambient/current context; it's stored explicitly on + ``session._transaction.span`` and passed as the explicit ``parent_span`` + wherever an operation span is started under this transaction (see + :func:`start_operation_span`). Per the OTel driver spec, this span has + exactly one attribute. + """ + if not _is_tracing_enabled(tracing_options): + return None + assert _TRACER is not None + return _TRACER.start_span( + "transaction", kind=SpanKind.CLIENT, attributes={"db.system.name": "mongodb"} + ) + + +def end_transaction_span(span: Optional[Span]) -> None: + """End the transaction span, if any.""" + if span is None: + return + span.end() diff --git a/pymongo/_telemetry.py b/pymongo/_telemetry.py index b770add9d6..4b32027469 100644 --- a/pymongo/_telemetry.py +++ b/pymongo/_telemetry.py @@ -17,6 +17,7 @@ from __future__ import annotations import datetime +import enum import logging import queue import time @@ -235,6 +236,107 @@ def failed( _otel.end_command_span_failure(self._span, failure, exc) +# A handful of internal `_Op` values (used elsewhere for retry/server-selection and +# cluster-time-advancing logic, e.g. `_WRITES_WITH_CLUSTER_TIME` in pymongo/operations.py) are +# literally the wire protocol command name (e.g. "drop"/"create") rather than the OTel spec's +# canonical db.operation.name for that logical operation ("dropCollection"/"createCollection", per +# the open-telemetry spec's "Covered operations" table and its vendored +# drop_collection.json/create_collection.json tests). Translate only the name used for the span; +# leave the `operation` value used for retry selection/logging untouched everywhere else. +_OTEL_OPERATION_NAME_OVERRIDES = { + "drop": "dropCollection", + "create": "createCollection", + "dropSearchIndexes": "dropSearchIndex", +} + +# Per the OTel driver spec's span-name rule ("`driver_operation_name db` if there is no specific +# collection"), db.namespace examples, and db.collection.name examples (omitted for runCommand), +# any operation reaching the server through the generic `Database.command()` API (signaled by +# `is_run_command`) is named "runCommand" regardless of the actual command sent, rather than being +# named after that command. +_RUN_COMMAND_OPERATION_NAME = "runCommand" + + +def _normalize_operation_name(operation: Any) -> str: + """Return the plain ``str`` form of an operation name. + + Most `_retry_internal` call sites pass an `_Op` enum member (a `str` + mixin enum) rather than a plain string. Python 3.11 changed + ``Enum.__format__`` for such mixin enums so that ``str()``/f-string + formatting includes the class name (e.g. ``"_Op.INSERT"`` instead of + ``"insert"``); on 3.10 the same code happened to produce the correct bare + value. Normalize to the actual string value once, here, so every caller + that builds a span name/attribute from an operation gets a stable, + version-independent result. + """ + if isinstance(operation, enum.Enum): + return operation.value + return str(operation) + + +class _OperationTelemetry: + """One span-scoped context per logical operation (spanning all retry attempts). + + Construct once per call to ``_retry_internal``; call :meth:`succeeded` or + :meth:`failed` exactly once when the operation's outcome is known, or use + it as a context manager to do so automatically. A no-op throughout when + tracing is disabled. + + With ``set_current=False`` the span is not made current at construction. + That suits a span outliving one ``_retry_internal`` call (cursor getMores), + where each call makes it current with :meth:`use`. + """ + + __slots__ = ("handle", "operation_name") + + def __init__( + self, + tracing_options: Optional[_otel.TracingOptions], + operation: str, + session: Optional[Any], + is_run_command: bool = False, + dbname: Optional[str] = None, + collection: Optional[str] = None, + set_current: bool = True, + ) -> None: + parent_span = None + if session is not None and session.in_transaction: + parent_span = session._transaction.span + if is_run_command: + otel_operation = _RUN_COMMAND_OPERATION_NAME + else: + name = _normalize_operation_name(operation) + otel_operation = _OTEL_OPERATION_NAME_OVERRIDES.get(name, name) + self.operation_name = otel_operation + self.handle = _otel.start_operation_span( + tracing_options, + otel_operation, + parent_span, + dbname=dbname, + collection=collection, + set_current=set_current, + ) + + def use(self) -> Any: + """Make this operation's span current for the duration of a block.""" + return _otel.use_operation_span(self.handle) + + def succeeded(self) -> None: + _otel.end_operation_span_success(self.handle) + + def failed(self, exc: BaseException) -> None: + _otel.end_operation_span_failure(self.handle, exc) + + def __enter__(self) -> _OperationTelemetry: + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if exc_val is None: + self.succeeded() + else: + self.failed(exc_val) + + class _CmapTelemetry: """Combines CMAP structured logging and APM event publishing for pool and connection events.""" diff --git a/pymongo/asynchronous/aggregation.py b/pymongo/asynchronous/aggregation.py index f1f77acc73..724773a1a8 100644 --- a/pymongo/asynchronous/aggregation.py +++ b/pymongo/asynchronous/aggregation.py @@ -22,6 +22,7 @@ from pymongo import common from pymongo.collation import validate_collation_or_none from pymongo.errors import ConfigurationError +from pymongo.helpers_shared import _split_namespace from pymongo.read_preferences import ReadPreference, _AggWritePref if TYPE_CHECKING: @@ -251,5 +252,5 @@ def _cursor_collection(self, cursor: Mapping[str, Any]) -> AsyncCollection[Any]: # AsyncCollection level aggregate may not always return the "ns" field # according to our MockupDB tests. Let's handle that case for db level # aggregate too by defaulting to the .$cmd.aggregate namespace. - _, collname = cursor.get("ns", self._cursor_namespace).split(".", 1) + _, collname = _split_namespace(cursor.get("ns", self._cursor_namespace)) return self._database[collname] diff --git a/pymongo/asynchronous/change_stream.py b/pymongo/asynchronous/change_stream.py index e9d588ac95..ef126938d3 100644 --- a/pymongo/asynchronous/change_stream.py +++ b/pymongo/asynchronous/change_stream.py @@ -250,6 +250,14 @@ async def _run_aggregation_cmd( result_processor=self._process_result, comment=self._comment, ) + # Deliberately no operation_telemetry is attached to the resulting + # cursor here: a change stream can tail indefinitely, so an operation + # span covering its whole lifetime (initial query + every getMore, + # like other command cursors) would never end while it's watching. + # Leaving it unattached means each getMore instead gets its own + # short-lived sibling "getMore" operation span, less ideal nesting, + # but not a leaked/never-exported span. Do not "fix" this without + # addressing that tradeoff. return await self._client._retryable_read( cmd.get_cursor, self._target._read_preference_for(session), diff --git a/pymongo/asynchronous/client_bulk.py b/pymongo/asynchronous/client_bulk.py index cfa2ea9853..fd786e0cd7 100644 --- a/pymongo/asynchronous/client_bulk.py +++ b/pymongo/asynchronous/client_bulk.py @@ -32,6 +32,7 @@ from bson.objectid import ObjectId from bson.raw_bson import RawBSONDocument from pymongo import _csot, common +from pymongo._telemetry import _OperationTelemetry from pymongo.asynchronous.client_session import ( AsyncClientSession, _validate_session_write_concern, @@ -335,6 +336,12 @@ async def _process_results_cursor( session=session, comment=self.comment, ) + # This cursor's getMores run inside the enclosing bulkWrite + # operation span, so their command spans belong under it directly; + # a getMore operation span of their own would be spurious. The + # cursor is also per-batch and never surfaces to the caller, so + # there is no cursor-lifetime span to own here. + cmd_cursor._reuse_current_span_for_getmore = True await cmd_cursor._maybe_pin_connection(conn) # Iterate the cursor to get individual write results. @@ -630,13 +637,26 @@ async def execute( session = _validate_session_write_concern(session, self.write_concern) if not self.write_concern.acknowledged: - async with await self.client._conn_for_writes(session, operation) as connection: - if connection.max_wire_version < 25: - raise InvalidOperation( - "MongoClient.bulk_write requires MongoDB server version 8.0+." - ) - await self.execute_no_results(connection) - return ClientBulkWriteResult(None, False, False) # type: ignore[arg-type] + # This path never reaches the command-span code that would + # otherwise fill in the span's namespace, so pass it here. A client + # bulk write always runs against admin and spans multiple + # namespaces, so there is no single collection to report. + operation_telemetry = _OperationTelemetry( + self.client.options.tracing, operation, session, dbname="admin" + ) + try: + async with await self.client._conn_for_writes(session, operation) as connection: + if connection.max_wire_version < 25: + raise InvalidOperation( + "MongoClient.bulk_write requires MongoDB server version 8.0+." + ) + await self.execute_no_results(connection) + except BaseException as exc: + operation_telemetry.failed(exc) + raise + else: + operation_telemetry.succeeded() + return ClientBulkWriteResult(None, False, False) # type: ignore[arg-type] result = await self.execute_command(session, operation) return ClientBulkWriteResult( diff --git a/pymongo/asynchronous/client_session.py b/pymongo/asynchronous/client_session.py index 72b1ac100e..359a853330 100644 --- a/pymongo/asynchronous/client_session.py +++ b/pymongo/asynchronous/client_session.py @@ -156,7 +156,7 @@ from bson.binary import Binary from bson.int64 import Int64 from bson.timestamp import Timestamp -from pymongo import _csot +from pymongo import _csot, _otel from pymongo.asynchronous.cursor_base import _ConnectionManager from pymongo.errors import ( ConfigurationError, @@ -427,6 +427,7 @@ def __init__(self, opts: Optional[TransactionOptions], client: AsyncMongoClient[ self.attempt = 0 self.client = client self.has_completed_command = False + self.span: Optional[Any] = None def active(self) -> bool: return self.state in (_TxnState.STARTING, _TxnState.IN_PROGRESS) @@ -467,6 +468,7 @@ async def reset(self) -> None: self.recovery_token = None self.attempt = 0 self.has_completed_command = False + self.span = None def __del__(self) -> None: if self.conn_mgr: @@ -562,6 +564,12 @@ def __init__( # Is this an implicitly created session? self._implicit = implicit self._transaction = _Transaction(None, client) + # The one "transaction" span shared across every retry of a single + # with_transaction() call, or None outside of with_transaction (in + # which case start_transaction/commit_transaction/abort_transaction + # each manage their own span exactly as they did before with_transaction + # existed). See with_transaction's docstring/comments for details. + self._with_transaction_span: Optional[Any] = None # Is this session attached to a cursor? self._attached_to_cursor = False # Should we leave the session alive when the cursor is closed? @@ -769,6 +777,44 @@ async def callback(session, custom_arg, custom_kwarg=None): .. _transactions specification: https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/transactions-convenient-api.md#handling-errors-inside-the-callback """ + if self._with_transaction_span is not None: + # Nested/concurrent with_transaction() calls on one session are + # unsupported. Raise here, before any span bookkeeping, so we + # don't silently clobber and leak the outer call's span. + raise InvalidOperation( + "Cannot call with_transaction() while a previous with_transaction() " + "call on this session has not returned; sessions do not support " + "nested or concurrent with_transaction() calls" + ) + # One span for the whole call: start_transaction reuses it instead of + # creating one per retry, and commit/abort leave it open, so a + # retried with_transaction() yields a single span. Skipped when a + # direct-API transaction is already active, because start_transaction() + # will raise immediately below and the span would record nothing. + if not self.in_transaction: + self._with_transaction_span = _otel.start_transaction_span(self._client.options.tracing) + try: + return await self._with_transaction_retry_loop( + callback, read_concern, write_concern, read_preference, max_commit_time_ms + ) + finally: + _otel.end_transaction_span(self._with_transaction_span) + # Only clear the span this call owns. If a direct-API transaction + # was already active, self._transaction.span belongs to that + # transaction and must not be nulled here. + if self._transaction.span is self._with_transaction_span: + self._transaction.span = None + self._with_transaction_span = None + + async def _with_transaction_retry_loop( + self, + callback: Callable[[AsyncClientSession], Awaitable[_T]], + read_concern: Optional[ReadConcern], + write_concern: Optional[WriteConcern], + read_preference: Optional[_ServerMode], + max_commit_time_ms: Optional[int], + ) -> _T: + """Run with_transaction's retry loop; see with_transaction.""" start_time = time.monotonic() retry = 0 last_error: Optional[BaseException] = None @@ -864,9 +910,31 @@ async def start_transaction( ) await self._transaction.reset() self._transaction.state = _TxnState.STARTING + if self._with_transaction_span is not None: + # with_transaction() is retrying the whole transaction: reuse its + # one shared span instead of starting a new one, so a retried + # with_transaction() still produces exactly one "transaction" span. + self._transaction.span = self._with_transaction_span + else: + self._transaction.span = _otel.start_transaction_span( + self._transaction.client.options.tracing + ) self._start_retryable_write() return _TransactionContext(self) + def _end_own_transaction_span(self) -> None: + """End and clear the transaction span, unless with_transaction() owns it. + + with_transaction() pins one shared span across all of its retries in + ``self._with_transaction_span`` (see its comments); while that's set, + the span must survive until with_transaction() itself ends it, so this + is a no-op here. Otherwise a retried with_transaction() would end the + shared span prematurely on the first failed attempt. + """ + if self._with_transaction_span is None: + _otel.end_transaction_span(self._transaction.span) + self._transaction.span = None + async def commit_transaction(self) -> None: """Commit a multi-statement transaction. @@ -879,6 +947,7 @@ async def commit_transaction(self) -> None: elif state in (_TxnState.STARTING, _TxnState.COMMITTED_EMPTY): # Server transaction was never started, no need to send a command. self._transaction.state = _TxnState.COMMITTED_EMPTY + self._end_own_transaction_span() return elif state is _TxnState.ABORTED: raise InvalidOperation("Cannot call commitTransaction after calling abortTransaction") @@ -886,6 +955,16 @@ async def commit_transaction(self) -> None: # We're explicitly retrying the commit, move the state back to # "in progress" so that in_transaction returns true. self._transaction.state = _TxnState.IN_PROGRESS + # Outside of with_transaction() (which pins its shared span across + # this transition, see _end_own_transaction_span), the prior + # attempt's finally block already ended and cleared the + # transaction span, so this direct-API retry needs a fresh one; + # otherwise it would run with no transaction span and its command + # span would have no parent. + if self._transaction.span is None: + self._transaction.span = _otel.start_transaction_span( + self._transaction.client.options.tracing + ) try: await self._finish_transaction_with_retry("commitTransaction") @@ -909,6 +988,7 @@ async def commit_transaction(self) -> None: _reraise_with_unknown_commit(exc) finally: self._transaction.state = _TxnState.COMMITTED + self._end_own_transaction_span() async def abort_transaction(self) -> None: """Abort a multi-statement transaction. @@ -923,6 +1003,7 @@ async def abort_transaction(self) -> None: elif state is _TxnState.STARTING: # Server transaction was never started, no need to send a command. self._transaction.state = _TxnState.ABORTED + self._end_own_transaction_span() return elif state is _TxnState.ABORTED: raise InvalidOperation("Cannot call abortTransaction twice") @@ -936,6 +1017,7 @@ async def abort_transaction(self) -> None: pass finally: self._transaction.state = _TxnState.ABORTED + self._end_own_transaction_span() await self._unpin() async def _finish_transaction_with_retry(self, command_name: str) -> dict[str, Any]: diff --git a/pymongo/asynchronous/collection.py b/pymongo/asynchronous/collection.py index ddffc46632..7f18ec7160 100644 --- a/pymongo/asynchronous/collection.py +++ b/pymongo/asynchronous/collection.py @@ -2584,8 +2584,13 @@ async def _cmd( return cmd_cursor async with self._database.client._tmp_session(session) as s: - return await self._database.client._retryable_read( - _cmd, read_pref, s, operation=_Op.LIST_INDEXES + return await self._database.client._retryable_read_cursor( + _cmd, + read_pref, + s, + operation=_Op.LIST_INDEXES, + dbname=self._database.name, + collection=self._name, ) async def index_information( @@ -2683,12 +2688,14 @@ async def list_search_indexes( user_fields={"cursor": {"firstBatch": 1}}, ) - return await self._database.client._retryable_read( + return await self._database.client._retryable_read_cursor( cmd.get_cursor, cmd.get_read_preference(session), # type: ignore[arg-type] session, retryable=not cmd._performs_write, operation=_Op.LIST_SEARCH_INDEX, + dbname=self._database.name, + collection=self.name, ) async def create_search_index( @@ -2932,13 +2939,15 @@ async def _aggregate( user_fields={"cursor": {"firstBatch": 1}}, ) - return await self._database.client._retryable_read( + return await self._database.client._retryable_read_cursor( cmd.get_cursor, cmd.get_read_preference(session), # type: ignore[arg-type] session, retryable=not cmd._performs_write, operation=_Op.AGGREGATE, is_aggregate_write=cmd._performs_write, + dbname=self._database.name, + collection=self._name, ) async def aggregate( diff --git a/pymongo/asynchronous/command_cursor.py b/pymongo/asynchronous/command_cursor.py index 71404281a4..8b54fea6c0 100644 --- a/pymongo/asynchronous/command_cursor.py +++ b/pymongo/asynchronous/command_cursor.py @@ -29,6 +29,7 @@ from pymongo.asynchronous.cursor_base import _AsyncCursorBase, _ConnectionManager from pymongo.cursor_shared import _CURSOR_CLOSED_ERRORS from pymongo.errors import ConnectionFailure, InvalidOperation, OperationFailure +from pymongo.helpers_shared import _split_namespace from pymongo.message import _GetMore, _OpMsg, _RawBatchGetMore from pymongo.response import PinnedResponse from pymongo.typings import _Address, _DocumentOut, _DocumentType @@ -173,9 +174,14 @@ async def _send_message(self, operation: _GetMore) -> None: client = self._collection.database.client try: response = await client._run_operation( - operation, self._run_with_conn, address=self._address + operation, + self._run_with_conn, + address=self._address, + operation_telemetry=self._operation_telemetry, + reuse_current_span=self._reuse_current_span_for_getmore, ) except OperationFailure as exc: + self._end_operation_telemetry(exc) if exc.code in _CURSOR_CLOSED_ERRORS: # Don't send killCursors because the cursor is already closed. self._killed = True @@ -185,13 +191,15 @@ async def _send_message(self, operation: _GetMore) -> None: # Return the session and pinned connection, if necessary. await self.close() raise - except ConnectionFailure: + except ConnectionFailure as exc: + self._end_operation_telemetry(exc) # Don't send killCursors because the cursor is already closed. self._killed = True # Return the session and pinned connection, if necessary. await self.close() raise - except Exception: + except Exception as exc: + self._end_operation_telemetry(exc) await self.close() raise @@ -218,7 +226,7 @@ async def _refresh(self) -> int: return len(self._data) if self._id: # Get More - dbname, collname = self._ns.split(".", 1) + dbname, collname = _split_namespace(self._ns) read_pref = self._collection._read_preference_for(self.session) await self._send_message( self._getmore_class( diff --git a/pymongo/asynchronous/cursor.py b/pymongo/asynchronous/cursor.py index 51c00a0b36..0ff10c29d7 100644 --- a/pymongo/asynchronous/cursor.py +++ b/pymongo/asynchronous/cursor.py @@ -34,6 +34,7 @@ from bson.code import Code from bson.son import SON from pymongo import helpers_shared +from pymongo._telemetry import _OperationTelemetry from pymongo.asynchronous.cursor_base import _AsyncCursorBase, _ConnectionManager from pymongo.asynchronous.helpers import anext from pymongo.collation import validate_collation_or_none @@ -980,9 +981,13 @@ async def _send_message(self, operation: Union[_Query, _GetMore]) -> None: try: response = await client._run_operation( - operation, self._run_with_conn, address=self._address + operation, + self._run_with_conn, + address=self._address, + operation_telemetry=self._operation_telemetry, ) except OperationFailure as exc: + self._end_operation_telemetry(exc) if exc.code in _CURSOR_CLOSED_ERRORS or self._exhaust: # Don't send killCursors because the cursor is already closed. self._killed = True @@ -1000,12 +1005,14 @@ async def _send_message(self, operation: Union[_Query, _GetMore]) -> None: ): return raise - except ConnectionFailure: + except ConnectionFailure as exc: + self._end_operation_telemetry(exc) self._killed = True await self.close() raise # Catch KeyboardInterrupt, CancelledError, etc. and cleanup. - except BaseException: + except BaseException as exc: + self._end_operation_telemetry(exc) await self.close() raise self._address = response.address @@ -1023,7 +1030,7 @@ async def _send_message(self, operation: Union[_Query, _GetMore]) -> None: # Update the namespace used for future getMore commands. ns = cursor.get("ns") if ns: - self._dbname, self._collname = ns.split(".", 1) + self._dbname, self._collname = helpers_shared._split_namespace(ns) else: documents = cursor["nextBatch"] self._data = deque(documents) @@ -1078,6 +1085,15 @@ async def _refresh(self) -> int: self._allow_disk_use, self._exhaust, ) + client = self._collection.database.client + self._operation_telemetry = _OperationTelemetry( + client.options.tracing, + q.name, + self._session, + dbname=self._collection.database.name, + collection=self._collection.name, + set_current=False, + ) await self._send_message(q) elif self._id: # Get More if self._limit: diff --git a/pymongo/asynchronous/cursor_base.py b/pymongo/asynchronous/cursor_base.py index e8ac4c3139..870120f78a 100644 --- a/pymongo/asynchronous/cursor_base.py +++ b/pymongo/asynchronous/cursor_base.py @@ -175,6 +175,7 @@ async def _die_lock(self) -> None: # ___init__ did not run to completion (or at all). return + self._end_operation_telemetry() cursor_id, address = self._prepare_to_die(already_killed) await self._collection.database.client._cleanup_cursor_lock( cursor_id, diff --git a/pymongo/asynchronous/database.py b/pymongo/asynchronous/database.py index 88ca9cab5b..96971fdf2d 100644 --- a/pymongo/asynchronous/database.py +++ b/pymongo/asynchronous/database.py @@ -708,12 +708,13 @@ async def aggregate( kwargs, user_fields={"cursor": {"firstBatch": 1}}, ) - return await self.client._retryable_read( + return await self.client._retryable_read_cursor( cmd.get_cursor, cmd.get_read_preference(s), # type: ignore[arg-type] s, retryable=not cmd._performs_write, operation=_Op.AGGREGATE, + dbname=self.name, ) @overload @@ -1051,8 +1052,8 @@ async def inner( else: raise InvalidOperation("Command does not return a cursor.") - return await self.client._retryable_read( - inner, read_preference, tmp_session, command_name, None, False + return await self.client._retryable_read_cursor( + inner, read_preference, tmp_session, command_name, None, False, dbname=self.name ) async def _retryable_read_command( @@ -1149,8 +1150,8 @@ async def _cmd( conn, session, read_preference=read_preference, **kwargs ) - return await self._client._retryable_read( - _cmd, read_pref, session, operation=_Op.LIST_COLLECTIONS + return await self._client._retryable_read_cursor( + _cmd, read_pref, session, operation=_Op.LIST_COLLECTIONS, dbname=self.name ) async def list_collections( diff --git a/pymongo/asynchronous/mongo_client.py b/pymongo/asynchronous/mongo_client.py index 7d81eab506..c29d6b9382 100644 --- a/pymongo/asynchronous/mongo_client.py +++ b/pymongo/asynchronous/mongo_client.py @@ -58,7 +58,7 @@ from bson.codec_options import DEFAULT_CODEC_OPTIONS, CodecOptions, TypeRegistry from bson.timestamp import Timestamp from pymongo import _csot, _op_id, common, helpers_shared, periodic_executor -from pymongo._telemetry import log_command_retry +from pymongo._telemetry import _OperationTelemetry, log_command_retry from pymongo.asynchronous import client_session, database, uri_parser from pymongo.asynchronous.change_stream import AsyncChangeStream, AsyncClusterChangeStream from pymongo.asynchronous.client_bulk import _AsyncClientBulk @@ -149,6 +149,7 @@ T = TypeVar("T") +_CommandCursor = TypeVar("_CommandCursor", bound=AsyncCommandCursor[Any]) _WriteCall = Callable[ [Optional["AsyncClientSession"], "AsyncConnection", bool], Coroutine[Any, Any, T] @@ -621,7 +622,8 @@ def __init__( | **OpenTelemetry options:** | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) - - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: + - `tracing`: (dict) Configuration for OpenTelemetry command, operation, and + transaction spans, with keys: - ``enabled``: (boolean) Whether to create spans for server commands issued by this client. Defaults to ``False``. Also controlled by the @@ -637,7 +639,10 @@ def __init__( .. seealso:: The MongoDB documentation on `connections `_. .. versionchanged:: 4.18 - Added the ``tracing`` keyword argument. + Added the ``tracing`` keyword argument. Every public API call + produces an operation span, which contains one span per command + sent to the server. Inside a transaction, those operation spans + nest under a ``"transaction"`` span. .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. @@ -1739,7 +1744,13 @@ async def _end_sessions(self, session_ids: list[_ServerSession]) -> None: for i in range(0, len(session_ids), common._MAX_END_SESSIONS): spec = {"endSessions": session_ids[i : i + common._MAX_END_SESSIONS]} - await conn.command("admin", spec, read_preference=read_pref, client=self) + # endSessions deliberately bypasses _retry_internal (errors are + # ignored per spec, and it must not be retried), so its + # operation span is created here instead. + with _OperationTelemetry( + self.options.tracing, _Op.END_SESSIONS, None, dbname="admin" + ): + await conn.command("admin", spec, read_preference=read_pref, client=self) except PyMongoError: # Drivers MUST ignore any errors returned by the endSessions # command. @@ -1938,6 +1949,8 @@ async def _run_operation( operation: Union[_Query, _GetMore], run_with_conn: Callable, # type: ignore[type-arg] address: Optional[_Address] = None, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ) -> Response: """Run a _Query/_GetMore operation and return a Response. @@ -1946,6 +1959,12 @@ async def _run_operation( that executes the operation on a given connection. :param address: Optional address when sending a message to a specific server, used for getMore. + :param operation_telemetry: The cursor's caller-owned operation span, shared + across its initial query and every getMore, or None. + :param reuse_current_span: Create no operation span at all and leave the + ambient span in place as the parent for this operation's command + spans. Mutually exclusive with ``operation_telemetry``. Defaults to + False. """ if operation.conn_mgr: server = await self._select_server( @@ -1958,9 +1977,19 @@ async def _run_operation( async with operation.conn_mgr._lock: async with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type] err_handler.contribute_socket(operation.conn_mgr.conn) - return await run_with_conn( - operation.conn_mgr.conn, operation, operation.read_preference - ) + # Exhaust/pinned cursors bypass _retryable_read (and thus + # _retry_internal) entirely, so make the caller's operation + # span current here instead, so its getMore command spans + # still nest under it. contextlib.nullcontext when there's + # no caller-owned span (or tracing is disabled). + with ( + operation_telemetry.use() + if operation_telemetry + else contextlib.nullcontext() + ): + return await run_with_conn( + operation.conn_mgr.conn, operation, operation.read_preference + ) async def _cmd( _session: Optional[AsyncClientSession], @@ -1978,6 +2007,8 @@ async def _cmd( address=address, retryable=isinstance(operation, _Query), operation=operation.name, + operation_telemetry=operation_telemetry, + reuse_current_span=reuse_current_span, ) async def _retry_with_session( @@ -2024,6 +2055,8 @@ async def _retry_internal( operation_id: Optional[int] = None, is_run_command: bool = False, is_aggregate_write: bool = False, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ) -> T: """Internal retryable helper for all client transactions. @@ -2038,6 +2071,17 @@ async def _retry_internal( :param is_run_command: If this is a runCommand operation, defaults to False :param is_aggregate_write: If this is a aggregate operation with a write, defaults to False. :param operation_id: Stable operation id shared across retries, defaults to None + :param operation_telemetry: A caller-owned operation span outliving this call + (a cursor's, shared by its getMores). When given, this method neither + creates nor ends a span; it only makes the caller's current for this + call. Defaults to None, meaning this method owns a fresh span. + :param reuse_current_span: Create no operation span at all and leave the + ambient span in place as the parent for this operation's command + spans. For callers that know a suitable operation span is already + current, where a second one would be spurious (the client + bulk-write results cursor's getMores, which belong under the + enclosing bulkWrite span). Mutually exclusive with + ``operation_telemetry``. Defaults to False. :return: Output of the calling func() """ @@ -2054,6 +2098,8 @@ async def _retry_internal( operation_id=operation_id, is_run_command=is_run_command, is_aggregate_write=is_aggregate_write, + operation_telemetry=operation_telemetry, + reuse_current_span=reuse_current_span, ).run() async def _retryable_read( @@ -2067,6 +2113,8 @@ async def _retryable_read( operation_id: Optional[int] = None, is_run_command: bool = False, is_aggregate_write: bool = False, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ) -> T: """Execute an operation with consecutive retries if possible @@ -2085,6 +2133,12 @@ async def _retryable_read( :param is_run_command: If this is a runCommand operation, defaults to False. :param is_aggregate_write: If this is a aggregate operation with a write, defaults to False. :param operation_id: Stable operation id shared across retries, defaults to None + :param operation_telemetry: A caller-owned operation span outliving this call, + defaults to None, meaning this method owns a fresh span. + :param reuse_current_span: Create no operation span at all and leave the + ambient span in place as the parent for this operation's command + spans. Mutually exclusive with ``operation_telemetry``. Defaults to + False. """ # Ensure that the client supports retrying on reads and there is no session in @@ -2105,8 +2159,61 @@ async def _retryable_read( operation_id=operation_id, is_run_command=is_run_command, is_aggregate_write=is_aggregate_write, + reuse_current_span=reuse_current_span, + operation_telemetry=operation_telemetry, ) + async def _retryable_read_cursor( + self, + func: _ReadCall[_CommandCursor], + read_pref: _ServerMode, + session: Optional[AsyncClientSession], + operation: str, + address: Optional[_Address] = None, + retryable: bool = True, + operation_id: Optional[int] = None, + is_run_command: bool = False, + is_aggregate_write: bool = False, + *, + dbname: str, + collection: Optional[str] = None, + ) -> _CommandCursor: + """Run a command-cursor read, nesting its getMores under one operation span. + + Takes the same arguments as :meth:`_retryable_read`, plus the namespace + for the span. A command cursor's first batch is fetched inside that + call, before the cursor exists, so the span cannot be owned by the + cursor the way a find cursor's is. Create it here, keep it open across + the call, and hand it to the cursor that comes back so every later + getMore nests under the operation that produced the cursor. + """ + operation_telemetry = _OperationTelemetry( + self.options.tracing, + operation, + session, + dbname=dbname, + collection=collection, + set_current=False, + ) + try: + cmd_cursor = await self._retryable_read( + func, + read_pref, + session, + operation, + address, + retryable, + operation_id, + is_run_command, + is_aggregate_write, + operation_telemetry=operation_telemetry, + ) + except BaseException as exc: + operation_telemetry.failed(exc) + raise + cmd_cursor._attach_operation_telemetry(operation_telemetry) + return cmd_cursor + async def _retryable_write( self, retryable: bool, @@ -2243,9 +2350,14 @@ async def _kill_cursor_impl( conn: AsyncConnection, ) -> None: namespace = address.namespace - db, coll = namespace.split(".", 1) + db, coll = helpers_shared._split_namespace(namespace) spec = {"killCursors": coll, "cursors": cursor_ids} - await conn.command(db, spec, session=session, client=self) + # killCursors deliberately bypasses _retry_internal (it must never be + # retried), so its operation span is created here instead. + with _OperationTelemetry( + self.options.tracing, _Op.KILL_CURSORS, session, dbname=db, collection=coll + ): + await conn.command(db, spec, session=session, client=self) async def _process_kill_cursors(self) -> None: """Process any pending kill cursors requests.""" @@ -2792,6 +2904,8 @@ def __init__( operation_id: Optional[int] = None, is_run_command: bool = False, is_aggregate_write: bool = False, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ): self._last_error: Optional[Exception] = None self._retrying = False @@ -2823,12 +2937,42 @@ def __init__( ): operation_id = _randint() self._operation_id = operation_id + if reuse_current_span and operation_telemetry is not None: + raise ValueError("reuse_current_span and operation_telemetry are mutually exclusive") + # An operation span covering every attempt of this operation. A caller + # that needs the span to outlive this object (a cursor, whose getMores + # belong under the operation that created it) passes its own and keeps + # ownership; reuse_current_span means an enclosing span is already + # current and a second one would be spurious. Otherwise this object + # owns the span for its own lifetime. + self._owns_telemetry = operation_telemetry is None and not reuse_current_span + if self._owns_telemetry: + operation_telemetry = _OperationTelemetry( + mongo_client.options.tracing, operation, session, is_run_command=is_run_command + ) + self._operation_telemetry = operation_telemetry self._attempt_number = 0 self._is_run_command = is_run_command self._is_aggregate_write = is_aggregate_write self._base_backoff_ms: Optional[float] = None async def run(self) -> T: + """Run the operation, retrying as allowed, within its operation span.""" + if self._operation_telemetry is None: + return await self._run() + if not self._owns_telemetry: + with self._operation_telemetry.use(): + return await self._run() + try: + result = await self._run() + except BaseException as exc: + self._operation_telemetry.failed(exc) + raise + else: + self._operation_telemetry.succeeded() + return result + + async def _run(self) -> T: """Runs the supplied func() and attempts a retry :raises: self._last_error: Last exception raised diff --git a/pymongo/cursor_shared.py b/pymongo/cursor_shared.py index df0e1e2f58..2735b3ee05 100644 --- a/pymongo/cursor_shared.py +++ b/pymongo/cursor_shared.py @@ -55,6 +55,11 @@ class _AgnosticCursorBase(Generic[_DocumentType], ABC): _sock_mgr: Any _session: Optional[Any] _killed: bool + _operation_telemetry: Optional[Any] = None + # Set by callers whose getMores belong under an operation span that is + # already current (the client bulk-write results cursor), rather than under + # a getMore operation span of their own. + _reuse_current_span_for_getmore: bool = False @abstractmethod def _get_namespace(self) -> str: @@ -115,6 +120,42 @@ def _prepare_to_die(self, already_killed: bool) -> tuple[int, Optional[_CursorAd address = None return cursor_id, address + def _end_operation_telemetry(self, exc: Optional[BaseException] = None) -> None: + """End this cursor's operation span, exactly once. + + The span covers the cursor's whole lifetime (its initial query plus + every getMore), so it is ended by whichever comes first: exhaustion, + an explicit close(), or __del__ for an abandoned cursor. Idempotent, so + all three paths can call it unconditionally. + """ + telemetry = self._operation_telemetry + if telemetry is None: + return + self._operation_telemetry = None + if exc is None: + telemetry.succeeded() + else: + telemetry.failed(exc) + + def _attach_operation_telemetry(self, telemetry: Any) -> None: + """Attach a command cursor's already-started operation span. + + For AsyncCommandCursor/CommandCursor only. AsyncCursor/Cursor attach + their span before the query even runs, which is already correct. + + A command cursor whose first batch exhausts it is marked ``_killed`` + in ``__init__`` without any call to ``close()``. No getMore is ever + sent, so ``_refresh()``/``_die_lock()`` never run, leaving an explicit + ``close()`` or ``__del__`` as the only thing that would end the span. + That means whenever GC happens to run, or never at all if the cursor + is retained. Ending the span here instead makes a single-batch cursor + end it promptly at construction, matching how a multi-batch cursor + ends it promptly at exhaustion. + """ + self._operation_telemetry = telemetry + if self._killed: + self._end_operation_telemetry() + def _die_no_lock(self) -> None: """Closes this cursor without acquiring a lock.""" try: @@ -123,6 +164,7 @@ def _die_no_lock(self) -> None: # ___init__ did not run to completion (or at all). return + self._end_operation_telemetry() cursor_id, address = self._prepare_to_die(already_killed) self._collection.database.client._cleanup_cursor_no_lock( cursor_id, address, self._sock_mgr, self._session diff --git a/pymongo/helpers_shared.py b/pymongo/helpers_shared.py index ccd1b942e3..06f39acbd3 100644 --- a/pymongo/helpers_shared.py +++ b/pymongo/helpers_shared.py @@ -135,6 +135,17 @@ def format_timeout_details(details: Optional[dict[str, float]]) -> str: return result +def _split_namespace(namespace: str) -> tuple[str, str]: + """Split a ``"dbname.collname"`` namespace into its two parts. + + Collection names may contain dots, so only the first separator is + significant. If no dot is present, the entire string is treated as the + database name and the collection name is empty. + """ + dbname, _, collname = namespace.partition(".") + return dbname, collname + + def _gen_index_name(keys: _IndexList) -> str: """Generate an index name from the set of fields it is over.""" return "_".join(["{}_{}".format(*item) for item in keys]) diff --git a/pymongo/periodic_executor.py b/pymongo/periodic_executor.py index 4b979ca9f9..00b878d216 100644 --- a/pymongo/periodic_executor.py +++ b/pymongo/periodic_executor.py @@ -23,7 +23,7 @@ import weakref from typing import Any, Optional -from pymongo import _csot, _op_id +from pymongo import _csot, _op_id, _otel from pymongo._asyncio_task import create_task from pymongo.lock import _create_lock @@ -94,9 +94,14 @@ def skip_sleep(self) -> None: self._skip_sleep = True async def _run(self) -> None: - # The CSOT and op id contextvars must be cleared inside the executor task before execution begins + # The CSOT, op id, and OpenTelemetry contextvars must be cleared inside + # the executor task before execution begins: create_task froze a copy of + # whatever context was current when this executor was opened, which for + # the kill-cursors executor is the middle of the client's first + # operation. _csot.reset_all() _op_id.reset() + _otel.reset_context() while not self._stopped: if self._task and self._task.cancelling(): # type: ignore[unused-ignore, attr-defined] raise asyncio.CancelledError diff --git a/pymongo/synchronous/aggregation.py b/pymongo/synchronous/aggregation.py index d540484fa8..dbc892c5e9 100644 --- a/pymongo/synchronous/aggregation.py +++ b/pymongo/synchronous/aggregation.py @@ -22,6 +22,7 @@ from pymongo import common from pymongo.collation import validate_collation_or_none from pymongo.errors import ConfigurationError +from pymongo.helpers_shared import _split_namespace from pymongo.read_preferences import ReadPreference, _AggWritePref if TYPE_CHECKING: @@ -251,5 +252,5 @@ def _cursor_collection(self, cursor: Mapping[str, Any]) -> Collection[Any]: # Collection level aggregate may not always return the "ns" field # according to our MockupDB tests. Let's handle that case for db level # aggregate too by defaulting to the .$cmd.aggregate namespace. - _, collname = cursor.get("ns", self._cursor_namespace).split(".", 1) + _, collname = _split_namespace(cursor.get("ns", self._cursor_namespace)) return self._database[collname] diff --git a/pymongo/synchronous/change_stream.py b/pymongo/synchronous/change_stream.py index f442cc6c67..8b5ac31a44 100644 --- a/pymongo/synchronous/change_stream.py +++ b/pymongo/synchronous/change_stream.py @@ -248,6 +248,14 @@ def _run_aggregation_cmd(self, session: Optional[ClientSession]) -> CommandCurso result_processor=self._process_result, comment=self._comment, ) + # Deliberately no operation_telemetry is attached to the resulting + # cursor here: a change stream can tail indefinitely, so an operation + # span covering its whole lifetime (initial query + every getMore, + # like other command cursors) would never end while it's watching. + # Leaving it unattached means each getMore instead gets its own + # short-lived sibling "getMore" operation span, less ideal nesting, + # but not a leaked/never-exported span. Do not "fix" this without + # addressing that tradeoff. return self._client._retryable_read( cmd.get_cursor, self._target._read_preference_for(session), diff --git a/pymongo/synchronous/client_bulk.py b/pymongo/synchronous/client_bulk.py index 4cf1d9dbb0..10b4b9306d 100644 --- a/pymongo/synchronous/client_bulk.py +++ b/pymongo/synchronous/client_bulk.py @@ -32,6 +32,7 @@ from bson.objectid import ObjectId from bson.raw_bson import RawBSONDocument from pymongo import _csot, common +from pymongo._telemetry import _OperationTelemetry from pymongo.synchronous.client_session import ( ClientSession, _validate_session_write_concern, @@ -333,6 +334,12 @@ def _process_results_cursor( session=session, comment=self.comment, ) + # This cursor's getMores run inside the enclosing bulkWrite + # operation span, so their command spans belong under it directly; + # a getMore operation span of their own would be spurious. The + # cursor is also per-batch and never surfaces to the caller, so + # there is no cursor-lifetime span to own here. + cmd_cursor._reuse_current_span_for_getmore = True cmd_cursor._maybe_pin_connection(conn) # Iterate the cursor to get individual write results. @@ -628,13 +635,26 @@ def execute( session = _validate_session_write_concern(session, self.write_concern) if not self.write_concern.acknowledged: - with self.client._conn_for_writes(session, operation) as connection: - if connection.max_wire_version < 25: - raise InvalidOperation( - "MongoClient.bulk_write requires MongoDB server version 8.0+." - ) - self.execute_no_results(connection) - return ClientBulkWriteResult(None, False, False) # type: ignore[arg-type] + # This path never reaches the command-span code that would + # otherwise fill in the span's namespace, so pass it here. A client + # bulk write always runs against admin and spans multiple + # namespaces, so there is no single collection to report. + operation_telemetry = _OperationTelemetry( + self.client.options.tracing, operation, session, dbname="admin" + ) + try: + with self.client._conn_for_writes(session, operation) as connection: + if connection.max_wire_version < 25: + raise InvalidOperation( + "MongoClient.bulk_write requires MongoDB server version 8.0+." + ) + self.execute_no_results(connection) + except BaseException as exc: + operation_telemetry.failed(exc) + raise + else: + operation_telemetry.succeeded() + return ClientBulkWriteResult(None, False, False) # type: ignore[arg-type] result = self.execute_command(session, operation) return ClientBulkWriteResult( diff --git a/pymongo/synchronous/client_session.py b/pymongo/synchronous/client_session.py index 0774c182de..47a133d7e7 100644 --- a/pymongo/synchronous/client_session.py +++ b/pymongo/synchronous/client_session.py @@ -155,7 +155,7 @@ from bson.binary import Binary from bson.int64 import Int64 from bson.timestamp import Timestamp -from pymongo import _csot +from pymongo import _csot, _otel from pymongo.errors import ( ConfigurationError, ConnectionFailure, @@ -426,6 +426,7 @@ def __init__(self, opts: Optional[TransactionOptions], client: MongoClient[Any]) self.attempt = 0 self.client = client self.has_completed_command = False + self.span: Optional[Any] = None def active(self) -> bool: return self.state in (_TxnState.STARTING, _TxnState.IN_PROGRESS) @@ -466,6 +467,7 @@ def reset(self) -> None: self.recovery_token = None self.attempt = 0 self.has_completed_command = False + self.span = None def __del__(self) -> None: if self.conn_mgr: @@ -561,6 +563,12 @@ def __init__( # Is this an implicitly created session? self._implicit = implicit self._transaction = _Transaction(None, client) + # The one "transaction" span shared across every retry of a single + # with_transaction() call, or None outside of with_transaction (in + # which case start_transaction/commit_transaction/abort_transaction + # each manage their own span exactly as they did before with_transaction + # existed). See with_transaction's docstring/comments for details. + self._with_transaction_span: Optional[Any] = None # Is this session attached to a cursor? self._attached_to_cursor = False # Should we leave the session alive when the cursor is closed? @@ -768,6 +776,44 @@ def callback(session, custom_arg, custom_kwarg=None): .. _transactions specification: https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/transactions-convenient-api.md#handling-errors-inside-the-callback """ + if self._with_transaction_span is not None: + # Nested/concurrent with_transaction() calls on one session are + # unsupported. Raise here, before any span bookkeeping, so we + # don't silently clobber and leak the outer call's span. + raise InvalidOperation( + "Cannot call with_transaction() while a previous with_transaction() " + "call on this session has not returned; sessions do not support " + "nested or concurrent with_transaction() calls" + ) + # One span for the whole call: start_transaction reuses it instead of + # creating one per retry, and commit/abort leave it open, so a + # retried with_transaction() yields a single span. Skipped when a + # direct-API transaction is already active, because start_transaction() + # will raise immediately below and the span would record nothing. + if not self.in_transaction: + self._with_transaction_span = _otel.start_transaction_span(self._client.options.tracing) + try: + return self._with_transaction_retry_loop( + callback, read_concern, write_concern, read_preference, max_commit_time_ms + ) + finally: + _otel.end_transaction_span(self._with_transaction_span) + # Only clear the span this call owns. If a direct-API transaction + # was already active, self._transaction.span belongs to that + # transaction and must not be nulled here. + if self._transaction.span is self._with_transaction_span: + self._transaction.span = None + self._with_transaction_span = None + + def _with_transaction_retry_loop( + self, + callback: Callable[[ClientSession], _T], + read_concern: Optional[ReadConcern], + write_concern: Optional[WriteConcern], + read_preference: Optional[_ServerMode], + max_commit_time_ms: Optional[int], + ) -> _T: + """Run with_transaction's retry loop; see with_transaction.""" start_time = time.monotonic() retry = 0 last_error: Optional[BaseException] = None @@ -861,9 +907,31 @@ def start_transaction( ) self._transaction.reset() self._transaction.state = _TxnState.STARTING + if self._with_transaction_span is not None: + # with_transaction() is retrying the whole transaction: reuse its + # one shared span instead of starting a new one, so a retried + # with_transaction() still produces exactly one "transaction" span. + self._transaction.span = self._with_transaction_span + else: + self._transaction.span = _otel.start_transaction_span( + self._transaction.client.options.tracing + ) self._start_retryable_write() return _TransactionContext(self) + def _end_own_transaction_span(self) -> None: + """End and clear the transaction span, unless with_transaction() owns it. + + with_transaction() pins one shared span across all of its retries in + ``self._with_transaction_span`` (see its comments); while that's set, + the span must survive until with_transaction() itself ends it, so this + is a no-op here. Otherwise a retried with_transaction() would end the + shared span prematurely on the first failed attempt. + """ + if self._with_transaction_span is None: + _otel.end_transaction_span(self._transaction.span) + self._transaction.span = None + def commit_transaction(self) -> None: """Commit a multi-statement transaction. @@ -876,6 +944,7 @@ def commit_transaction(self) -> None: elif state in (_TxnState.STARTING, _TxnState.COMMITTED_EMPTY): # Server transaction was never started, no need to send a command. self._transaction.state = _TxnState.COMMITTED_EMPTY + self._end_own_transaction_span() return elif state is _TxnState.ABORTED: raise InvalidOperation("Cannot call commitTransaction after calling abortTransaction") @@ -883,6 +952,16 @@ def commit_transaction(self) -> None: # We're explicitly retrying the commit, move the state back to # "in progress" so that in_transaction returns true. self._transaction.state = _TxnState.IN_PROGRESS + # Outside of with_transaction() (which pins its shared span across + # this transition, see _end_own_transaction_span), the prior + # attempt's finally block already ended and cleared the + # transaction span, so this direct-API retry needs a fresh one; + # otherwise it would run with no transaction span and its command + # span would have no parent. + if self._transaction.span is None: + self._transaction.span = _otel.start_transaction_span( + self._transaction.client.options.tracing + ) try: self._finish_transaction_with_retry("commitTransaction") @@ -906,6 +985,7 @@ def commit_transaction(self) -> None: _reraise_with_unknown_commit(exc) finally: self._transaction.state = _TxnState.COMMITTED + self._end_own_transaction_span() def abort_transaction(self) -> None: """Abort a multi-statement transaction. @@ -920,6 +1000,7 @@ def abort_transaction(self) -> None: elif state is _TxnState.STARTING: # Server transaction was never started, no need to send a command. self._transaction.state = _TxnState.ABORTED + self._end_own_transaction_span() return elif state is _TxnState.ABORTED: raise InvalidOperation("Cannot call abortTransaction twice") @@ -933,6 +1014,7 @@ def abort_transaction(self) -> None: pass finally: self._transaction.state = _TxnState.ABORTED + self._end_own_transaction_span() self._unpin() def _finish_transaction_with_retry(self, command_name: str) -> dict[str, Any]: diff --git a/pymongo/synchronous/collection.py b/pymongo/synchronous/collection.py index 15a38b2f3e..aa2df2712d 100644 --- a/pymongo/synchronous/collection.py +++ b/pymongo/synchronous/collection.py @@ -2580,8 +2580,13 @@ def _cmd( return cmd_cursor with self._database.client._tmp_session(session) as s: - return self._database.client._retryable_read( - _cmd, read_pref, s, operation=_Op.LIST_INDEXES + return self._database.client._retryable_read_cursor( + _cmd, + read_pref, + s, + operation=_Op.LIST_INDEXES, + dbname=self._database.name, + collection=self._name, ) def index_information( @@ -2679,12 +2684,14 @@ def list_search_indexes( user_fields={"cursor": {"firstBatch": 1}}, ) - return self._database.client._retryable_read( + return self._database.client._retryable_read_cursor( cmd.get_cursor, cmd.get_read_preference(session), # type: ignore[arg-type] session, retryable=not cmd._performs_write, operation=_Op.LIST_SEARCH_INDEX, + dbname=self._database.name, + collection=self.name, ) def create_search_index( @@ -2926,13 +2933,15 @@ def _aggregate( user_fields={"cursor": {"firstBatch": 1}}, ) - return self._database.client._retryable_read( + return self._database.client._retryable_read_cursor( cmd.get_cursor, cmd.get_read_preference(session), # type: ignore[arg-type] session, retryable=not cmd._performs_write, operation=_Op.AGGREGATE, is_aggregate_write=cmd._performs_write, + dbname=self._database.name, + collection=self._name, ) def aggregate( diff --git a/pymongo/synchronous/command_cursor.py b/pymongo/synchronous/command_cursor.py index 8868d87939..bda5e705b3 100644 --- a/pymongo/synchronous/command_cursor.py +++ b/pymongo/synchronous/command_cursor.py @@ -28,6 +28,7 @@ from bson import CodecOptions, _convert_raw_document_lists_to_streams from pymongo.cursor_shared import _CURSOR_CLOSED_ERRORS from pymongo.errors import ConnectionFailure, InvalidOperation, OperationFailure +from pymongo.helpers_shared import _split_namespace from pymongo.message import _GetMore, _OpMsg, _RawBatchGetMore from pymongo.response import PinnedResponse from pymongo.synchronous.cursor_base import _ConnectionManager, _CursorBase @@ -172,8 +173,15 @@ def _send_message(self, operation: _GetMore) -> None: """Send a getmore message and handle the response.""" client = self._collection.database.client try: - response = client._run_operation(operation, self._run_with_conn, address=self._address) + response = client._run_operation( + operation, + self._run_with_conn, + address=self._address, + operation_telemetry=self._operation_telemetry, + reuse_current_span=self._reuse_current_span_for_getmore, + ) except OperationFailure as exc: + self._end_operation_telemetry(exc) if exc.code in _CURSOR_CLOSED_ERRORS: # Don't send killCursors because the cursor is already closed. self._killed = True @@ -183,13 +191,15 @@ def _send_message(self, operation: _GetMore) -> None: # Return the session and pinned connection, if necessary. self.close() raise - except ConnectionFailure: + except ConnectionFailure as exc: + self._end_operation_telemetry(exc) # Don't send killCursors because the cursor is already closed. self._killed = True # Return the session and pinned connection, if necessary. self.close() raise - except Exception: + except Exception as exc: + self._end_operation_telemetry(exc) self.close() raise @@ -216,7 +226,7 @@ def _refresh(self) -> int: return len(self._data) if self._id: # Get More - dbname, collname = self._ns.split(".", 1) + dbname, collname = _split_namespace(self._ns) read_pref = self._collection._read_preference_for(self.session) self._send_message( self._getmore_class( diff --git a/pymongo/synchronous/cursor.py b/pymongo/synchronous/cursor.py index 7ea81ea9ac..769c7f7802 100644 --- a/pymongo/synchronous/cursor.py +++ b/pymongo/synchronous/cursor.py @@ -34,6 +34,7 @@ from bson.code import Code from bson.son import SON from pymongo import helpers_shared +from pymongo._telemetry import _OperationTelemetry from pymongo.collation import validate_collation_or_none from pymongo.common import ( validate_is_document_type, @@ -977,8 +978,14 @@ def _send_message(self, operation: Union[_Query, _GetMore]) -> None: raise InvalidOperation("exhaust cursors do not support auto encryption") try: - response = client._run_operation(operation, self._run_with_conn, address=self._address) + response = client._run_operation( + operation, + self._run_with_conn, + address=self._address, + operation_telemetry=self._operation_telemetry, + ) except OperationFailure as exc: + self._end_operation_telemetry(exc) if exc.code in _CURSOR_CLOSED_ERRORS or self._exhaust: # Don't send killCursors because the cursor is already closed. self._killed = True @@ -996,12 +1003,14 @@ def _send_message(self, operation: Union[_Query, _GetMore]) -> None: ): return raise - except ConnectionFailure: + except ConnectionFailure as exc: + self._end_operation_telemetry(exc) self._killed = True self.close() raise # Catch KeyboardInterrupt, CancelledError, etc. and cleanup. - except BaseException: + except BaseException as exc: + self._end_operation_telemetry(exc) self.close() raise self._address = response.address @@ -1019,7 +1028,7 @@ def _send_message(self, operation: Union[_Query, _GetMore]) -> None: # Update the namespace used for future getMore commands. ns = cursor.get("ns") if ns: - self._dbname, self._collname = ns.split(".", 1) + self._dbname, self._collname = helpers_shared._split_namespace(ns) else: documents = cursor["nextBatch"] self._data = deque(documents) @@ -1074,6 +1083,15 @@ def _refresh(self) -> int: self._allow_disk_use, self._exhaust, ) + client = self._collection.database.client + self._operation_telemetry = _OperationTelemetry( + client.options.tracing, + q.name, + self._session, + dbname=self._collection.database.name, + collection=self._collection.name, + set_current=False, + ) self._send_message(q) elif self._id: # Get More if self._limit: diff --git a/pymongo/synchronous/cursor_base.py b/pymongo/synchronous/cursor_base.py index 4cad4e0c09..03686e8051 100644 --- a/pymongo/synchronous/cursor_base.py +++ b/pymongo/synchronous/cursor_base.py @@ -175,6 +175,7 @@ def _die_lock(self) -> None: # ___init__ did not run to completion (or at all). return + self._end_operation_telemetry() cursor_id, address = self._prepare_to_die(already_killed) self._collection.database.client._cleanup_cursor_lock( cursor_id, diff --git a/pymongo/synchronous/database.py b/pymongo/synchronous/database.py index 65c5d2a7f0..2f9b1a7eb8 100644 --- a/pymongo/synchronous/database.py +++ b/pymongo/synchronous/database.py @@ -708,12 +708,13 @@ def aggregate( kwargs, user_fields={"cursor": {"firstBatch": 1}}, ) - return self.client._retryable_read( + return self.client._retryable_read_cursor( cmd.get_cursor, cmd.get_read_preference(s), # type: ignore[arg-type] s, retryable=not cmd._performs_write, operation=_Op.AGGREGATE, + dbname=self.name, ) @overload @@ -1051,8 +1052,8 @@ def inner( else: raise InvalidOperation("Command does not return a cursor.") - return self.client._retryable_read( - inner, read_preference, tmp_session, command_name, None, False + return self.client._retryable_read_cursor( + inner, read_preference, tmp_session, command_name, None, False, dbname=self.name ) def _retryable_read_command( @@ -1147,8 +1148,8 @@ def _cmd( ) -> CommandCursor[MutableMapping[str, Any]]: return self._list_collections(conn, session, read_preference=read_preference, **kwargs) - return self._client._retryable_read( - _cmd, read_pref, session, operation=_Op.LIST_COLLECTIONS + return self._client._retryable_read_cursor( + _cmd, read_pref, session, operation=_Op.LIST_COLLECTIONS, dbname=self.name ) def list_collections( diff --git a/pymongo/synchronous/mongo_client.py b/pymongo/synchronous/mongo_client.py index 511a7172cb..08c033cd33 100644 --- a/pymongo/synchronous/mongo_client.py +++ b/pymongo/synchronous/mongo_client.py @@ -58,7 +58,7 @@ from bson.codec_options import DEFAULT_CODEC_OPTIONS, CodecOptions, TypeRegistry from bson.timestamp import Timestamp from pymongo import _csot, _op_id, common, helpers_shared, periodic_executor -from pymongo._telemetry import log_command_retry +from pymongo._telemetry import _OperationTelemetry, log_command_retry from pymongo.client_options import ClientOptions from pymongo.driver_info import DriverInfo from pymongo.errors import ( @@ -149,6 +149,7 @@ T = TypeVar("T") +_CommandCursor = TypeVar("_CommandCursor", bound=CommandCursor[Any]) _WriteCall = Callable[[Optional["ClientSession"], "Connection", bool], T] _ReadCall = Callable[ @@ -622,7 +623,8 @@ def __init__( | **OpenTelemetry options:** | (Requires the ``opentelemetry-api`` package; install with the ``pymongo[opentelemetry]`` extra.) - - `tracing`: (dict) Configuration for OpenTelemetry command spans, with keys: + - `tracing`: (dict) Configuration for OpenTelemetry command, operation, and + transaction spans, with keys: - ``enabled``: (boolean) Whether to create spans for server commands issued by this client. Defaults to ``False``. Also controlled by the @@ -638,7 +640,10 @@ def __init__( .. seealso:: The MongoDB documentation on `connections `_. .. versionchanged:: 4.18 - Added the ``tracing`` keyword argument. + Added the ``tracing`` keyword argument. Every public API call + produces an operation span, which contains one span per command + sent to the server. Inside a transaction, those operation spans + nest under a ``"transaction"`` span. .. versionchanged:: 4.17 Added the ``max_adaptive_retries`` and ``enable_overload_retargeting`` URI and keyword arguments. @@ -1736,7 +1741,13 @@ def _end_sessions(self, session_ids: list[_ServerSession]) -> None: for i in range(0, len(session_ids), common._MAX_END_SESSIONS): spec = {"endSessions": session_ids[i : i + common._MAX_END_SESSIONS]} - conn.command("admin", spec, read_preference=read_pref, client=self) + # endSessions deliberately bypasses _retry_internal (errors are + # ignored per spec, and it must not be retried), so its + # operation span is created here instead. + with _OperationTelemetry( + self.options.tracing, _Op.END_SESSIONS, None, dbname="admin" + ): + conn.command("admin", spec, read_preference=read_pref, client=self) except PyMongoError: # Drivers MUST ignore any errors returned by the endSessions # command. @@ -1935,6 +1946,8 @@ def _run_operation( operation: Union[_Query, _GetMore], run_with_conn: Callable, # type: ignore[type-arg] address: Optional[_Address] = None, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ) -> Response: """Run a _Query/_GetMore operation and return a Response. @@ -1943,6 +1956,12 @@ def _run_operation( that executes the operation on a given connection. :param address: Optional address when sending a message to a specific server, used for getMore. + :param operation_telemetry: The cursor's caller-owned operation span, shared + across its initial query and every getMore, or None. + :param reuse_current_span: Create no operation span at all and leave the + ambient span in place as the parent for this operation's command + spans. Mutually exclusive with ``operation_telemetry``. Defaults to + False. """ if operation.conn_mgr: server = self._select_server( @@ -1955,9 +1974,19 @@ def _run_operation( with operation.conn_mgr._lock: with _MongoClientErrorHandler(self, server, operation.session) as err_handler: # type: ignore[arg-type] err_handler.contribute_socket(operation.conn_mgr.conn) - return run_with_conn( - operation.conn_mgr.conn, operation, operation.read_preference - ) + # Exhaust/pinned cursors bypass _retryable_read (and thus + # _retry_internal) entirely, so make the caller's operation + # span current here instead, so its getMore command spans + # still nest under it. contextlib.nullcontext when there's + # no caller-owned span (or tracing is disabled). + with ( + operation_telemetry.use() + if operation_telemetry + else contextlib.nullcontext() + ): + return run_with_conn( + operation.conn_mgr.conn, operation, operation.read_preference + ) def _cmd( _session: Optional[ClientSession], @@ -1975,6 +2004,8 @@ def _cmd( address=address, retryable=isinstance(operation, _Query), operation=operation.name, + operation_telemetry=operation_telemetry, + reuse_current_span=reuse_current_span, ) def _retry_with_session( @@ -2021,6 +2052,8 @@ def _retry_internal( operation_id: Optional[int] = None, is_run_command: bool = False, is_aggregate_write: bool = False, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ) -> T: """Internal retryable helper for all client transactions. @@ -2035,6 +2068,17 @@ def _retry_internal( :param is_run_command: If this is a runCommand operation, defaults to False :param is_aggregate_write: If this is a aggregate operation with a write, defaults to False. :param operation_id: Stable operation id shared across retries, defaults to None + :param operation_telemetry: A caller-owned operation span outliving this call + (a cursor's, shared by its getMores). When given, this method neither + creates nor ends a span; it only makes the caller's current for this + call. Defaults to None, meaning this method owns a fresh span. + :param reuse_current_span: Create no operation span at all and leave the + ambient span in place as the parent for this operation's command + spans. For callers that know a suitable operation span is already + current, where a second one would be spurious (the client + bulk-write results cursor's getMores, which belong under the + enclosing bulkWrite span). Mutually exclusive with + ``operation_telemetry``. Defaults to False. :return: Output of the calling func() """ @@ -2051,6 +2095,8 @@ def _retry_internal( operation_id=operation_id, is_run_command=is_run_command, is_aggregate_write=is_aggregate_write, + operation_telemetry=operation_telemetry, + reuse_current_span=reuse_current_span, ).run() def _retryable_read( @@ -2064,6 +2110,8 @@ def _retryable_read( operation_id: Optional[int] = None, is_run_command: bool = False, is_aggregate_write: bool = False, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ) -> T: """Execute an operation with consecutive retries if possible @@ -2082,6 +2130,12 @@ def _retryable_read( :param is_run_command: If this is a runCommand operation, defaults to False. :param is_aggregate_write: If this is a aggregate operation with a write, defaults to False. :param operation_id: Stable operation id shared across retries, defaults to None + :param operation_telemetry: A caller-owned operation span outliving this call, + defaults to None, meaning this method owns a fresh span. + :param reuse_current_span: Create no operation span at all and leave the + ambient span in place as the parent for this operation's command + spans. Mutually exclusive with ``operation_telemetry``. Defaults to + False. """ # Ensure that the client supports retrying on reads and there is no session in @@ -2102,8 +2156,61 @@ def _retryable_read( operation_id=operation_id, is_run_command=is_run_command, is_aggregate_write=is_aggregate_write, + reuse_current_span=reuse_current_span, + operation_telemetry=operation_telemetry, ) + def _retryable_read_cursor( + self, + func: _ReadCall[_CommandCursor], + read_pref: _ServerMode, + session: Optional[ClientSession], + operation: str, + address: Optional[_Address] = None, + retryable: bool = True, + operation_id: Optional[int] = None, + is_run_command: bool = False, + is_aggregate_write: bool = False, + *, + dbname: str, + collection: Optional[str] = None, + ) -> _CommandCursor: + """Run a command-cursor read, nesting its getMores under one operation span. + + Takes the same arguments as :meth:`_retryable_read`, plus the namespace + for the span. A command cursor's first batch is fetched inside that + call, before the cursor exists, so the span cannot be owned by the + cursor the way a find cursor's is. Create it here, keep it open across + the call, and hand it to the cursor that comes back so every later + getMore nests under the operation that produced the cursor. + """ + operation_telemetry = _OperationTelemetry( + self.options.tracing, + operation, + session, + dbname=dbname, + collection=collection, + set_current=False, + ) + try: + cmd_cursor = self._retryable_read( + func, + read_pref, + session, + operation, + address, + retryable, + operation_id, + is_run_command, + is_aggregate_write, + operation_telemetry=operation_telemetry, + ) + except BaseException as exc: + operation_telemetry.failed(exc) + raise + cmd_cursor._attach_operation_telemetry(operation_telemetry) + return cmd_cursor + def _retryable_write( self, retryable: bool, @@ -2240,9 +2347,14 @@ def _kill_cursor_impl( conn: Connection, ) -> None: namespace = address.namespace - db, coll = namespace.split(".", 1) + db, coll = helpers_shared._split_namespace(namespace) spec = {"killCursors": coll, "cursors": cursor_ids} - conn.command(db, spec, session=session, client=self) + # killCursors deliberately bypasses _retry_internal (it must never be + # retried), so its operation span is created here instead. + with _OperationTelemetry( + self.options.tracing, _Op.KILL_CURSORS, session, dbname=db, collection=coll + ): + conn.command(db, spec, session=session, client=self) def _process_kill_cursors(self) -> None: """Process any pending kill cursors requests.""" @@ -2783,6 +2895,8 @@ def __init__( operation_id: Optional[int] = None, is_run_command: bool = False, is_aggregate_write: bool = False, + operation_telemetry: Optional[_OperationTelemetry] = None, + reuse_current_span: bool = False, ): self._last_error: Optional[Exception] = None self._retrying = False @@ -2814,12 +2928,42 @@ def __init__( ): operation_id = _randint() self._operation_id = operation_id + if reuse_current_span and operation_telemetry is not None: + raise ValueError("reuse_current_span and operation_telemetry are mutually exclusive") + # An operation span covering every attempt of this operation. A caller + # that needs the span to outlive this object (a cursor, whose getMores + # belong under the operation that created it) passes its own and keeps + # ownership; reuse_current_span means an enclosing span is already + # current and a second one would be spurious. Otherwise this object + # owns the span for its own lifetime. + self._owns_telemetry = operation_telemetry is None and not reuse_current_span + if self._owns_telemetry: + operation_telemetry = _OperationTelemetry( + mongo_client.options.tracing, operation, session, is_run_command=is_run_command + ) + self._operation_telemetry = operation_telemetry self._attempt_number = 0 self._is_run_command = is_run_command self._is_aggregate_write = is_aggregate_write self._base_backoff_ms: Optional[float] = None def run(self) -> T: + """Run the operation, retrying as allowed, within its operation span.""" + if self._operation_telemetry is None: + return self._run() + if not self._owns_telemetry: + with self._operation_telemetry.use(): + return self._run() + try: + result = self._run() + except BaseException as exc: + self._operation_telemetry.failed(exc) + raise + else: + self._operation_telemetry.succeeded() + return result + + def _run(self) -> T: """Runs the supplied func() and attempts a retry :raises: self._last_error: Last exception raised diff --git a/test/__init__.py b/test/__init__.py index f4ae7fe948..d53915d1c6 100644 --- a/test/__init__.py +++ b/test/__init__.py @@ -53,6 +53,7 @@ import pymongo import pymongo.errors from bson.son import SON +from pymongo import _otel from pymongo.common import partition_node from pymongo.hello import HelloCompat from pymongo.server_api import ServerApi @@ -859,6 +860,72 @@ def get_loop() -> asyncio.AbstractEventLoop: return LOOP +def _tracing_enabled_for_cleanup(client_kwargs) -> bool: + """Return True if a client created with ``client_kwargs`` is tracing-enabled. + + Mirrors ``pymongo._otel._is_tracing_enabled``'s two ways a client ends up tracing-enabled: + an explicit ``tracing={"enabled": True}`` kwarg, or the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable (see e.g. + test_otel.py's ``test_prose_1_tracing_enable_disable_via_env_var``, which patches the env + var around a client with no ``tracing=`` kwarg at all). So the join-based cleanup below gets + applied to every client that can actually emit otel spans, without imposing its cost on the + vast majority of clients that can't. Reuses ``pymongo._otel``'s own + ``_OTEL_ENABLED_ENV``/``_env_truthy`` rather than re-parsing the env var, so the test + harness can't disagree with the driver about what "enabled" means. + + Must be called at client-creation time (when this decision is captured + into which cleanup callable gets registered), not deferred into the + cleanup itself: ``test_prose_1_.../test_prose_2_...`` patch the env var + only around client creation with ``patch.dict(...)``, so it is back to + its original value by the time cleanups run. + """ + tracing_opts = client_kwargs.get("tracing") + if isinstance(tracing_opts, dict) and tracing_opts.get("enabled"): + return True + return _otel._env_truthy(_otel._OTEL_ENABLED_ENV) + + +def _close_and_join_monitors(client) -> None: + """Close ``client`` and wait for its background monitors to actually stop. + + Registered instead of a plain ``client.close()`` cleanup for + tracing-enabled test clients (see ``_tracing_enabled_for_cleanup``): a + monitor that is still mid-flight when the next test starts can emit its + own otel command span (e.g. for a heartbeat cancelled by close()) onto + the shared, process-wide TracerProvider used by the otel test suite (see + test.unified_format_shared._shared_test_provider), landing in whichever + other test's span-capture window happens to be open at that moment. + ``client.close()`` alone does not prevent this: + + - The sync driver's ``Monitor.join()`` wraps two synchronous ``.join()`` + calls in an ``asyncio.gather(...)`` that is never awaited (a leftover + of generating the sync file from the async source, where the + equivalent call is properly awaited), so it does not actually block. + - Neither sync ``Topology.close()``/``MongoClient.close()`` nor async + ``MongoClient.close()`` (for topologies where monitors aren't + registered in ``_monitor_tasks``) guarantee every monitor executor has + fully exited by the time ``close()`` returns. + + Bypass ``Monitor.join()`` and wait on each server's monitor executors + (and the RTT/streaming monitor, and any SRV monitor) directly instead. + """ + client.close() + topology = client._topology + if topology is None: + return + for server in topology._servers.values(): + monitor = server._monitor + executor = getattr(monitor, "_executor", None) + if executor is not None: + executor.join(5) + rtt_monitor = getattr(monitor, "_rtt_monitor", None) + if rtt_monitor is not None: + rtt_monitor._executor.join(5) + srv_monitor = getattr(topology, "_srv_monitor", None) + if srv_monitor is not None: + srv_monitor._executor.join(5) + + class PyMongoTestCase(unittest.TestCase): if not _IS_SYNC: # An async TestCase that uses a single event loop for all tests. @@ -1035,7 +1102,10 @@ def _async_mongo_client(self, host, port, authenticate=True, directConnection=No client = MongoClient(uri, port, **client_options) if client._options.connect: client._connect() - self.addCleanup(client.close) + if _tracing_enabled_for_cleanup(client_options): + self.addCleanup(_close_and_join_monitors, client) + else: + self.addCleanup(client.close) return client @classmethod @@ -1119,7 +1189,10 @@ def simple_client(self, h: Any = None, p: Any = None, **kwargs: Any) -> MongoCli client = MongoClient(**kwargs) else: client = MongoClient(h, p, **kwargs) - self.addCleanup(client.close) + if _tracing_enabled_for_cleanup(kwargs): + self.addCleanup(_close_and_join_monitors, client) + else: + self.addCleanup(client.close) return client @classmethod diff --git a/test/asynchronous/__init__.py b/test/asynchronous/__init__.py index 0699451d7e..7f1cebe219 100644 --- a/test/asynchronous/__init__.py +++ b/test/asynchronous/__init__.py @@ -53,6 +53,7 @@ import pymongo import pymongo.errors from bson.son import SON +from pymongo import _otel from pymongo.asynchronous.database import AsyncDatabase from pymongo.asynchronous.mongo_client import AsyncMongoClient from pymongo.common import partition_node @@ -859,6 +860,72 @@ def get_loop() -> asyncio.AbstractEventLoop: return LOOP +def _tracing_enabled_for_cleanup(client_kwargs) -> bool: + """Return True if a client created with ``client_kwargs`` is tracing-enabled. + + Mirrors ``pymongo._otel._is_tracing_enabled``'s two ways a client ends up tracing-enabled: + an explicit ``tracing={"enabled": True}`` kwarg, or the + ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable (see e.g. + test_otel.py's ``test_prose_1_tracing_enable_disable_via_env_var``, which patches the env + var around a client with no ``tracing=`` kwarg at all). So the join-based cleanup below gets + applied to every client that can actually emit otel spans, without imposing its cost on the + vast majority of clients that can't. Reuses ``pymongo._otel``'s own + ``_OTEL_ENABLED_ENV``/``_env_truthy`` rather than re-parsing the env var, so the test + harness can't disagree with the driver about what "enabled" means. + + Must be called at client-creation time (when this decision is captured + into which cleanup callable gets registered), not deferred into the + cleanup itself: ``test_prose_1_.../test_prose_2_...`` patch the env var + only around client creation with ``patch.dict(...)``, so it is back to + its original value by the time cleanups run. + """ + tracing_opts = client_kwargs.get("tracing") + if isinstance(tracing_opts, dict) and tracing_opts.get("enabled"): + return True + return _otel._env_truthy(_otel._OTEL_ENABLED_ENV) + + +async def _close_and_join_monitors(client) -> None: + """Close ``client`` and wait for its background monitors to actually stop. + + Registered instead of a plain ``client.close()`` cleanup for + tracing-enabled test clients (see ``_tracing_enabled_for_cleanup``): a + monitor that is still mid-flight when the next test starts can emit its + own otel command span (e.g. for a heartbeat cancelled by close()) onto + the shared, process-wide TracerProvider used by the otel test suite (see + test.unified_format_shared._shared_test_provider), landing in whichever + other test's span-capture window happens to be open at that moment. + ``client.close()`` alone does not prevent this: + + - The sync driver's ``Monitor.join()`` wraps two synchronous ``.join()`` + calls in an ``asyncio.gather(...)`` that is never awaited (a leftover + of generating the sync file from the async source, where the + equivalent call is properly awaited), so it does not actually block. + - Neither sync ``Topology.close()``/``MongoClient.close()`` nor async + ``AsyncMongoClient.close()`` (for topologies where monitors aren't + registered in ``_monitor_tasks``) guarantee every monitor executor has + fully exited by the time ``close()`` returns. + + Bypass ``Monitor.join()`` and wait on each server's monitor executors + (and the RTT/streaming monitor, and any SRV monitor) directly instead. + """ + await client.close() + topology = client._topology + if topology is None: + return + for server in topology._servers.values(): + monitor = server._monitor + executor = getattr(monitor, "_executor", None) + if executor is not None: + await executor.join(5) + rtt_monitor = getattr(monitor, "_rtt_monitor", None) + if rtt_monitor is not None: + await rtt_monitor._executor.join(5) + srv_monitor = getattr(topology, "_srv_monitor", None) + if srv_monitor is not None: + await srv_monitor._executor.join(5) + + class AsyncPyMongoTestCase(unittest.TestCase): if not _IS_SYNC: # An async TestCase that uses a single event loop for all tests. @@ -1037,7 +1104,10 @@ async def _async_mongo_client( client = AsyncMongoClient(uri, port, **client_options) if client._options.connect: await client.aconnect() - self.addAsyncCleanup(client.close) + if _tracing_enabled_for_cleanup(client_options): + self.addAsyncCleanup(_close_and_join_monitors, client) + else: + self.addAsyncCleanup(client.close) return client @classmethod @@ -1133,7 +1203,10 @@ def simple_client(self, h: Any = None, p: Any = None, **kwargs: Any) -> AsyncMon client = AsyncMongoClient(**kwargs) else: client = AsyncMongoClient(h, p, **kwargs) - self.addAsyncCleanup(client.close) + if _tracing_enabled_for_cleanup(kwargs): + self.addAsyncCleanup(_close_and_join_monitors, client) + else: + self.addAsyncCleanup(client.close) return client @classmethod diff --git a/test/asynchronous/test_common.py b/test/asynchronous/test_common.py index 8440ca98dc..7190f393f3 100644 --- a/test/asynchronous/test_common.py +++ b/test/asynchronous/test_common.py @@ -25,12 +25,26 @@ from bson.codec_options import CodecOptions from bson.objectid import ObjectId from pymongo.errors import OperationFailure +from pymongo.helpers_shared import _split_namespace from pymongo.write_concern import WriteConcern from test.asynchronous import AsyncIntegrationTest, async_client_context, connected, unittest _IS_SYNC = False +class TestSplitNamespace(unittest.TestCase): + def test_plain_namespace(self): + self.assertEqual(_split_namespace("db.coll"), ("db", "coll")) + + def test_collection_name_with_dots(self): + self.assertEqual(_split_namespace("db.coll.with.dots"), ("db", "coll.with.dots")) + + def test_no_dot(self): + # No separator: the whole string is treated as the database name + # and the collection name is empty, matching str.partition. + self.assertEqual(_split_namespace("dbonly"), ("dbonly", "")) + + class TestCommon(AsyncIntegrationTest): async def test_uuid_representation(self): coll = self.db.uuid diff --git a/test/asynchronous/test_open_telemetry_unified.py b/test/asynchronous/test_open_telemetry_unified.py new file mode 100644 index 0000000000..092edb9df8 --- /dev/null +++ b/test/asynchronous/test_open_telemetry_unified.py @@ -0,0 +1,84 @@ +# 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. + +"""Run the OpenTelemetry unified format spec tests.""" + +from __future__ import annotations + +import os +import sys + +sys.path[0:0] = [""] + +import pytest +import pytest_asyncio + +from bson import json_util +from test import unittest +from test.asynchronous import async_client_context +from test.asynchronous.unified_format import generate_test_classes, get_test_path + +_IS_SYNC = False + +pytestmark = pytest.mark.otel + + +def _otel_fixture_database_names() -> set[str]: + """Collect every databaseName declared in the vendored OTel unified-test + fixtures' createEntities blocks, so the cleanup fixture below never goes + stale as fixtures are added, renamed, or removed.""" + names: set[str] = set() + for dirpath, _, filenames in os.walk(get_test_path("open_telemetry")): + for filename in filenames: + if not filename.endswith(".json"): + continue + with open(os.path.join(dirpath, filename)) as scenario_stream: + scenario_def = json_util.loads(scenario_stream.read()) + for entity in scenario_def.get("createEntities", []): + database = entity.get("database") + if database is not None: + names.add(database["databaseName"]) + return names + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def _drop_otel_fixture_databases(): + # Some vendored OTel fixtures (e.g. operation/create_collection.json) + # are not idempotent: they create a collection and rely on the unified + # runner's insert_initial_data step to drop it beforehand, but that step + # only runs when the fixture declares a non-null `initialData`; this + # one declares none, so nothing ever cleans the collection up between + # runs. That's invisible upstream, where each fixture runs once per + # process. PyMongo, however, runs every fixture *twice* per process: once + # from this async module and again from its synchro-generated mirror, + # test/test_open_telemetry_unified.py. The second pass then collides with + # the first pass's leftover collection (NamespaceExists), which every + # server version CI actually tests against (unlike our local 8.2+ server) + # treats as a hard error rather than a no-op. Dropping the fixture + # databases once before the module's tests run guarantees both passes + # start from a clean slate. + for name in _otel_fixture_database_names(): + await async_client_context.client.drop_database(name) + yield + + +globals().update( + generate_test_classes( + get_test_path("open_telemetry"), + module=__name__, + ) +) + +if __name__ == "__main__": + unittest.main() diff --git a/test/asynchronous/test_operation_id_retry.py b/test/asynchronous/test_operation_id_retry.py index e5a1d16c90..68ee451880 100644 --- a/test/asynchronous/test_operation_id_retry.py +++ b/test/asynchronous/test_operation_id_retry.py @@ -151,10 +151,17 @@ async def test_retry_without_listeners_or_logging_creates_no_operation_id(self): find_op_ids = [] original_init = _CommandTelemetry.__init__ - def recording_init(self, topology_id, conn, listeners, cmd, dbname, request_id, op_id): + # Accept and forward any trailing arguments (e.g. tracing_options, + # speculative_hello) so this stays working as _CommandTelemetry gains + # parameters; only cmd and op_id are of interest here. + def recording_init( + self, topology_id, conn, listeners, cmd, dbname, request_id, op_id, *args, **kwargs + ): if next(iter(cmd)) == "find": find_op_ids.append(op_id) - original_init(self, topology_id, conn, listeners, cmd, dbname, request_id, op_id) + original_init( + self, topology_id, conn, listeners, cmd, dbname, request_id, op_id, *args, **kwargs + ) fail_point = { "mode": {"times": 1}, diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 7eaeafe734..0fd5ef3c1d 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -26,18 +26,29 @@ import pytest import pymongo._otel as _otel -from pymongo import common -from pymongo.errors import ConfigurationError, OperationFailure +from pymongo import _telemetry, common +from pymongo._telemetry import _OperationTelemetry +from pymongo.errors import ( + ClientBulkWriteException, + ConfigurationError, + InvalidOperation, + OperationFailure, + ServerSelectionTimeoutError, +) +from pymongo.operations import InsertOne +from pymongo.read_preferences import ReadPreference from pymongo.typings import _Address -from test.asynchronous import AsyncIntegrationTest, unittest +from test.asynchronous import AsyncIntegrationTest, async_client_context, unittest +from test.asynchronous.utils import async_wait_until +from test.unified_format_shared import _shared_test_provider _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 + from opentelemetry.trace import StatusCode _HAS_OTEL_TEST_DEPS = True except ImportError: @@ -48,19 +59,368 @@ pytestmark = pytest.mark.otel -def _shared_test_provider() -> TracerProvider: - """Return a process-wide SDK TracerProvider for tests to attach exporters to. +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOTelOperationSpanPrimitives(unittest.TestCase): + """Unit tests for the pymongo._otel operation-span primitives (no live server needed).""" - ``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 + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/asynchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_start_operation_span_disabled_returns_none(self): + handle = _otel.start_operation_span(None, "find", None) + self.assertIsNone(handle) + + def test_start_operation_span_success_sets_provisional_attributes(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None) + self.assertIsNotNone(handle) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + self.assertEqual(span.attributes["db.system.name"], "mongodb") + self.assertEqual(span.attributes["db.operation.name"], "find") + self.assertEqual(span.status.status_code, StatusCode.UNSET) + + def test_start_operation_span_failure_records_exception(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "insert", None) + _otel.end_operation_span_failure(handle, ValueError("boom")) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertEqual(len(span.events), 1) + self.assertEqual(span.events[0].name, "exception") + + def test_start_operation_span_with_parent(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + parent_handle = _otel.start_operation_span(opts, "transaction", None) + handle = _otel.start_operation_span(opts, "insert", parent_handle.span) + _otel.end_operation_span_success(handle) + _otel.end_operation_span_success(parent_handle) + child, parent = self.exporter.get_finished_spans() + self.assertEqual(child.parent.span_id, parent.context.span_id) + + def test_current_operation_name_contextvar_scoped_correctly(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + handle = _otel.start_operation_span(opts, "find", None) + self.assertEqual(_otel._CURRENT_OPERATION_NAME.get(), "find") + _otel.end_operation_span_success(handle) + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + + def test_eager_dbname_and_collection_set_at_creation(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, dbname="mydb", collection="mycoll") + self.assertIsNotNone(handle) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find mydb.mycoll") + self.assertEqual(span.attributes["db.namespace"], "mydb") + self.assertEqual(span.attributes["db.collection.name"], "mycoll") + self.assertEqual(span.attributes["db.operation.summary"], "find mydb.mycoll") + self.assertEqual(span.attributes["db.operation.name"], "find") + + def test_eager_dbname_without_collection_omits_collection_attribute(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "listCollections", None, dbname="mydb") + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "listCollections mydb") + self.assertEqual(span.attributes["db.operation.summary"], "listCollections mydb") + self.assertNotIn("db.collection.name", span.attributes) + + def test_no_eager_attributes_leaves_provisional_name(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + self.assertNotIn("db.namespace", span.attributes) + # db.operation.summary is Required (unlike db.namespace, which is only + # "Required if available"), so it always falls back to the bare + # operation name when no dbname is given. + self.assertEqual(span.attributes["db.operation.summary"], "find") + + def test_detached_span_is_not_current_until_used(self): + from opentelemetry import trace + + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + self.assertIsNotNone(handle) + # Not current, and the operation-name contextvar is untouched. + self.assertIsNot(trace.get_current_span(), handle.span) + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + with _otel.use_operation_span(handle): + self.assertIs(trace.get_current_span(), handle.span) + self.assertEqual(_otel._CURRENT_OPERATION_NAME.get(), "find") + # Restored afterwards, and the span is still open (not ended). + self.assertIsNot(trace.get_current_span(), handle.span) + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + self.assertEqual(self.exporter.get_finished_spans(), ()) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + + def test_detached_span_reused_across_multiple_use_blocks(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + for _ in range(3): + with _otel.use_operation_span(handle): + pass + self.assertEqual(self.exporter.get_finished_spans(), ()) + _otel.end_operation_span_success(handle) + self.assertEqual(len(self.exporter.get_finished_spans()), 1) + + def test_use_operation_span_with_none_handle_is_noop(self): + with _otel.use_operation_span(None): + pass + self.assertEqual(self.exporter.get_finished_spans(), ()) + + def test_detached_span_failure_inside_use_block_records_exception_once(self): + # Regression test: trace.use_span's record_exception/ + # set_status_on_exception default to True, so without explicitly + # disabling them, an exception propagating out of a `with + # use_operation_span(handle):` block gets auto-recorded there *and + # again* by the caller's own end_operation_span_failure, producing + # two identical "exception" events on the finished span. + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + exc = ValueError("boom") + try: + with _otel.use_operation_span(handle): + raise exc + except ValueError: + pass + _otel.end_operation_span_failure(handle, exc) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + exception_events = [e for e in span.events if e.name == "exception"] + self.assertEqual(len(exception_events), 1) + + def test_detached_span_failure_without_use_block(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + _otel.end_operation_span_failure(handle, ValueError("boom")) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + exception_events = [e for e in span.events if e.name == "exception"] + self.assertEqual(len(exception_events), 1) + + +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOTelTransactionSpanPrimitives(unittest.TestCase): + """Unit tests for the pymongo._otel transaction-span primitives (no live server needed).""" + + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/asynchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_start_transaction_span_disabled_returns_none(self): + self.assertIsNone(_otel.start_transaction_span(None)) + + def test_start_transaction_span_has_only_one_attribute(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + span = _otel.start_transaction_span(opts) + _otel.end_transaction_span(span) + (finished,) = self.exporter.get_finished_spans() + self.assertEqual(finished.name, "transaction") + self.assertEqual(dict(finished.attributes), {"db.system.name": "mongodb"}) + + def test_end_transaction_span_is_none_safe(self): + _otel.end_transaction_span(None) # must not raise + + +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOperationTelemetry(unittest.TestCase): + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/asynchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_succeeded_with_no_session(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, "find", None) + telemetry.succeeded() + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + self.assertIsNone(span.parent) + + def test_failed_records_exception(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, "insert", None) + telemetry.failed(RuntimeError("nope")) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_nests_under_active_transaction_span(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + txn_span = _otel.start_transaction_span(opts) + + class _FakeTransaction: + span = txn_span + + class _FakeSession: + in_transaction = True + _transaction = _FakeTransaction() + + telemetry = _telemetry._OperationTelemetry(opts, "insert", _FakeSession()) + telemetry.succeeded() + _otel.end_transaction_span(txn_span) + child, parent = self.exporter.get_finished_spans() + self.assertEqual(child.parent.span_id, parent.context.span_id) + + def test_disabled_is_a_no_op(self): + telemetry = _telemetry._OperationTelemetry(None, "find", None) + telemetry.succeeded() # must not raise + telemetry2 = _telemetry._OperationTelemetry(None, "find", None) + telemetry2.failed(RuntimeError("x")) # must not raise + self.assertEqual(self.exporter.get_finished_spans(), ()) + + def test_operation_name_normalizes_enum_operation(self): + # Most _retry_internal call sites pass an `_Op` enum member (a + # `str`-mixin enum), not a plain string, as the `operation` argument. + # Python 3.11 changed `Enum.__format__` for `str`-mixin enums so that + # f"{_Op.INSERT}"/str(_Op.INSERT) produce "_Op.INSERT" instead of + # "insert": on 3.10 the same code happened to already produce the + # bare value, which is why this bug wasn't caught there. This test is + # meaningful (and must pass) on every supported Python version. + from pymongo.operations import _Op + + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, _Op.INSERT, None) + telemetry.succeeded() + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "insert") + self.assertEqual(span.attributes["db.operation.name"], "insert") + self.assertIs(type(span.attributes["db.operation.name"]), str) + + def test_run_command_operation_name_override(self): + # Database.command() (is_run_command=True) must produce a "runCommand" + # operation span, per the OTel driver spec's span-name rule and its + # db.namespace/db.collection.name examples, not one named after the + # specific command sent. + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, "ping", None, is_run_command=True) + telemetry.succeeded() + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "runCommand") + self.assertEqual(span.attributes["db.operation.name"], "runCommand") + + +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOperationTelemetryContextManager(unittest.TestCase): + """Unit tests for _OperationTelemetry's context-manager and detached modes.""" + + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/asynchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_context_manager_success_ends_span(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + with _OperationTelemetry(opts, "killCursors", None, dbname="mydb", collection="c"): + pass + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "killCursors mydb.c") + self.assertEqual(span.status.status_code, StatusCode.UNSET) + + def test_context_manager_failure_records_exception(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + with self.assertRaises(ValueError): + with _OperationTelemetry(opts, "killCursors", None, dbname="mydb"): + raise ValueError("boom") + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertEqual(span.attributes["exception.type"], "ValueError") + + def test_context_manager_disabled_is_noop(self): + with _OperationTelemetry(None, "killCursors", None, dbname="mydb"): + pass + self.assertEqual(self.exporter.get_finished_spans(), ()) + + def test_detached_telemetry_use_makes_span_current(self): + from opentelemetry import trace + + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _OperationTelemetry( + opts, "find", None, dbname="mydb", collection="c", set_current=False + ) + self.assertIsNot(trace.get_current_span(), telemetry.handle.span) + with telemetry.use(): + self.assertIs(trace.get_current_span(), telemetry.handle.span) + self.assertEqual(self.exporter.get_finished_spans(), ()) + telemetry.succeeded() + self.assertEqual(len(self.exporter.get_finished_spans()), 1) @unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") @@ -71,6 +431,16 @@ def setUpClass(cls): cls.exporter = InMemorySpanExporter() _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + @classmethod + def tearDownClass(cls): + # See the matching comment in test/asynchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + super().tearDownClass() + async def asyncSetUp(self): await super().asyncSetUp() self.exporter.clear() @@ -81,36 +451,53 @@ def spans(self, name: str | None = None): return list(finished) return [s for s in finished if s.name == name] - # TODO(PYTHON-5947): once the unified test format runner supports - # expectTracingMessages/operation spans, this is superseded by the spec's - # find_without_query_text.yml and insert.yml. - async def test_span_created_for_insert_and_find(self): + async def test_operation_span_wraps_command_span_for_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() + coll = client[self.db.name].test await coll.insert_one({"x": 1}) + self.exporter.clear() + await coll.find_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) + finished = self.exporter.get_finished_spans() + # Identify the operation vs. command span by their distinguishing + # attribute (db.operation.name / db.command.name) rather than by + # span.name: start_command_span backfills the operation span's name + # to a query summary (e.g. "find .") once the first command + # inside it runs, so only the command span still literally reads "find". + matching = [s for s in finished if s.attributes.get("db.operation.name") == "find"] + self.assertEqual(len(matching), 1) + op_span = matching[0] + self.assertEqual(op_span.attributes["db.namespace"], self.db.name) + self.assertEqual(op_span.attributes["db.collection.name"], "test") + cmd_spans = [s for s in finished if s.attributes.get("db.command.name") == "find"] + self.assertEqual(len(cmd_spans), 1) + cmd_span = cmd_spans[0] + self.assertEqual(cmd_span.name, "find") + self.assertIsNotNone(cmd_span.parent) + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + async def test_operation_span_records_failure(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test 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") + with self.assertRaises(OperationFailure): + await coll.find_one({"$invalidOperator": 1}) + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "find" + ] + self.assertEqual(len(matching), 1) + op_span = matching[0] + self.assertEqual(op_span.status.status_code, StatusCode.ERROR) + # The operation-span Exceptions section of the OTel spec requires the + # same exception.type/message/stacktrace *attributes* as the command + # span, not just the exception *event* that record_exception alone + # attaches. + self.assertTrue(any(event.name == "exception" for event in op_span.events)) + self.assertIn("exception.type", op_span.attributes) + self.assertIn("exception.message", op_span.attributes) + self.assertIn("exception.stacktrace", op_span.attributes) async def test_span_created_for_get_more(self): client = await self.async_rs_or_single_client(tracing={"enabled": True}) @@ -128,6 +515,136 @@ async def test_span_created_for_get_more(self): self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore") self.assertEqual(span.attributes["db.command.name"], "getMore") + async def test_find_getmores_nest_under_one_operation_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.getmore_nesting + await coll.drop() + await coll.insert_many([{"i": i} for i in range(10)]) + self.exporter.clear() + + docs = await coll.find({}, batch_size=2).to_list() + self.assertEqual(len(docs), 10) + + finished = self.exporter.get_finished_spans() + # Exactly one operation span for the whole cursor. + find_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "find" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(find_op_spans), 1, [s.name for s in finished]) + op_span = find_op_spans[0] + self.assertEqual(op_span.name, "find pymongo_test.getmore_nesting") + + # No getMore *operation* spans at all. + getmore_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "getMore" + and "db.command.name" not in s.attributes + ] + self.assertEqual(getmore_op_spans, []) + + # Every getMore *command* span is a child of that one operation span. + getmore_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "getMore" + ] + self.assertGreater(len(getmore_cmd_spans), 1) + for cmd_span in getmore_cmd_spans: + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + + async def test_aggregate_getmores_nest_under_one_operation_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.agg_nesting + await coll.drop() + await coll.insert_many([{"i": i} for i in range(10)]) + self.exporter.clear() + + docs = await (await coll.aggregate([{"$match": {}}], batchSize=2)).to_list() + self.assertEqual(len(docs), 10) + + finished = self.exporter.get_finished_spans() + agg_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "aggregate" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(agg_op_spans), 1, [s.name for s in finished]) + op_span = agg_op_spans[0] + + getmore_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "getMore" + and "db.command.name" not in s.attributes + ] + self.assertEqual(getmore_op_spans, []) + + getmore_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "getMore" + ] + self.assertGreater(len(getmore_cmd_spans), 1) + for cmd_span in getmore_cmd_spans: + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + + async def test_single_batch_aggregate_ends_span_promptly_not_at_gc(self): + # A command cursor whose first batch exhausts it is marked _killed in + # __init__ without ever calling close(); no getMore is sent, so + # _refresh()/_die_lock() never run. Without explicit attachment its + # operation span would only be ended by __del__, i.e. whenever GC + # happens to run (or never, if the cursor is retained; Important #2). + # Assert the span is already ended while a reference to the + # cursor is still held, proving it ended at construction rather than + # waiting on GC. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.agg_single_batch + await coll.drop() + await coll.insert_many([{"i": i} for i in range(3)]) + self.exporter.clear() + + cursor = await coll.aggregate([{"$match": {}}]) + # Confirm this test actually exercises the single-batch path. + self.assertTrue(cursor._killed) + + finished = self.exporter.get_finished_spans() + agg_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "aggregate" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(agg_op_spans), 1, [s.name for s in finished]) + self.assertIsNotNone(agg_op_spans[0].end_time) + + # The cursor reference is kept alive through this assertion: if + # __del__ were the only thing ending the span, get_finished_spans() + # above would not have included it yet. + self.assertIsNotNone(cursor) + + async def test_abandoned_cursor_still_ends_operation_span(self): + import gc + + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.getmore_abandoned + await coll.drop() + await coll.insert_many([{"i": i} for i in range(10)]) + self.exporter.clear() + + cursor = coll.find({}, batch_size=2) + await cursor.next() # Leaves the cursor open with batches pending. + del cursor + gc.collect() + + find_op_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "find" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(find_op_spans), 1) + 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. @@ -171,8 +688,26 @@ async def test_sensitive_command_produces_no_span(self): 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) + # The inner command span must stay fully suppressed for sensitive commands. + command_span_names = [s.name for s in self.spans() if "db.command.name" in s.attributes] + self.assertNotIn("saslStart", command_span_names) + + # The sensitive command name must never leak onto the wrapping + # *operation* span either: Database.command() always runs with + # is_run_command=True, so the operation span is named/attributed + # "runCommand" regardless of the actual (sensitive) command sent; + # the bare "saslStart" name never appears anywhere. The operation + # span must still carry its Required db.namespace/db.operation.summary + # attributes (backfilled before start_command_span's sensitive-command + # early return), even though the command itself produced no span. + finished = self.exporter.get_finished_spans() + operation_names = [s.attributes.get("db.operation.name") for s in finished] + self.assertNotIn("saslStart", operation_names) + self.assertIn("runCommand", operation_names) + op_span = next(s for s in finished if s.attributes.get("db.operation.name") == "runCommand") + self.assertEqual(op_span.name, "runCommand admin") + self.assertEqual(op_span.attributes["db.namespace"], "admin") + self.assertEqual(op_span.attributes["db.operation.summary"], "runCommand admin") async def test_admin_command_omits_collection_name(self): # usersInfo's command value is a username string, not a collection, and @@ -188,13 +723,39 @@ async def test_admin_command_omits_collection_name(self): self.assertNotIn("db.collection.name", attrs) self.assertEqual(attrs["db.query.summary"], "usersInfo admin") + async def test_database_command_produces_run_command_operation_span(self): + # The OTel driver spec names "runCommand" as the driver-operation name + # for any operation reached through the generic Database.command() + # API, so c.admin.command("ping") must produce an operation span named + # "runCommand admin" with db.operation.name="runCommand", not one + # named "ping"/"ping admin". + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + await client.admin.command("ping") + + finished = self.exporter.get_finished_spans() + matching = [s for s in finished if s.attributes.get("db.operation.name") == "runCommand"] + self.assertEqual(len(matching), 1) + op_span = matching[0] + self.assertEqual(op_span.name, "runCommand admin") + self.assertEqual(op_span.attributes["db.namespace"], "admin") + # The wire-level command span is unaffected: it's still named/attributed + # after the actual command sent. + cmd_spans = [s for s in finished if s.attributes.get("db.command.name") == "ping"] + self.assertEqual(len(cmd_spans), 1) + self.assertEqual(cmd_spans[0].name, "ping") + 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() + # Operation spans wrap command spans, so this also produces an + # ERROR-status operation span alongside the command span; narrow to + # the command span specifically (it alone carries + # db.response.status_code) rather than asserting there's only one span. + spans = [s for s in self.spans() if "db.response.status_code" in s.attributes] self.assertEqual(len(spans), 1) span = spans[0] self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) @@ -207,29 +768,45 @@ async def test_tracing_disabled_by_default(self): await client.admin.command("ping") self.assertEqual(self.spans(), []) - # TODO(PYTHON-5947): once operation spans exist, also assert that the - # "ping" *operation* span (not just the command span) is absent/present - # here, and that self.spans() counts both. async def test_prose_1_tracing_enable_disable_via_env_var(self): """Prose Test 1: Tracing Enable/Disable via Environment Variable.""" with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "false"}): client = await self.async_rs_or_single_client() self.exporter.clear() await client.admin.command("ping") + # Disabled must suppress both the operation span and the command span + # it wraps: db.command() routes through _retry_internal same as any + # CRUD call, so both would exist if tracing weren't fully off. 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()]) + finished = self.exporter.get_finished_spans() + # Disambiguate the command span (db.command.name) from the operation + # span (db.operation.name) that wraps it: start_command_span renames + # the operation span in place once the command runs, so span.name + # alone can't tell them apart, but these attributes can. The operation + # span reads "runCommand" (not "ping"): Database.command() always runs + # with is_run_command=True, per the OTel spec's runCommand naming rule. + self.assertIn("ping", [s.attributes.get("db.command.name") for s in finished]) + self.assertIn("runCommand", [s.attributes.get("db.operation.name") for s in finished]) - # 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.""" + + def command_spans(): + # self.spans("find") would also match the outer find *operation* + # span (renamed to a "find ." summary once the command + # runs, not literally "find"); filter on db.command.name instead + # to isolate the inner command span that carries db.query.text. + return [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.command.name") == "find" + ] + env = { "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true", "OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024", @@ -238,7 +815,7 @@ async def test_prose_2_command_payload_emission_via_env_var(self): 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") + spans = command_spans() self.assertEqual(len(spans), 1) self.assertIn("db.query.text", spans[0].attributes) @@ -246,27 +823,10 @@ async def test_prose_2_command_payload_emission_via_env_var(self): 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") + spans = command_spans() 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. @@ -300,10 +860,590 @@ async def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertLessEqual(len(query_text), 200) self.assertNotIn("a" * 500, query_text) + @async_client_context.require_transactions + async def test_transaction_span_parents_operation_and_command_spans(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test + await coll.drop() + await client[self.db.name].create_collection("test") + self.exporter.clear() + + async with client.start_session() as session: + async with await session.start_transaction(): + await coll.insert_one({"x": 2}, session=session) + await coll.insert_one({"x": 3}, session=session) + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertEqual(dict(txn_span.attributes), {"db.system.name": "mongodb"}) + + insert_op_spans = [s for s in finished if s.attributes.get("db.operation.name") == "insert"] + self.assertEqual(len(insert_op_spans), 2) + for op_span in insert_op_spans: + self.assertEqual(op_span.parent.span_id, txn_span.context.span_id) + + commit_op_spans = [ + s for s in finished if s.attributes.get("db.operation.name") == "commitTransaction" + ] + self.assertEqual(len(commit_op_spans), 1) + self.assertEqual(commit_op_spans[0].parent.span_id, txn_span.context.span_id) + + @async_client_context.require_transactions + async def test_aborted_transaction_still_ends_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test + await coll.drop() + await client[self.db.name].create_collection("test") + self.exporter.clear() + + async with client.start_session() as session: + async with await session.start_transaction(): + await coll.insert_one({"x": 4}, session=session) + await session.abort_transaction() + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertTrue(txn_span.end_time is not None) + + @async_client_context.require_transactions + async def test_committing_empty_transaction_ends_span(self): + # No operation is ever run against the server, so commit_transaction + # takes the STARTING/COMMITTED_EMPTY early-return path rather than + # actually sending a commitTransaction command. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + + async with client.start_session() as session: + await session.start_transaction() + await session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertTrue(txn_span.end_time is not None) + + @async_client_context.require_transactions + async def test_aborting_empty_transaction_ends_span(self): + # No operation is ever run against the server, so abort_transaction + # takes the STARTING early-return path rather than actually sending + # an abortTransaction command. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + + async with client.start_session() as session: + await session.start_transaction() + await session.abort_transaction() + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertTrue(txn_span.end_time is not None) + + @async_client_context.require_transactions + async def test_direct_commit_retry_gives_each_span_its_own_end(self): + # Explicitly retrying a successful commit moves the transaction state + # COMMITTED -> IN_PROGRESS -> (back through the try/finally) -> + # COMMITTED again. The prior attempt's span was already ended and + # cleared, so the retry gets a fresh "transaction" span of its own + # (this is the direct-API path, not with_transaction; see + # test_with_transaction_retry_reuses_one_transaction_span for the + # with_transaction case, which shares a single span across retries + # instead); each span's ending finally block must run exactly once + # for its own span, never double-ending the same span and never + # leaving one unended. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test + await coll.drop() + await client[self.db.name].create_collection("test") + self.exporter.clear() + + async with client.start_session() as session: + async with await session.start_transaction(): + await coll.insert_one({"x": 5}, session=session) + # The transaction context manager already committed on clean + # exit; retry the commit explicitly. + await session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 2) + self.assertNotEqual(txn_spans[0].context.span_id, txn_spans[1].context.span_id) + for txn_span in txn_spans: + self.assertTrue(txn_span.end_time is not None) + + @async_client_context.require_transactions + async def test_with_transaction_retry_reuses_one_transaction_span(self): + # A retried with_transaction() call must still produce exactly one + # "transaction" span for the whole logical call, not one sibling + # span per full-transaction retry, and no separately-named wrapper + # span either (the vendored transaction/convenient.json fixture + # pins "transaction" itself as the trace root for withTransaction). + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.with_txn_spans + await coll.drop() + await client.pymongo_test.create_collection("with_txn_spans") + + attempts = [] + + async def callback(session): + attempts.append(1) + await coll.insert_one({"n": len(attempts)}, session=session) + if len(attempts) == 1: + exc = OperationFailure("transient", 251) + exc._add_error_label("TransientTransactionError") + raise exc + + self.exporter.clear() + async with client.start_session() as session: + await session.with_transaction(callback) + + self.assertEqual(len(attempts), 2) + finished = self.exporter.get_finished_spans() + self.assertFalse( + [s.name for s in finished if s.name.startswith("withTransaction")], + [s.name for s in finished], + ) + + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + self.assertTrue(txn_spans[0].end_time is not None) + + insert_op_spans = [s for s in finished if s.attributes.get("db.operation.name") == "insert"] + self.assertEqual(len(insert_op_spans), 2) + for op_span in insert_op_spans: + self.assertEqual(op_span.parent.span_id, txn_spans[0].context.span_id) + + @async_client_context.require_transactions + async def test_reentrant_with_transaction_raises_and_does_not_leak_span(self): + # A callback that illegally re-enters with_transaction() on the same + # session must be rejected with a clear InvalidOperation, and the + # outer call's "transaction" span must still end exactly once, + # never leaked (created but never ended) and never double-ended. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.reentrant_with_txn + await coll.drop() + await client.pymongo_test.create_collection("reentrant_with_txn") + + async def inner_callback(session): + await coll.insert_one({"x": 1}, session=session) + + async def outer_callback(session): + await coll.insert_one({"x": 2}, session=session) + # Illegal: with_transaction() is not reentrant on one session. + await session.with_transaction(inner_callback) + + self.exporter.clear() + async with client.start_session() as session: + with self.assertRaises(InvalidOperation): + await session.with_transaction(outer_callback) + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + # Only the outer call ever gets far enough to create a span; the + # guard rejects the inner call before it creates one of its own. + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + for txn_span in txn_spans: + self.assertIsNotNone(txn_span.end_time) + + @async_client_context.require_transactions + async def test_with_transaction_while_direct_api_transaction_active_does_not_corrupt_span( + self, + ): + # Calling with_transaction() while a transaction started with the + # DIRECT API is already active on the same session is illegal: + # start_transaction() inside with_transaction() raises "Transaction + # already in progress", but the direct-API transaction's own + # "transaction" span must survive that failure: with_transaction()'s + # finally must not end/null it out from under the still-active + # transaction (Important #1). Operations run on the session + # afterwards must still parent to that span rather than becoming + # trace roots, and the failed call must not leave behind a second, + # spurious "transaction" span of its own. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.direct_api_with_txn_conflict + await coll.drop() + await client.pymongo_test.create_collection("direct_api_with_txn_conflict") + + async def callback(session): + raise AssertionError("never reached; start_transaction() raises first") + + self.exporter.clear() + async with client.start_session() as session: + await session.start_transaction() + await coll.insert_one({"x": 1}, session=session) + + with self.assertRaises(InvalidOperation): + await session.with_transaction(callback) + + # The original transaction is still active; this must still + # nest under its span, not become a trace root. + await coll.insert_one({"x": 2}, session=session) + await session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + txn_span = txn_spans[0] + self.assertIsNotNone(txn_span.end_time) + + insert_op_spans = [s for s in finished if s.attributes.get("db.operation.name") == "insert"] + self.assertEqual(len(insert_op_spans), 2) + for op_span in insert_op_spans: + self.assertEqual(op_span.parent.span_id, txn_span.context.span_id) + + @async_client_context.require_transactions + async def test_retried_commit_has_a_transaction_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.retried_commit_spans + await coll.drop() + await client.pymongo_test.create_collection("retried_commit_spans") + + async with client.start_session() as session: + await session.start_transaction() + await coll.insert_one({"x": 1}, session=session) + await session.commit_transaction() + self.exporter.clear() + # An explicit second commit re-enters the COMMITTED -> IN_PROGRESS + # branch, which previously ran with no transaction span at all. + await session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + commit_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "commitTransaction" + ] + self.assertGreaterEqual(len(commit_cmd_spans), 1) + for cmd_span in commit_cmd_spans: + self.assertIsNotNone(cmd_span.parent) + + @async_client_context.require_version_min(8, 0, 0, -24) + async def test_bulk_write_acknowledged_gets_operation_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + await client.bulk_write([InsertOne(namespace=f"{self.db.name}.test", document={"x": 1})]) + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "bulkWrite" + ] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0].attributes["db.namespace"], "admin") + self.assertNotIn("db.collection.name", matching[0].attributes) + + @async_client_context.require_version_min(8, 0, 0, -24) + async def test_bulk_write_unacknowledged_gets_operation_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}, w=0) + self.exporter.clear() + await client.bulk_write( + [InsertOne(namespace=f"{self.db.name}.test", document={"x": 1})], + ordered=False, + ) + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "bulkWrite" + ] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0].attributes["db.namespace"], "admin") + + @async_client_context.require_version_min(8, 0) + async def test_client_bulk_write_results_cursor_getmores_nest_under_bulk_write(self): + # A successful InsertOne's verbose result doc is tiny (~{"ok": 1, "idx": + # i, "n": 1}) regardless of the inserted document's size, and the driver + # never sends more than maxWriteBatchSize (100_000 by default) ops in one + # bulkWrite command, so plain successful inserts can never make the + # results cursor's first batch exceed the 16MB per-batch limit, no + # matter how many operations are given. Duplicate-key write errors, + # whose result docs embed the offending key (here padded to 3000 bytes), + # blow past that limit at a much smaller, fast-running operation count + # while still exercising the exact same code path (a real + # AsyncCommandCursor built and iterated by _process_results_cursor). + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.bulk_results_cursor + await coll.drop() + await coll.create_index("dup", unique=True) + dup_value = "d" * 3000 + models = [ + InsertOne(namespace=coll.full_name, document={"dup": dup_value}) for _ in range(10000) + ] + self.exporter.clear() + with self.assertRaises(ClientBulkWriteException): + await client.bulk_write(models, verbose_results=True, ordered=False) + + finished = self.exporter.get_finished_spans() + # Exactly one operation span, for the bulkWrite itself. + op_spans = [ + s + for s in finished + if "db.command.name" not in s.attributes + and s.attributes.get("db.operation.name") is not None + ] + self.assertEqual( + [s.attributes["db.operation.name"] for s in op_spans], + ["bulkWrite"], + [s.name for s in finished], + ) + (op_span,) = op_spans + + # Any getMore command spans parent directly to the bulkWrite span. + getmore_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "getMore" + ] + self.assertGreater(len(getmore_cmd_spans), 0, "expected a multi-batch results cursor") + for cmd_span in getmore_cmd_spans: + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + + async def test_operation_span_falls_back_to_bare_name_when_no_command_is_sent(self): + # An operation that fails during server selection never builds a + # command, so the lazy backfill in start_command_span never runs. + # insert_one doesn't go through a cursor (unlike find), so nothing + # eagerly threads dbname/collection to the operation span either: + # db.operation.summary (Required, per the OTel spec) still falls back + # to the bare operation name, but db.namespace/db.collection.name + # (only "Required if available") are simply absent. + client = await self.async_rs_or_single_client( + "mongodb://localhost:1/", + tracing={"enabled": True}, + serverSelectionTimeoutMS=10, + connect=False, + ) + self.exporter.clear() + with self.assertRaises(ServerSelectionTimeoutError): + await client.mydb.mycoll.insert_one({}) + (span,) = [s for s in self.exporter.get_finished_spans() if s.name == "insert"] + self.assertEqual(span.attributes["db.operation.name"], "insert") + self.assertEqual(span.attributes["db.operation.summary"], "insert") + self.assertNotIn("db.namespace", span.attributes) + self.assertNotIn("db.collection.name", span.attributes) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + async def test_caller_owned_operation_telemetry_is_not_ended_by_retry_internal(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + telemetry = _OperationTelemetry( + client.options.tracing, + "find", + None, + dbname="mydb", + collection="c", + set_current=False, + ) + self.exporter.clear() + + async def _noop_read(_session, _server, _conn, _read_pref): + return "ok" + + result = await client._retryable_read( + _noop_read, + ReadPreference.PRIMARY, + None, + operation="find", + operation_telemetry=telemetry, + ) + self.assertEqual(result, "ok") + # _retry_internal must not have ended the caller's span. + self.assertEqual( + [s for s in self.exporter.get_finished_spans() if s.name.startswith("find")], [] + ) + telemetry.succeeded() + self.assertEqual( + len([s for s in self.exporter.get_finished_spans() if s.name.startswith("find")]), + 1, + ) + + async def test_eager_namespace_for_collection_and_database_operations(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + db = client.pymongo_test + cases = [ + # (coroutine factory, expected span name) + (lambda: db.mycoll.insert_one({"x": 1}), "insert pymongo_test.mycoll"), + (lambda: db.mycoll.find_one({}), "find pymongo_test.mycoll"), + (lambda: db.mycoll.count_documents({}), "count pymongo_test.mycoll"), + (lambda: db.list_collection_names(), "listCollections pymongo_test"), + ] + for factory, expected_name in cases: + with self.subTest(expected_name=expected_name): + self.exporter.clear() + await factory() + names = [s.name for s in self.exporter.get_finished_spans()] + self.assertIn(expected_name, names) + (span,) = [s for s in self.exporter.get_finished_spans() if s.name == expected_name] + self.assertEqual(span.attributes["db.operation.summary"], expected_name) + self.assertEqual(span.attributes["db.namespace"], "pymongo_test") + + def _aggregate_operation_span(self): + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "aggregate" + ] + self.assertEqual(len(matching), 1) + return matching[0] + + @async_client_context.require_version_min(4, 2, 0) + @async_client_context.require_change_streams + async def test_change_stream_collection_level_operation_span_has_full_namespace(self): + # A collection-level change stream's operation span carries both the + # database and the collection, derived from the aggregate command by + # _otel's lazy backfill. The database- and cluster-level cases below + # must omit db.collection.name, since neither targets one collection. + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + db = client.pymongo_test + coll = db.test_otel_change_stream_coll + await coll.drop() + self.exporter.clear() + async with await coll.watch(): + pass + span = self._aggregate_operation_span() + self.assertEqual(span.attributes["db.namespace"], "pymongo_test") + self.assertEqual(span.attributes["db.collection.name"], "test_otel_change_stream_coll") + + @async_client_context.require_version_min(4, 2, 0) + @async_client_context.require_change_streams + async def test_change_stream_database_level_operation_span_omits_collection_name(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + db = client.pymongo_test + self.exporter.clear() + async with await db.watch(): + pass + span = self._aggregate_operation_span() + self.assertEqual(span.attributes["db.namespace"], "pymongo_test") + self.assertNotIn("db.collection.name", span.attributes) + + @async_client_context.require_version_min(4, 2, 0) + @async_client_context.require_change_streams + async def test_change_stream_cluster_level_operation_span_targets_admin(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + async with await client.watch(): + pass + span = self._aggregate_operation_span() + self.assertEqual(span.attributes["db.namespace"], "admin") + self.assertNotIn("db.collection.name", span.attributes) + + async def test_kill_cursors_gets_operation_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.kill_cursors_span + await coll.drop() + await coll.insert_many([{"i": i} for i in range(10)]) + cursor = coll.find({}, batch_size=2) + await cursor.next() + self.exporter.clear() + await cursor.close() # Sends killCursors, since batches remain. + + op_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "killCursors" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(op_spans), 1, [s.name for s in self.exporter.get_finished_spans()]) + (op_span,) = op_spans + self.assertEqual(op_span.name, "killCursors pymongo_test.kill_cursors_span") + self.assertEqual(op_span.attributes["db.namespace"], "pymongo_test") + self.assertEqual(op_span.attributes["db.collection.name"], "kill_cursors_span") + + cmd_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.command.name") == "killCursors" + ] + self.assertEqual(len(cmd_spans), 1) + self.assertEqual(cmd_spans[0].parent.span_id, op_span.context.span_id) + + async def test_background_kill_cursors_span_is_a_trace_root(self): + # Regression test for PYTHON-5947: asyncio.create_task freezes the + # calling coroutine's contextvars.Context, and the kill-cursors + # executor is opened lazily from inside the client's first traced + # operation (_get_topology -> executor.open() -> create_task, reached + # while that operation's _OperationTelemetry has already made its + # span ambient-current). Without resetting the OTel context inside + # AsyncPeriodicExecutor._run, every killCursors span the background + # tick emits for the rest of the process's life gets parented under + # that first, long-since-ended operation and shares its trace id. + # + # Calling client._process_kill_cursors() directly from this test coroutine would NOT + # reproduce the bug: this coroutine's own context is clean, so the span would come out + # parentless even with the bug present. Instead we drive the *existing* kill-cursors + # executor task (the one whose context was frozen inside find_one() below) using + # wake()/skip_sleep(), so the tick actually runs inside that frozen context, and poll + # (async_wait_until) for the resulting span rather than sleeping a fixed amount. + import gc + + # connect=False is essential here: the test helper's default + # connect=True calls client.aconnect() -> _get_topology() right after + # construction, *before* any traced operation runs and thus with no + # span current, which would open (and freeze) the kill-cursors + # executor with a clean context and make this test pass regardless of + # the bug. With connect=False, _get_topology() (and therefore the + # executor's create_task) is only reached lazily, from inside the + # find_one() call below, while that operation's span is current. + client = await self.async_rs_or_single_client(tracing={"enabled": True}, connect=False) + coll = client.pymongo_test.bg_kill_cursors + await coll.drop() + await coll.insert_many([{"i": i} for i in range(10)]) + + # This first traced operation is what opens the kill-cursors executor, + # freezing its context while this operation's span is current. + await coll.find_one({}) + + cursor = coll.find({}, batch_size=2) + await cursor.next() + del cursor + gc.collect() # Queues a deferred killCursors. + + self.exporter.clear() + + def _kill_op_spans(): + return [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "killCursors" + and "db.command.name" not in s.attributes + ] + + executor = client._kill_cursors_executor + executor.skip_sleep() + executor.wake() + await async_wait_until(_kill_op_spans, "background killCursors span emitted") + + kill_spans = _kill_op_spans() + self.assertEqual(len(kill_spans), 1, [s.name for s in self.exporter.get_finished_spans()]) + # The background tick must not inherit a parent from whatever span + # happened to be current when the executor task was created. + self.assertIsNone(kill_spans[0].parent) + + async def test_end_sessions_gets_operation_span(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + await client.pymongo_test.end_sessions_span.find_one({}) # Uses an implicit session. + self.exporter.clear() + await client.close() # Sends endSessions. + + op_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "endSessions" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(op_spans), 1, [s.name for s in self.exporter.get_finished_spans()]) + (op_span,) = op_spans + self.assertEqual(op_span.name, "endSessions admin") + self.assertEqual(op_span.attributes["db.namespace"], "admin") + self.assertNotIn("db.collection.name", op_span.attributes) + + cmd_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.command.name") == "endSessions" + ] + self.assertEqual(len(cmd_spans), 1) + self.assertEqual(cmd_spans[0].parent.span_id, op_span.context.span_id) + -# 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. +# The unified test format's expectTracingMessages/observeTracingMessages +# tests (test_open_telemetry_unified.py) now exercise this validator +# indirectly through real client construction, but these direct unit tests +# are kept for the validator's edge cases (rejection paths, the explicit-zero +# vs. unset distinction for query_text_max_length) that aren't necessarily +# covered by the vendored fixtures. class TestValidateTracingOrNone(unittest.TestCase): def test_none(self): self.assertIsNone(common.validate_tracing_or_none("tracing", None)) diff --git a/test/asynchronous/unified_format.py b/test/asynchronous/unified_format.py index 5e6e24e9c4..285fca993b 100644 --- a/test/asynchronous/unified_format.py +++ b/test/asynchronous/unified_format.py @@ -29,6 +29,7 @@ import sys import time import traceback +import uuid from collections import defaultdict from collections.abc import Mapping from inspect import iscoroutinefunction @@ -38,8 +39,11 @@ import pytest import pymongo +import pymongo._otel as _otel from bson import SON, json_util +from bson.binary import Binary from bson.codec_options import DEFAULT_CODEC_OPTIONS +from bson.int64 import Int64 from bson.objectid import ObjectId from gridfs import AsyncGridFSBucket, GridOut, NoFile from gridfs.errors import CorruptGridFile @@ -93,6 +97,7 @@ PLACEHOLDER_MAP, EventListenerUtil, MatchEvaluatorUtil, + _shared_test_provider, coerce_result, parse_bulk_write_error_result, parse_bulk_write_result, @@ -113,6 +118,16 @@ _IS_SYNC = False +_HAS_OTEL_TEST_DEPS = False +if _otel._HAS_OPENTELEMETRY: + try: + 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_INTERRUPTED = False @@ -230,6 +245,11 @@ def __init__(self, test_class): self._entities: dict[str, Any] = {} self._listeners: dict[str, EventListenerUtil] = {} self._session_lsids: dict[str, Mapping[str, Any]] = {} + # The id of the (at most one, today) client entity created with + # observeTracingMessages. Spans carry no attribute identifying which + # client emitted them, so multi-client tracing correlation isn't + # supported; _create_entity fails loudly if a second one appears. + self._tracing_client_id: Optional[str] = None self.test: UnifiedSpecTestMixinV1 = test_class def __contains__(self, item): @@ -311,6 +331,25 @@ async def _create_entity(self, entity_spec, uri=None): ) self._listeners[spec["id"]] = listener kwargs["event_listeners"] = [listener] + + observe_tracing = spec.get("observeTracingMessages") + if observe_tracing is not None: + if self._tracing_client_id is not None: + self.test.fail( + "Multiple clients with observeTracingMessages are not supported " + f"by the unified test format runner (already tracking " + f"{self._tracing_client_id!r}, got {spec['id']!r})" + ) + self._tracing_client_id = spec["id"] + enable_payload = observe_tracing.get("enableCommandPayload", False) + kwargs["tracing"] = { + "enabled": True, + # Tests asserting db.query.text match the full, untruncated + # command, so an effectively-unlimited length avoids + # truncating and failing that assertion. + "query_text_max_length": 1_000_000 if enable_payload else None, + } + if spec.get("useMultipleMongoses"): if async_client_context.load_balancer: kwargs["h"] = async_client_context.MULTI_MONGOS_LB_URI @@ -467,6 +506,36 @@ async def advance_cluster_times(self, cluster_time) -> None: entity.advance_cluster_time(cluster_time) +def _normalize_span_attribute_for_match(key: str, value: Any) -> Any: + """Adapt an OTel span attribute value to what the generic unified-format + match evaluator expects, since span attributes are plain Python + primitives rather than the BSON-decoded documents/events the evaluator + normally matches against. + + - Widen plain Python ints (excluding bools) to ``bson.Int64``: span + attributes carry no int32/int64 distinction, but the $$type matcher's + "long" alias maps to ``Int64`` specifically (see BSON_TYPE_ALIAS_MAP in + unified_format_shared.py), so a bare ``int`` (e.g. ``server.port``) + would otherwise fail a ``$$type: ["long", "string"]`` check. ``Int64`` + is a subclass of ``int``, so this is safe for "int" checks too. + - Reconstruct ``db.mongodb.lsid`` (formatted by pymongo/_otel.py as a + plain UUID string, per the OTel spec's attribute table) back into the + ``{"id": Binary(...)}`` document shape the ``$$sessionLsid`` operator + (designed for command-monitoring-style raw command documents) compares + against. + """ + if key == "db.mongodb.lsid" and isinstance(value, str): + try: + return {"id": Binary.from_uuid(uuid.UUID(value))} + except ValueError: + return value + if isinstance(value, bool): + return value + if isinstance(value, int): + return Int64(value) + return value + + class UnifiedSpecTestMixinV1(AsyncIntegrationTest): """Mixin class to run test cases from test specification files. @@ -482,6 +551,8 @@ class UnifiedSpecTestMixinV1(AsyncIntegrationTest): TEST_SPEC: Any TEST_PATH = "" # This gets filled in by generate_test_classes mongos_clients: list[AsyncMongoClient] = [] + # Set in setUpClass, only for test files that use observeTracingMessages. + _tracing_exporter: Optional[Any] = None @staticmethod async def should_run_on(run_on_spec): @@ -526,6 +597,23 @@ async def insert_initial_data(self, initial_data): @classmethod def setUpClass(cls) -> None: + # Only register a span exporter (and the shared SDK TracerProvider it + # depends on) for test files that actually use observeTracingMessages, + # to avoid needlessly accumulating span processors on the process-wide + # provider for the (vast majority of) unified-format suites that don't. + cls._tracing_exporter = None + uses_tracing = any( + "observeTracingMessages" in entity.get("client", {}) + for entity in cls.TEST_SPEC.get("createEntities", []) + ) + if uses_tracing: + if not _HAS_OTEL_TEST_DEPS: + raise unittest.SkipTest( + "observeTracingMessages requires opentelemetry-sdk to be installed" + ) + cls._tracing_exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls._tracing_exporter)) + # Speed up the tests by decreasing the heartbeat frequency. cls.knobs = client_knobs( heartbeat_frequency=0.1, @@ -538,6 +626,14 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: cls.knobs.disable() + # The exporter's span processor can never be removed from the shared process-wide + # TracerProvider (see _shared_test_provider), so without this, every span emitted by any + # client anywhere in the process for the rest of the test run keeps getting appended to this + # (otherwise dead) class's exporter: an unbounded memory leak across a full test run, and + # needless per-span export overhead for every other tracing-enabled test class that runs + # afterwards. shutdown() makes further export() calls into this exporter no-ops. + if cls._tracing_exporter is not None: + cls._tracing_exporter.shutdown() async def asyncSetUp(self): # super call creates internal client cls.client @@ -576,6 +672,26 @@ def maybe_skip_test(self, spec): self.skipTest("PyMongo does not support the symbol type") if "timeoutms applied to entire download" in description: self.skipTest("PyMongo's open_download_stream does not cap the stream's lifetime") + # Removed API: PyMongo no longer exposes map_reduce/inline_map_reduce at + # all (mapReduce is deprecated server-side), so there's no code path left + # that could send this command; this operation can never be exercised. + if class_name == "testoperationmapreduce" and description == "mapreduce": + self.skipTest( + "PyMongo removed the map_reduce/inline_map_reduce Collection methods " + "(mapReduce is deprecated server-side); this operation cannot be exercised" + ) + # Not a removed-API gap like the above: this is a genuine fixture-vs-driver + # divergence. PyMongo *can* run this update, it just always sends explicit + # multi/upsert fields (even at their default False value), which the + # vendored fixture's db.query.text $$matchAsRoot assertion doesn't allow for. + if class_name == "testoperationupdate" and description == "update one element": + self.skipTest( + "PyMongo always sends explicit multi/upsert fields in the update " + "statement (even at their default False value), but this vendored " + "fixture's db.query.text $$matchAsRoot expects an update statement " + "with only q/u: a real, narrow mismatch between this driver's wire " + "command shape and the fixture's assumption, not a tracing bug" + ) if any( x in description for x in [ @@ -1463,6 +1579,79 @@ def format_logs(log_list): self.match_evaluator.match_result(expected_data, actual_data) self.match_evaluator.match_result(expected_msg, actual_msg) + async def check_tracing_messages(self, operations, spec): + # Like expectLogMessages/expectEvents, expectTracingMessages is a list of + # per-client blocks (even though only one client with + # observeTracingMessages is currently supported, see entity.py above). + exporter = self._tracing_exporter + if exporter is None: + self.fail( + "expectTracingMessages requires a client entity created with observeTracingMessages" + ) + + exporter.clear() + await self.run_operations(operations) + finished_spans = exporter.get_finished_spans() + + # Reconstruct the parent/child span tree from the flat, finish-ordered + # list the in-memory exporter records, keyed by each span's parent id. + children_by_parent_id = defaultdict(list) + for span in finished_spans: + parent_id = span.parent.span_id if span.parent is not None else None + children_by_parent_id[parent_id].append(span) + + def check_span_list(expected_list, actual_list, ignore_extra_spans): + if ignore_extra_spans: + # Per the unified-test-format spec, "additional unexpected spans + # are allowed". Unlike ignoreExtraEvents (which only tolerates + # a trailing tail), spans from concurrent/out-of-band activity + # (e.g. a testRunner-issued configureFailPoint command) can + # finish interleaved anywhere among the expected ones, not just + # at the end. Filter down to just the spans that line up (by + # name, in order) with the expected list, dropping anything + # else, instead of naively truncating the tail. + filtered = [] + expected_iter = iter(expected_list) + current_expected = next(expected_iter, None) + for actual in actual_list: + if current_expected is not None and actual.name == current_expected["name"]: + filtered.append(actual) + current_expected = next(expected_iter, None) + actual_list = filtered + self.assertEqual( + len(expected_list), + len(actual_list), + f"expected spans {[e['name'] for e in expected_list]} but got " + f"{[a.name for a in actual_list]}", + ) + for expected, actual in zip(expected_list, actual_list): + self.assertEqual(expected["name"], actual.name) + actual_attributes = { + k: _normalize_span_attribute_for_match(k, v) + for k, v in actual.attributes.items() + } + self.match_evaluator.match_result(expected["attributes"], actual_attributes) + expected_nested = expected.get("nested") + if expected_nested is not None: + actual_children = children_by_parent_id[actual.context.span_id] + check_span_list(expected_nested, actual_children, ignore_extra_spans) + + for client_spec in spec: + expected_client_id = client_spec["client"] + tracing_client_id = self.entity_map._tracing_client_id + self.assertEqual( + expected_client_id, + tracing_client_id, + f"expectTracingMessages.client {expected_client_id!r} does not match the " + f"client with observeTracingMessages enabled ({tracing_client_id!r})", + ) + + ignore_extra_spans = client_spec.get("ignoreExtraSpans", False) + expected_spans = client_spec["spans"] + self.assertTrue(expected_spans, "expectTracingMessages spans must be non-empty") + + check_span_list(expected_spans, children_by_parent_id[None], ignore_extra_spans) + async def verify_outcome(self, spec): for collection_data in spec: coll_name = collection_data["collectionName"] @@ -1551,6 +1740,10 @@ async def _run_scenario(self, spec, uri=None): expect_log_messages = spec["expectLogMessages"] self.assertTrue(expect_log_messages, "expectEvents must be non-empty") await self.check_log_messages(spec["operations"], expect_log_messages) + elif "expectTracingMessages" in spec: + expect_tracing_messages = spec["expectTracingMessages"] + self.assertTrue(expect_tracing_messages, "expectTracingMessages must be non-empty") + await self.check_tracing_messages(spec["operations"], expect_tracing_messages) else: # process operations await self.run_operations(spec["operations"]) diff --git a/test/open_telemetry/operation/aggregate.json b/test/open_telemetry/operation/aggregate.json new file mode 100644 index 0000000000..07c30a6761 --- /dev/null +++ b/test/open_telemetry/operation/aggregate.json @@ -0,0 +1,127 @@ +{ + "description": "operation aggregate", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-aggregate" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "aggregation", + "operations": [ + { + "name": "aggregate", + "object": "collection0", + "arguments": { + "pipeline": [ + { + "$match": { + "_id": 1 + } + } + ] + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "aggregate operation-aggregate.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-aggregate", + "db.collection.name": "test", + "db.operation.name": "aggregate", + "db.operation.summary": "aggregate operation-aggregate.test" + }, + "nested": [ + { + "name": "aggregate", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-aggregate", + "db.collection.name": "test", + "db.command.name": "aggregate", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "aggregate operation-aggregate.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "aggregate": "test", + "pipeline": [ + { + "$match": { + "_id": 1 + } + } + ] + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/atlas_search.json b/test/open_telemetry/operation/atlas_search.json new file mode 100644 index 0000000000..e961e7a261 --- /dev/null +++ b/test/open_telemetry/operation/atlas_search.json @@ -0,0 +1,317 @@ +{ + "description": "operation atlas_search", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-atlas-search" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.5", + "maxServerVersion": "7.0.99", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + }, + { + "minServerVersion": "7.2.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "atlas search indexes", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + }, + "type": "search" + } + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + }, + { + "name": "updateSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index", + "definition": {} + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + }, + { + "name": "dropSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Atlas" + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "createSearchIndexes operation-atlas-search.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-atlas-search", + "db.collection.name": "test", + "db.operation.name": "createSearchIndexes", + "db.operation.summary": "createSearchIndexes operation-atlas-search.test" + }, + "nested": [ + { + "name": "createSearchIndexes", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-atlas-search", + "db.command.name": "createSearchIndexes", + "db.collection.name": "test", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": true + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "exception.message": { + "$$exists": true + }, + "exception.type": { + "$$exists": true + }, + "exception.stacktrace": { + "$$exists": true + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "createSearchIndexes operation-atlas-search.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "createSearchIndexes": "test", + "indexes": [ + { + "type": "search", + "definition": { + "mappings": { + "dynamic": true + } + } + } + ] + } + } + } + } + } + ] + }, + { + "name": "updateSearchIndex operation-atlas-search.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-atlas-search", + "db.collection.name": "test", + "db.operation.name": "updateSearchIndex", + "db.operation.summary": "updateSearchIndex operation-atlas-search.test" + }, + "nested": [ + { + "name": "updateSearchIndex", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-atlas-search", + "db.command.name": "updateSearchIndex", + "db.collection.name": "test", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": true + }, + "exception.message": { + "$$exists": true + }, + "exception.type": { + "$$exists": true + }, + "exception.stacktrace": { + "$$exists": true + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "updateSearchIndex operation-atlas-search.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "updateSearchIndex": "test", + "name": "test index", + "definition": {} + } + } + } + } + } + ] + }, + { + "name": "dropSearchIndex operation-atlas-search.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-atlas-search", + "db.collection.name": "test", + "db.operation.name": "dropSearchIndex", + "db.operation.summary": "dropSearchIndex operation-atlas-search.test" + }, + "nested": [ + { + "name": "dropSearchIndex", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-atlas-search", + "db.command.name": "dropSearchIndex", + "db.collection.name": "test", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": true + }, + "exception.message": { + "$$exists": true + }, + "exception.type": { + "$$exists": true + }, + "exception.stacktrace": { + "$$exists": true + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "dropSearchIndex operation-atlas-search.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "dropSearchIndex": "test", + "name": "test index" + } + } + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/bulk_write.json b/test/open_telemetry/operation/bulk_write.json new file mode 100644 index 0000000000..312a55f383 --- /dev/null +++ b/test/open_telemetry/operation/bulk_write.json @@ -0,0 +1,333 @@ +{ + "description": "operation bulk_write", + "schemaVersion": "1.27", + "runOnRequirements": [ + { + "minServerVersion": "8.0", + "serverless": "forbid" + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-bulk-write-0" + } + }, + { + "database": { + "id": "database1", + "client": "client0", + "databaseName": "operation-bulk-write-1" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test0" + } + }, + { + "collection": { + "id": "collection1", + "database": "database1", + "collectionName": "test1" + } + } + ], + "initialData": [ + { + "collectionName": "test0", + "databaseName": "operation-bulk-write-0", + "documents": [] + }, + { + "collectionName": "test1", + "databaseName": "operation-bulk-write-1", + "documents": [] + } + ], + "_yamlAnchors": { + "namespace0": "operation-bulk-write-0.test0", + "namespace1": "operation-bulk-write-1.test1" + }, + "tests": [ + { + "description": "bulkWrite", + "operations": [ + { + "object": "client0", + "name": "clientBulkWrite", + "arguments": { + "models": [ + { + "insertOne": { + "namespace": "operation-bulk-write-0.test0", + "document": { + "_id": 8, + "x": 88 + } + } + }, + { + "updateOne": { + "namespace": "operation-bulk-write-0.test0", + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + }, + { + "updateMany": { + "namespace": "operation-bulk-write-1.test1", + "filter": { + "$and": [ + { + "_id": { + "$gt": 1 + } + }, + { + "_id": { + "$lte": 3 + } + } + ] + }, + "update": { + "$inc": { + "x": 2 + } + } + } + }, + { + "replaceOne": { + "namespace": "operation-bulk-write-1.test1", + "filter": { + "_id": 4 + }, + "replacement": { + "x": 44 + }, + "upsert": true + } + }, + { + "deleteOne": { + "namespace": "operation-bulk-write-0.test0", + "filter": { + "_id": 5 + } + } + }, + { + "deleteMany": { + "namespace": "operation-bulk-write-1.test1", + "filter": { + "$and": [ + { + "_id": { + "$gt": 5 + } + }, + { + "_id": { + "$lte": 7 + } + } + ] + } + } + } + ] + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "bulkWrite admin", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.operation.name": "bulkWrite", + "db.operation.summary": "bulkWrite admin" + }, + "nested": [ + { + "name": "bulkWrite", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.command.name": "bulkWrite", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "bulkWrite admin", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "bulkWrite": 1, + "errorsOnly": true, + "ordered": true, + "ops": [ + { + "insert": 0, + "document": { + "_id": 8, + "x": 88 + } + }, + { + "update": 0, + "multi": false, + "filter": { + "_id": 1 + }, + "updateMods": { + "$inc": { + "x": 1 + } + } + }, + { + "update": 1, + "multi": true, + "filter": { + "$and": [ + { + "_id": { + "$gt": 1 + } + }, + { + "_id": { + "$lte": 3 + } + } + ] + }, + "updateMods": { + "$inc": { + "x": 2 + } + } + }, + { + "update": 1, + "multi": false, + "filter": { + "_id": 4 + }, + "updateMods": { + "x": 44 + }, + "upsert": true + }, + { + "delete": 0, + "multi": false, + "filter": { + "_id": 5 + } + }, + { + "delete": 1, + "multi": true, + "filter": { + "$and": [ + { + "_id": { + "$gt": 5 + } + }, + { + "_id": { + "$lte": 7 + } + } + ] + } + } + ], + "nsInfo": [ + { + "ns": "operation-bulk-write-0.test0" + }, + { + "ns": "operation-bulk-write-1.test1" + } + ] + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/count.json b/test/open_telemetry/operation/count.json new file mode 100644 index 0000000000..97a12bda29 --- /dev/null +++ b/test/open_telemetry/operation/count.json @@ -0,0 +1,123 @@ +{ + "description": "operation count", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-count" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-count", + "documents": [] + } + ], + "tests": [ + { + "description": "estimated document count", + "operations": [ + { + "object": "collection0", + "name": "estimatedDocumentCount", + "arguments": {}, + "expectResult": 0 + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "count operation-count.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-count", + "db.collection.name": "test", + "db.operation.name": "count", + "db.operation.summary": "count operation-count.test" + }, + "nested": [ + { + "name": "count", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-count", + "db.collection.name": "test", + "db.command.name": "count", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "count operation-count.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "count": "test" + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/create_collection.json b/test/open_telemetry/operation/create_collection.json new file mode 100644 index 0000000000..2cbbe8c627 --- /dev/null +++ b/test/open_telemetry/operation/create_collection.json @@ -0,0 +1,110 @@ +{ + "description": "operation create collection", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-create-collection" + } + } + ], + "tests": [ + { + "description": "create collection", + "operations": [ + { + "object": "database0", + "name": "createCollection", + "arguments": { + "collection": "newlyCreatedCollection" + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "createCollection operation-create-collection.newlyCreatedCollection", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-create-collection", + "db.collection.name": "newlyCreatedCollection", + "db.operation.name": "createCollection", + "db.operation.summary": "createCollection operation-create-collection.newlyCreatedCollection" + }, + "nested": [ + { + "name": "create", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-create-collection", + "db.collection.name": "newlyCreatedCollection", + "db.command.name": "create", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "create operation-create-collection.newlyCreatedCollection", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "create": "newlyCreatedCollection" + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/create_indexes.json b/test/open_telemetry/operation/create_indexes.json new file mode 100644 index 0000000000..ff1e86d0b2 --- /dev/null +++ b/test/open_telemetry/operation/create_indexes.json @@ -0,0 +1,127 @@ +{ + "description": "operation create_indexes", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-create-indexes" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "create indexes", + "operations": [ + { + "object": "collection0", + "name": "createIndex", + "arguments": { + "keys": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "createIndexes operation-create-indexes.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-create-indexes", + "db.collection.name": "test", + "db.operation.name": "createIndexes", + "db.operation.summary": "createIndexes operation-create-indexes.test" + }, + "nested": [ + { + "name": "createIndexes", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-create-indexes", + "db.collection.name": "test", + "db.command.name": "createIndexes", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "createIndexes operation-create-indexes.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "createIndexes": "test", + "indexes": [ + { + "key": { + "x": 1 + }, + "name": "x_1" + } + ] + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/delete.json b/test/open_telemetry/operation/delete.json new file mode 100644 index 0000000000..151c7abf98 --- /dev/null +++ b/test/open_telemetry/operation/delete.json @@ -0,0 +1,132 @@ +{ + "description": "operation delete", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-delete" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "delete elements", + "operations": [ + { + "object": "collection0", + "name": "deleteMany", + "arguments": { + "filter": { + "_id": { + "$gt": 1 + } + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "delete operation-delete.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-delete", + "db.collection.name": "test", + "db.operation.name": "delete", + "db.operation.summary": "delete operation-delete.test" + }, + "nested": [ + { + "name": "delete", + "attributes": { + "db.system.name": "mongodb", + "db.command.name": "delete", + "db.namespace": "operation-delete", + "db.collection.name": "test", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "delete operation-delete.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "delete": "test", + "ordered": true, + "deletes": [ + { + "q": { + "_id": { + "$gt": 1 + } + }, + "limit": 0 + } + ] + } + } + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/distinct.json b/test/open_telemetry/operation/distinct.json new file mode 100644 index 0000000000..6fdabc682d --- /dev/null +++ b/test/open_telemetry/operation/distinct.json @@ -0,0 +1,127 @@ +{ + "description": "operation distinct", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-distinct" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-distinct", + "documents": [] + } + ], + "tests": [ + { + "description": "distinct on a field", + "operations": [ + { + "object": "collection0", + "name": "distinct", + "arguments": { + "fieldName": "x", + "filter": {} + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "distinct operation-distinct.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-distinct", + "db.collection.name": "test", + "db.operation.name": "distinct", + "db.operation.summary": "distinct operation-distinct.test" + }, + "nested": [ + { + "name": "distinct", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-distinct", + "db.collection.name": "test", + "db.command.name": "distinct", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "distinct operation-distinct.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "distinct": "test", + "key": "x", + "query": {} + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/drop_collection.json b/test/open_telemetry/operation/drop_collection.json new file mode 100644 index 0000000000..cc5358c1f2 --- /dev/null +++ b/test/open_telemetry/operation/drop_collection.json @@ -0,0 +1,122 @@ +{ + "description": "operation drop collection", + "schemaVersion": "1.27", + "runOnRequirements": [ + { + "minServerVersion": "7.0" + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-drop-collection" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "drop collection", + "operations": [ + { + "object": "database0", + "name": "dropCollection", + "arguments": { + "collection": "test" + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "dropCollection operation-drop-collection.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-drop-collection", + "db.collection.name": "test", + "db.operation.name": "dropCollection", + "db.operation.summary": "dropCollection operation-drop-collection.test" + }, + "nested": [ + { + "name": "drop", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-drop-collection", + "db.collection.name": "test", + "db.command.name": "drop", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "drop operation-drop-collection.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "drop": "test" + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/drop_indexes.json b/test/open_telemetry/operation/drop_indexes.json new file mode 100644 index 0000000000..91126c9227 --- /dev/null +++ b/test/open_telemetry/operation/drop_indexes.json @@ -0,0 +1,145 @@ +{ + "description": "operation drop indexes", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-drop-indexes" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "client": { + "id": "clientWithoutTracing", + "useMultipleMongoses": false + } + }, + { + "database": { + "id": "databaseWithoutTracing", + "client": "clientWithoutTracing", + "databaseName": "operation-drop-indexes" + } + }, + { + "collection": { + "id": "collectionWithoutTracing", + "database": "databaseWithoutTracing", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "drop indexes", + "operations": [ + { + "name": "createIndex", + "object": "collectionWithoutTracing", + "arguments": { + "keys": { + "x": 1 + }, + "name": "x_1" + } + }, + { + "name": "dropIndexes", + "object": "collection0" + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "dropIndexes operation-drop-indexes.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-drop-indexes", + "db.collection.name": "test", + "db.operation.name": "dropIndexes", + "db.operation.summary": "dropIndexes operation-drop-indexes.test" + }, + "nested": [ + { + "name": "dropIndexes", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-drop-indexes", + "db.collection.name": "test", + "db.command.name": "dropIndexes", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "dropIndexes operation-drop-indexes.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "dropIndexes": "test", + "index": "*" + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/find.json b/test/open_telemetry/operation/find.json new file mode 100644 index 0000000000..555a972b9b --- /dev/null +++ b/test/open_telemetry/operation/find.json @@ -0,0 +1,291 @@ +{ + "description": "operation find", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-find" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-find", + "documents": [] + } + ], + "tests": [ + { + "description": "find an element", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "find operation-find.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-find.test" + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "find operation-find.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "find": "test", + "filter": { + "x": 1 + } + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + }, + { + "description": "find an element retrying failed command", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "client0", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "errorCode": 89, + "errorLabels": [ + "RetryableWriteError" + ] + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-find.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-find.test" + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": "89", + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-find.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "find": "test", + "filter": { + "x": 1 + } + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + }, + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "find operation-find.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "find": "test", + "filter": { + "x": 1 + } + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/find_and_modify.json b/test/open_telemetry/operation/find_and_modify.json new file mode 100644 index 0000000000..7bacba57f8 --- /dev/null +++ b/test/open_telemetry/operation/find_and_modify.json @@ -0,0 +1,143 @@ +{ + "description": "operation find_one_and_update", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-find-one-and-update" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "findOneAndUpdate", + "runOnRequirements": [ + { + "minServerVersion": "4.4" + } + ], + "operations": [ + { + "name": "findOneAndUpdate", + "object": "collection0", + "arguments": { + "filter": { + "_id": 1 + }, + "update": [ + { + "$set": { + "x": 5 + } + } + ], + "comment": "comment" + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "findAndModify operation-find-one-and-update.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find-one-and-update", + "db.collection.name": "test", + "db.operation.name": "findAndModify", + "db.operation.summary": "findAndModify operation-find-one-and-update.test" + }, + "nested": [ + { + "name": "findAndModify", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find-one-and-update", + "db.collection.name": "test", + "db.command.name": "findAndModify", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "findAndModify operation-find-one-and-update.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "findAndModify": "test", + "query": { + "_id": 1 + }, + "update": [ + { + "$set": { + "x": 5 + } + } + ], + "comment": "comment" + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/find_without_query_text.json b/test/open_telemetry/operation/find_without_query_text.json new file mode 100644 index 0000000000..0a2464ba60 --- /dev/null +++ b/test/open_telemetry/operation/find_without_query_text.json @@ -0,0 +1,119 @@ +{ + "description": "operation find without db.query.text", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": false + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-find" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-find", + "documents": [] + } + ], + "tests": [ + { + "description": "find an element", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "find operation-find.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-find.test" + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-find", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "find operation-find.test", + "db.query.text": { + "$$exists": false + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/insert.json b/test/open_telemetry/operation/insert.json new file mode 100644 index 0000000000..6ab0450357 --- /dev/null +++ b/test/open_telemetry/operation/insert.json @@ -0,0 +1,146 @@ +{ + "description": "operation insert", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-insert" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-insert", + "documents": [] + } + ], + "tests": [ + { + "description": "insert one element", + "operations": [ + { + "object": "collection0", + "name": "insertOne", + "arguments": { + "document": { + "_id": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "insert operation-insert.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-insert", + "db.collection.name": "test", + "db.operation.name": "insert", + "db.operation.summary": "insert operation-insert.test" + }, + "nested": [ + { + "name": "insert", + "attributes": { + "db.system.name": "mongodb", + "db.command.name": "insert", + "db.namespace": "operation-insert", + "db.collection.name": "test", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "insert operation-insert.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "insert": "test", + "ordered": true, + "txnNumber": { + "$$unsetOrMatches": 1 + }, + "documents": [ + { + "_id": 1 + } + ] + } + } + } + } + } + ] + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "operation-insert", + "documents": [ + { + "_id": 1 + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/list_collections.json b/test/open_telemetry/operation/list_collections.json new file mode 100644 index 0000000000..e3a28dd36a --- /dev/null +++ b/test/open_telemetry/operation/list_collections.json @@ -0,0 +1,108 @@ +{ + "description": "operation list_collections", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-list-collections" + } + } + ], + "tests": [ + { + "description": "List collections", + "operations": [ + { + "object": "database0", + "name": "listCollections" + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "listCollections operation-list-collections", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-list-collections", + "db.operation.name": "listCollections", + "db.operation.summary": "listCollections operation-list-collections", + "db.collection.name": { + "$$exists": false + } + }, + "nested": [ + { + "name": "listCollections", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-list-collections", + "db.collection.name": { + "$$exists": false + }, + "db.command.name": "listCollections", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "listCollections operation-list-collections", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "listCollections": 1 + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/list_databases.json b/test/open_telemetry/operation/list_databases.json new file mode 100644 index 0000000000..ae86b87073 --- /dev/null +++ b/test/open_telemetry/operation/list_databases.json @@ -0,0 +1,104 @@ +{ + "description": "operation list_databases", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + } + ], + "tests": [ + { + "description": "list databases", + "operations": [ + { + "object": "client0", + "name": "listDatabases" + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "listDatabases admin", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.operation.name": "listDatabases", + "db.operation.summary": "listDatabases admin", + "db.collection.name": { + "$$exists": false + } + }, + "nested": [ + { + "name": "listDatabases", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.command.name": "listDatabases", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "listDatabases admin", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "listDatabases": 1 + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/list_indexes.json b/test/open_telemetry/operation/list_indexes.json new file mode 100644 index 0000000000..d37138d275 --- /dev/null +++ b/test/open_telemetry/operation/list_indexes.json @@ -0,0 +1,118 @@ +{ + "description": "operation list_indexes", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-list-indexes" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-list-indexes", + "documents": [] + } + ], + "tests": [ + { + "description": "List indexes", + "operations": [ + { + "object": "collection0", + "name": "listIndexes" + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "listIndexes operation-list-indexes.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-list-indexes", + "db.collection.name": "test", + "db.operation.name": "listIndexes", + "db.operation.summary": "listIndexes operation-list-indexes.test" + }, + "nested": [ + { + "name": "listIndexes", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-list-indexes", + "db.collection.name": "test", + "db.command.name": "listIndexes", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "listIndexes operation-list-indexes.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "listIndexes": "test" + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/map_reduce.json b/test/open_telemetry/operation/map_reduce.json new file mode 100644 index 0000000000..9230f7fd86 --- /dev/null +++ b/test/open_telemetry/operation/map_reduce.json @@ -0,0 +1,177 @@ +{ + "description": "operation map_reduce", + "schemaVersion": "1.27", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "single", + "replicaset" + ] + }, + { + "minServerVersion": "4.1.7", + "serverless": "forbid", + "topologies": [ + "sharded", + "load-balanced" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-map-reduce" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-map-reduce", + "documents": [ + { + "_id": 1, + "x": 0 + }, + { + "_id": 2, + "x": 1 + }, + { + "_id": 3, + "x": 2 + } + ] + } + ], + "tests": [ + { + "description": "mapReduce", + "operations": [ + { + "object": "collection0", + "name": "mapReduce", + "arguments": { + "map": { + "$code": "function inc() { return emit(0, this.x + 1) }" + }, + "reduce": { + "$code": "function sum(key, values) { return values.reduce((acc, x) => acc + x); }" + }, + "out": { + "inline": 1 + } + }, + "expectResult": [ + { + "_id": 0, + "value": 6 + } + ] + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "mapReduce operation-map-reduce.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-map-reduce", + "db.collection.name": "test", + "db.operation.name": "mapReduce", + "db.operation.summary": "mapReduce operation-map-reduce.test" + }, + "nested": [ + { + "name": "mapReduce", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-map-reduce", + "db.collection.name": "test", + "db.command.name": "mapReduce", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "mapReduce operation-map-reduce.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "mapReduce": "test", + "map": { + "$code": "function inc() { return emit(0, this.x + 1) }" + }, + "reduce": { + "$code": "function sum(key, values) { return values.reduce((acc, x) => acc + x); }" + }, + "out": { + "inline": 1 + } + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/retries.json b/test/open_telemetry/operation/retries.json new file mode 100644 index 0000000000..d42f83f333 --- /dev/null +++ b/test/open_telemetry/operation/retries.json @@ -0,0 +1,209 @@ +{ + "description": "retries", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "client": { + "id": "failPointClient", + "useMultipleMongoses": false + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-retries" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-retries", + "documents": [] + } + ], + "tests": [ + { + "description": "find an element with retries", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "errorCode": 89, + "errorLabels": [ + "RetryableWriteError" + ] + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-retries.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-retries", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-retries.test" + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-retries", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": "89", + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-retries.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "find": "test", + "filter": { + "x": 1 + } + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + }, + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-retries", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "find operation-retries.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "find": "test", + "filter": { + "x": 1 + } + } + } + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/operation/update.json b/test/open_telemetry/operation/update.json new file mode 100644 index 0000000000..509c98be30 --- /dev/null +++ b/test/open_telemetry/operation/update.json @@ -0,0 +1,140 @@ +{ + "description": "operation update", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-update" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "tests": [ + { + "description": "update one element", + "operations": [ + { + "object": "collection0", + "name": "updateOne", + "arguments": { + "filter": { + "_id": 1 + }, + "update": { + "$inc": { + "x": 1 + } + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "update operation-update.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-update", + "db.collection.name": "test", + "db.operation.name": "update", + "db.operation.summary": "update operation-update.test" + }, + "nested": [ + { + "name": "update", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-update", + "db.collection.name": "test", + "db.command.name": "update", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "update operation-update.test", + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "update": "test", + "ordered": true, + "txnNumber": { + "$$unsetOrMatches": 1 + }, + "updates": [ + { + "q": { + "_id": 1 + }, + "u": { + "$inc": { + "x": 1 + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/transaction/convenient.json b/test/open_telemetry/transaction/convenient.json new file mode 100644 index 0000000000..a8f0c212d1 --- /dev/null +++ b/test/open_telemetry/transaction/convenient.json @@ -0,0 +1,326 @@ +{ + "description": "convenient transactions", + "schemaVersion": "1.27", + "runOnRequirements": [ + { + "minServerVersion": "4.4", + "topologies": [ + "replicaset", + "sharded" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database", + "client": "client", + "databaseName": "convenient-transaction-tests" + } + }, + { + "collection": { + "id": "collection", + "database": "database", + "collectionName": "test" + } + }, + { + "session": { + "id": "session", + "client": "client" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "convenient-transaction-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "withTransaction", + "operations": [ + { + "name": "withTransaction", + "object": "session", + "arguments": { + "callback": [ + { + "name": "insertOne", + "object": "collection", + "arguments": { + "document": { + "_id": 1 + }, + "session": "session" + } + } + ] + } + }, + { + "name": "find", + "object": "collection", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "transaction", + "attributes": { + "db.system.name": "mongodb" + }, + "nested": [ + { + "name": "insert convenient-transaction-tests.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "convenient-transaction-tests", + "db.collection.name": "test", + "db.operation.name": "insert", + "db.operation.summary": "insert convenient-transaction-tests.test" + }, + "nested": [ + { + "name": "insert", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "convenient-transaction-tests", + "db.collection.name": "test", + "db.command.name": "insert", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "insert convenient-transaction-tests.test", + "db.mongodb.lsid": { + "$$sessionLsid": "session" + }, + "db.mongodb.txn_number": 1, + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "insert": "test", + "ordered": true, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "documents": [ + { + "_id": 1 + } + ] + } + } + } + } + } + ] + }, + { + "name": "commitTransaction admin", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.operation.name": "commitTransaction", + "db.operation.summary": "commitTransaction admin" + }, + "nested": [ + { + "name": "commitTransaction", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.query.summary": "commitTransaction admin", + "db.command.name": "commitTransaction", + "db.mongodb.lsid": { + "$$sessionLsid": "session" + }, + "db.mongodb.txn_number": 1, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "commitTransaction": 1, + "txnNumber": 1, + "autocommit": false + } + } + } + } + } + ] + } + ] + }, + { + "name": "find convenient-transaction-tests.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "convenient-transaction-tests", + "db.collection.name": "test", + "db.operation.summary": "find convenient-transaction-tests.test", + "db.operation.name": "find" + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "convenient-transaction-tests", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.summary": "find convenient-transaction-tests.test", + "db.query.text": { + "$$exists": true + } + } + } + ] + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "convenient-transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + } + ] +} diff --git a/test/open_telemetry/transaction/core_api.json b/test/open_telemetry/transaction/core_api.json new file mode 100644 index 0000000000..925cdb5162 --- /dev/null +++ b/test/open_telemetry/transaction/core_api.json @@ -0,0 +1,545 @@ +{ + "description": "transaction spans", + "schemaVersion": "1.27", + "runOnRequirements": [ + { + "minServerVersion": "4.0", + "topologies": [ + "replicaset" + ] + }, + { + "minServerVersion": "4.1.8", + "topologies": [ + "sharded", + "load-balanced" + ] + } + ], + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeTracingMessages": { + "enableCommandPayload": true + } + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "transaction-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + }, + { + "session": { + "id": "session0", + "client": "client0" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [] + } + ], + "tests": [ + { + "description": "commit transaction", + "operations": [ + { + "object": "session0", + "name": "startTransaction" + }, + { + "object": "collection0", + "name": "insertOne", + "arguments": { + "session": "session0", + "document": { + "_id": 1 + } + } + }, + { + "object": "session0", + "name": "commitTransaction" + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "transaction", + "attributes": { + "db.system.name": "mongodb" + }, + "nested": [ + { + "name": "insert transaction-tests.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "transaction-tests", + "db.collection.name": "test", + "db.operation.name": "insert", + "db.operation.summary": "insert transaction-tests.test" + }, + "nested": [ + { + "name": "insert", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "transaction-tests", + "db.collection.name": "test", + "db.command.name": "insert", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "insert transaction-tests.test", + "db.mongodb.lsid": { + "$$sessionLsid": "session0" + }, + "db.mongodb.txn_number": 1, + "network.transport": "tcp", + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "insert": "test", + "ordered": true, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "documents": [ + { + "_id": 1 + } + ] + } + } + } + } + } + ] + }, + { + "name": "commitTransaction admin", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.operation.name": "commitTransaction", + "db.operation.summary": "commitTransaction admin" + }, + "nested": [ + { + "name": "commitTransaction", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.query.summary": "commitTransaction admin", + "db.command.name": "commitTransaction", + "db.mongodb.lsid": { + "$$sessionLsid": "session0" + }, + "db.mongodb.txn_number": 1, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "commitTransaction": 1, + "txnNumber": 1, + "autocommit": false + } + } + } + } + } + ] + } + ] + }, + { + "name": "find transaction-tests.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "transaction-tests", + "db.collection.name": "test", + "db.operation.summary": "find transaction-tests.test", + "db.operation.name": "find" + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "transaction-tests", + "db.collection.name": "test", + "db.command.name": "find", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$exists": true + }, + "db.query.summary": "find transaction-tests.test" + } + } + ] + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [ + { + "_id": 1 + } + ] + } + ] + }, + { + "description": "abort transaction", + "operations": [ + { + "object": "session0", + "name": "startTransaction" + }, + { + "object": "collection0", + "name": "insertOne", + "arguments": { + "session": "session0", + "document": { + "_id": 1 + } + } + }, + { + "object": "session0", + "name": "abortTransaction" + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": false, + "spans": [ + { + "name": "transaction", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": { + "$$exists": false + }, + "db.collection.name": { + "$$exists": false + }, + "db.operation.name": { + "$$exists": false + }, + "db.operation.summary": { + "$$exists": false + } + }, + "nested": [ + { + "name": "insert transaction-tests.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "transaction-tests", + "db.collection.name": "test", + "db.operation.name": "insert", + "db.operation.summary": "insert transaction-tests.test" + }, + "nested": [ + { + "name": "insert", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "transaction-tests", + "db.collection.name": "test", + "db.command.name": "insert", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "insert transaction-tests.test", + "db.mongodb.lsid": { + "$$sessionLsid": "session0" + }, + "db.mongodb.txn_number": 1, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "insert": "test", + "ordered": true, + "txnNumber": 1, + "startTransaction": true, + "autocommit": false, + "documents": [ + { + "_id": 1 + } + ] + } + } + } + } + } + ] + }, + { + "name": "abortTransaction admin", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.operation.name": "abortTransaction", + "db.operation.summary": "abortTransaction admin" + }, + "nested": [ + { + "name": "abortTransaction", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "admin", + "db.collection.name": { + "$$exists": false + }, + "db.query.summary": "abortTransaction admin", + "db.command.name": "abortTransaction", + "db.mongodb.lsid": { + "$$sessionLsid": "session0" + }, + "db.mongodb.txn_number": 1, + "db.mongodb.cursor_id": { + "$$exists": false + }, + "db.response.status_code": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "network.transport": "tcp", + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.query.text": { + "$$matchAsDocument": { + "$$matchAsRoot": { + "abortTransaction": 1, + "txnNumber": 1, + "autocommit": false + } + } + } + } + } + ] + } + ] + } + ] + } + ], + "outcome": [ + { + "collectionName": "test", + "databaseName": "transaction-tests", + "documents": [] + } + ] + } + ] +} diff --git a/test/test_common.py b/test/test_common.py index b0c706b9f2..705dc5f78e 100644 --- a/test/test_common.py +++ b/test/test_common.py @@ -25,12 +25,26 @@ from bson.codec_options import CodecOptions from bson.objectid import ObjectId from pymongo.errors import OperationFailure +from pymongo.helpers_shared import _split_namespace from pymongo.write_concern import WriteConcern from test import IntegrationTest, client_context, connected, unittest _IS_SYNC = True +class TestSplitNamespace(unittest.TestCase): + def test_plain_namespace(self): + self.assertEqual(_split_namespace("db.coll"), ("db", "coll")) + + def test_collection_name_with_dots(self): + self.assertEqual(_split_namespace("db.coll.with.dots"), ("db", "coll.with.dots")) + + def test_no_dot(self): + # No separator: the whole string is treated as the database name + # and the collection name is empty, matching str.partition. + self.assertEqual(_split_namespace("dbonly"), ("dbonly", "")) + + class TestCommon(IntegrationTest): def test_uuid_representation(self): coll = self.db.uuid diff --git a/test/test_json_util.py b/test/test_json_util.py index cd86456ab0..81f0f5ad2b 100644 --- a/test/test_json_util.py +++ b/test/test_json_util.py @@ -635,6 +635,27 @@ class MyBinary(Binary): expected_json = json_util.dumps(Binary(b"bin", USER_DEFINED_SUBTYPE)) self.assertEqual(json_util.dumps(MyBinary(b"bin", USER_DEFINED_SUBTYPE)), expected_json) + def test_truncate_documents_retains_falsy_values(self): + # Regression test: _truncate_documents (used by pymongo/logger.py for + # structured command logging, and by pymongo/_otel.py for OTel's + # db.query.text) must not drop fields whose value is falsy-but-present + # (0, False, "", {}, []); only fields that genuinely don't fit within + # the remaining budget should be omitted. A prior implementation used + # `if truncated_v:` to decide whether to keep a field, which silently + # dropped legitimate falsy values along with truly-out-of-room ones. + doc = { + "a": 0, + "b": False, + "c": "", + "d": {}, + "e": [], + "f": None, + "g": [0, False, "", {}, [], None], + } + truncated, remaining = json_util._truncate_documents(doc, 1000) + self.assertEqual(truncated, doc) + self.assertGreater(remaining, 0) + if __name__ == "__main__": unittest.main() diff --git a/test/test_open_telemetry_unified.py b/test/test_open_telemetry_unified.py new file mode 100644 index 0000000000..a3d47ab71d --- /dev/null +++ b/test/test_open_telemetry_unified.py @@ -0,0 +1,82 @@ +# 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. + +"""Run the OpenTelemetry unified format spec tests.""" + +from __future__ import annotations + +import os +import sys + +sys.path[0:0] = [""] + +import pytest + +from bson import json_util +from test import client_context, unittest +from test.unified_format import generate_test_classes, get_test_path + +_IS_SYNC = True + +pytestmark = pytest.mark.otel + + +def _otel_fixture_database_names() -> set[str]: + """Collect every databaseName declared in the vendored OTel unified-test + fixtures' createEntities blocks, so the cleanup fixture below never goes + stale as fixtures are added, renamed, or removed.""" + names: set[str] = set() + for dirpath, _, filenames in os.walk(get_test_path("open_telemetry")): + for filename in filenames: + if not filename.endswith(".json"): + continue + with open(os.path.join(dirpath, filename)) as scenario_stream: + scenario_def = json_util.loads(scenario_stream.read()) + for entity in scenario_def.get("createEntities", []): + database = entity.get("database") + if database is not None: + names.add(database["databaseName"]) + return names + + +@pytest.fixture(scope="module", autouse=True) +def _drop_otel_fixture_databases(): + # Some vendored OTel fixtures (e.g. operation/create_collection.json) + # are not idempotent: they create a collection and rely on the unified + # runner's insert_initial_data step to drop it beforehand, but that step + # only runs when the fixture declares a non-null `initialData`; this + # one declares none, so nothing ever cleans the collection up between + # runs. That's invisible upstream, where each fixture runs once per + # process. PyMongo, however, runs every fixture *twice* per process: once + # from this async module and again from its synchro-generated mirror, + # test/test_open_telemetry_unified.py. The second pass then collides with + # the first pass's leftover collection (NamespaceExists), which every + # server version CI actually tests against (unlike our local 8.2+ server) + # treats as a hard error rather than a no-op. Dropping the fixture + # databases once before the module's tests run guarantees both passes + # start from a clean slate. + for name in _otel_fixture_database_names(): + client_context.client.drop_database(name) + yield + + +globals().update( + generate_test_classes( + get_test_path("open_telemetry"), + module=__name__, + ) +) + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_operation_id_retry.py b/test/test_operation_id_retry.py index cae8927a37..4311e878db 100644 --- a/test/test_operation_id_retry.py +++ b/test/test_operation_id_retry.py @@ -149,10 +149,17 @@ def test_retry_without_listeners_or_logging_creates_no_operation_id(self): find_op_ids = [] original_init = _CommandTelemetry.__init__ - def recording_init(self, topology_id, conn, listeners, cmd, dbname, request_id, op_id): + # Accept and forward any trailing arguments (e.g. tracing_options, + # speculative_hello) so this stays working as _CommandTelemetry gains + # parameters; only cmd and op_id are of interest here. + def recording_init( + self, topology_id, conn, listeners, cmd, dbname, request_id, op_id, *args, **kwargs + ): if next(iter(cmd)) == "find": find_op_ids.append(op_id) - original_init(self, topology_id, conn, listeners, cmd, dbname, request_id, op_id) + original_init( + self, topology_id, conn, listeners, cmd, dbname, request_id, op_id, *args, **kwargs + ) fail_point = { "mode": {"times": 1}, diff --git a/test/test_otel.py b/test/test_otel.py index d0e3a55fe5..99b9226f6b 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -26,18 +26,29 @@ import pytest import pymongo._otel as _otel -from pymongo import common -from pymongo.errors import ConfigurationError, OperationFailure +from pymongo import _telemetry, common +from pymongo._telemetry import _OperationTelemetry +from pymongo.errors import ( + ClientBulkWriteException, + ConfigurationError, + InvalidOperation, + OperationFailure, + ServerSelectionTimeoutError, +) +from pymongo.operations import InsertOne +from pymongo.read_preferences import ReadPreference from pymongo.typings import _Address -from test import IntegrationTest, unittest +from test import IntegrationTest, client_context, unittest +from test.unified_format_shared import _shared_test_provider +from test.utils import wait_until _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 + from opentelemetry.trace import StatusCode _HAS_OTEL_TEST_DEPS = True except ImportError: @@ -48,19 +59,368 @@ pytestmark = pytest.mark.otel -def _shared_test_provider() -> TracerProvider: - """Return a process-wide SDK TracerProvider for tests to attach exporters to. +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOTelOperationSpanPrimitives(unittest.TestCase): + """Unit tests for the pymongo._otel operation-span primitives (no live server needed).""" - ``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 + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/synchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_start_operation_span_disabled_returns_none(self): + handle = _otel.start_operation_span(None, "find", None) + self.assertIsNone(handle) + + def test_start_operation_span_success_sets_provisional_attributes(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None) + self.assertIsNotNone(handle) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + self.assertEqual(span.attributes["db.system.name"], "mongodb") + self.assertEqual(span.attributes["db.operation.name"], "find") + self.assertEqual(span.status.status_code, StatusCode.UNSET) + + def test_start_operation_span_failure_records_exception(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "insert", None) + _otel.end_operation_span_failure(handle, ValueError("boom")) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertEqual(len(span.events), 1) + self.assertEqual(span.events[0].name, "exception") + + def test_start_operation_span_with_parent(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + parent_handle = _otel.start_operation_span(opts, "transaction", None) + handle = _otel.start_operation_span(opts, "insert", parent_handle.span) + _otel.end_operation_span_success(handle) + _otel.end_operation_span_success(parent_handle) + child, parent = self.exporter.get_finished_spans() + self.assertEqual(child.parent.span_id, parent.context.span_id) + + def test_current_operation_name_contextvar_scoped_correctly(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + handle = _otel.start_operation_span(opts, "find", None) + self.assertEqual(_otel._CURRENT_OPERATION_NAME.get(), "find") + _otel.end_operation_span_success(handle) + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + + def test_eager_dbname_and_collection_set_at_creation(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, dbname="mydb", collection="mycoll") + self.assertIsNotNone(handle) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find mydb.mycoll") + self.assertEqual(span.attributes["db.namespace"], "mydb") + self.assertEqual(span.attributes["db.collection.name"], "mycoll") + self.assertEqual(span.attributes["db.operation.summary"], "find mydb.mycoll") + self.assertEqual(span.attributes["db.operation.name"], "find") + + def test_eager_dbname_without_collection_omits_collection_attribute(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "listCollections", None, dbname="mydb") + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "listCollections mydb") + self.assertEqual(span.attributes["db.operation.summary"], "listCollections mydb") + self.assertNotIn("db.collection.name", span.attributes) + + def test_no_eager_attributes_leaves_provisional_name(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + self.assertNotIn("db.namespace", span.attributes) + # db.operation.summary is Required (unlike db.namespace, which is only + # "Required if available"), so it always falls back to the bare + # operation name when no dbname is given. + self.assertEqual(span.attributes["db.operation.summary"], "find") + + def test_detached_span_is_not_current_until_used(self): + from opentelemetry import trace + + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + self.assertIsNotNone(handle) + # Not current, and the operation-name contextvar is untouched. + self.assertIsNot(trace.get_current_span(), handle.span) + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + with _otel.use_operation_span(handle): + self.assertIs(trace.get_current_span(), handle.span) + self.assertEqual(_otel._CURRENT_OPERATION_NAME.get(), "find") + # Restored afterwards, and the span is still open (not ended). + self.assertIsNot(trace.get_current_span(), handle.span) + self.assertIsNone(_otel._CURRENT_OPERATION_NAME.get()) + self.assertEqual(self.exporter.get_finished_spans(), ()) + _otel.end_operation_span_success(handle) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + + def test_detached_span_reused_across_multiple_use_blocks(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + for _ in range(3): + with _otel.use_operation_span(handle): + pass + self.assertEqual(self.exporter.get_finished_spans(), ()) + _otel.end_operation_span_success(handle) + self.assertEqual(len(self.exporter.get_finished_spans()), 1) + + def test_use_operation_span_with_none_handle_is_noop(self): + with _otel.use_operation_span(None): + pass + self.assertEqual(self.exporter.get_finished_spans(), ()) + + def test_detached_span_failure_inside_use_block_records_exception_once(self): + # Regression test: trace.use_span's record_exception/ + # set_status_on_exception default to True, so without explicitly + # disabling them, an exception propagating out of a `with + # use_operation_span(handle):` block gets auto-recorded there *and + # again* by the caller's own end_operation_span_failure, producing + # two identical "exception" events on the finished span. + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + exc = ValueError("boom") + try: + with _otel.use_operation_span(handle): + raise exc + except ValueError: + pass + _otel.end_operation_span_failure(handle, exc) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + exception_events = [e for e in span.events if e.name == "exception"] + self.assertEqual(len(exception_events), 1) + + def test_detached_span_failure_without_use_block(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + handle = _otel.start_operation_span(opts, "find", None, set_current=False) + _otel.end_operation_span_failure(handle, ValueError("boom")) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + exception_events = [e for e in span.events if e.name == "exception"] + self.assertEqual(len(exception_events), 1) + + +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOTelTransactionSpanPrimitives(unittest.TestCase): + """Unit tests for the pymongo._otel transaction-span primitives (no live server needed).""" + + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/synchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_start_transaction_span_disabled_returns_none(self): + self.assertIsNone(_otel.start_transaction_span(None)) + + def test_start_transaction_span_has_only_one_attribute(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + span = _otel.start_transaction_span(opts) + _otel.end_transaction_span(span) + (finished,) = self.exporter.get_finished_spans() + self.assertEqual(finished.name, "transaction") + self.assertEqual(dict(finished.attributes), {"db.system.name": "mongodb"}) + + def test_end_transaction_span_is_none_safe(self): + _otel.end_transaction_span(None) # must not raise + + +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOperationTelemetry(unittest.TestCase): + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/synchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_succeeded_with_no_session(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, "find", None) + telemetry.succeeded() + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "find") + self.assertIsNone(span.parent) + + def test_failed_records_exception(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, "insert", None) + telemetry.failed(RuntimeError("nope")) + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_nests_under_active_transaction_span(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + txn_span = _otel.start_transaction_span(opts) + + class _FakeTransaction: + span = txn_span + + class _FakeSession: + in_transaction = True + _transaction = _FakeTransaction() + + telemetry = _telemetry._OperationTelemetry(opts, "insert", _FakeSession()) + telemetry.succeeded() + _otel.end_transaction_span(txn_span) + child, parent = self.exporter.get_finished_spans() + self.assertEqual(child.parent.span_id, parent.context.span_id) + + def test_disabled_is_a_no_op(self): + telemetry = _telemetry._OperationTelemetry(None, "find", None) + telemetry.succeeded() # must not raise + telemetry2 = _telemetry._OperationTelemetry(None, "find", None) + telemetry2.failed(RuntimeError("x")) # must not raise + self.assertEqual(self.exporter.get_finished_spans(), ()) + + def test_operation_name_normalizes_enum_operation(self): + # Most _retry_internal call sites pass an `_Op` enum member (a + # `str`-mixin enum), not a plain string, as the `operation` argument. + # Python 3.11 changed `Enum.__format__` for `str`-mixin enums so that + # f"{_Op.INSERT}"/str(_Op.INSERT) produce "_Op.INSERT" instead of + # "insert": on 3.10 the same code happened to already produce the + # bare value, which is why this bug wasn't caught there. This test is + # meaningful (and must pass) on every supported Python version. + from pymongo.operations import _Op + + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, _Op.INSERT, None) + telemetry.succeeded() + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "insert") + self.assertEqual(span.attributes["db.operation.name"], "insert") + self.assertIs(type(span.attributes["db.operation.name"]), str) + + def test_run_command_operation_name_override(self): + # Database.command() (is_run_command=True) must produce a "runCommand" + # operation span, per the OTel driver spec's span-name rule and its + # db.namespace/db.collection.name examples, not one named after the + # specific command sent. + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _telemetry._OperationTelemetry(opts, "ping", None, is_run_command=True) + telemetry.succeeded() + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "runCommand") + self.assertEqual(span.attributes["db.operation.name"], "runCommand") + + +@unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") +class TestOperationTelemetryContextManager(unittest.TestCase): + """Unit tests for _OperationTelemetry's context-manager and detached modes.""" + + @classmethod + def setUpClass(cls): + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, + ) + + cls.exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + + @classmethod + def tearDownClass(cls): + # See the matching comment in test/synchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + + def setUp(self): + self.exporter.clear() + + def test_context_manager_success_ends_span(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + with _OperationTelemetry(opts, "killCursors", None, dbname="mydb", collection="c"): + pass + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.name, "killCursors mydb.c") + self.assertEqual(span.status.status_code, StatusCode.UNSET) + + def test_context_manager_failure_records_exception(self): + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + with self.assertRaises(ValueError): + with _OperationTelemetry(opts, "killCursors", None, dbname="mydb"): + raise ValueError("boom") + (span,) = self.exporter.get_finished_spans() + self.assertEqual(span.status.status_code, StatusCode.ERROR) + self.assertEqual(span.attributes["exception.type"], "ValueError") + + def test_context_manager_disabled_is_noop(self): + with _OperationTelemetry(None, "killCursors", None, dbname="mydb"): + pass + self.assertEqual(self.exporter.get_finished_spans(), ()) + + def test_detached_telemetry_use_makes_span_current(self): + from opentelemetry import trace + + opts: _otel.TracingOptions = {"enabled": True, "query_text_max_length": None} + telemetry = _OperationTelemetry( + opts, "find", None, dbname="mydb", collection="c", set_current=False + ) + self.assertIsNot(trace.get_current_span(), telemetry.handle.span) + with telemetry.use(): + self.assertIs(trace.get_current_span(), telemetry.handle.span) + self.assertEqual(self.exporter.get_finished_spans(), ()) + telemetry.succeeded() + self.assertEqual(len(self.exporter.get_finished_spans()), 1) @unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") @@ -71,6 +431,16 @@ def setUpClass(cls): cls.exporter = InMemorySpanExporter() _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls.exporter)) + @classmethod + def tearDownClass(cls): + # See the matching comment in test/synchronous/unified_format.py's + # UnifiedSpecTestMixinV1.tearDownClass: the span processor can never + # be removed from the shared process-wide TracerProvider, so without + # this shutdown() the exporter keeps accumulating every span from + # every client for the rest of the test run. + cls.exporter.shutdown() + super().tearDownClass() + def setUp(self): super().setUp() self.exporter.clear() @@ -81,36 +451,53 @@ def spans(self, name: str | None = None): return list(finished) return [s for s in finished if s.name == name] - # TODO(PYTHON-5947): once the unified test format runner supports - # expectTracingMessages/operation spans, this is superseded by the spec's - # find_without_query_text.yml and insert.yml. - def test_span_created_for_insert_and_find(self): + def test_operation_span_wraps_command_span_for_find(self): client = self.rs_or_single_client(tracing={"enabled": True}) - coll = client[self.db.name].test_otel - coll.drop() - self.exporter.clear() + coll = client[self.db.name].test coll.insert_one({"x": 1}) + self.exporter.clear() + coll.find_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) + finished = self.exporter.get_finished_spans() + # Identify the operation vs. command span by their distinguishing + # attribute (db.operation.name / db.command.name) rather than by + # span.name: start_command_span backfills the operation span's name + # to a query summary (e.g. "find .") once the first command + # inside it runs, so only the command span still literally reads "find". + matching = [s for s in finished if s.attributes.get("db.operation.name") == "find"] + self.assertEqual(len(matching), 1) + op_span = matching[0] + self.assertEqual(op_span.attributes["db.namespace"], self.db.name) + self.assertEqual(op_span.attributes["db.collection.name"], "test") + cmd_spans = [s for s in finished if s.attributes.get("db.command.name") == "find"] + self.assertEqual(len(cmd_spans), 1) + cmd_span = cmd_spans[0] + self.assertEqual(cmd_span.name, "find") + self.assertIsNotNone(cmd_span.parent) + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + def test_operation_span_records_failure(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test 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") + with self.assertRaises(OperationFailure): + coll.find_one({"$invalidOperator": 1}) + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "find" + ] + self.assertEqual(len(matching), 1) + op_span = matching[0] + self.assertEqual(op_span.status.status_code, StatusCode.ERROR) + # The operation-span Exceptions section of the OTel spec requires the + # same exception.type/message/stacktrace *attributes* as the command + # span, not just the exception *event* that record_exception alone + # attaches. + self.assertTrue(any(event.name == "exception" for event in op_span.events)) + self.assertIn("exception.type", op_span.attributes) + self.assertIn("exception.message", op_span.attributes) + self.assertIn("exception.stacktrace", op_span.attributes) def test_span_created_for_get_more(self): client = self.rs_or_single_client(tracing={"enabled": True}) @@ -128,6 +515,136 @@ def test_span_created_for_get_more(self): self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore") self.assertEqual(span.attributes["db.command.name"], "getMore") + def test_find_getmores_nest_under_one_operation_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.getmore_nesting + coll.drop() + coll.insert_many([{"i": i} for i in range(10)]) + self.exporter.clear() + + docs = coll.find({}, batch_size=2).to_list() + self.assertEqual(len(docs), 10) + + finished = self.exporter.get_finished_spans() + # Exactly one operation span for the whole cursor. + find_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "find" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(find_op_spans), 1, [s.name for s in finished]) + op_span = find_op_spans[0] + self.assertEqual(op_span.name, "find pymongo_test.getmore_nesting") + + # No getMore *operation* spans at all. + getmore_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "getMore" + and "db.command.name" not in s.attributes + ] + self.assertEqual(getmore_op_spans, []) + + # Every getMore *command* span is a child of that one operation span. + getmore_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "getMore" + ] + self.assertGreater(len(getmore_cmd_spans), 1) + for cmd_span in getmore_cmd_spans: + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + + def test_aggregate_getmores_nest_under_one_operation_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.agg_nesting + coll.drop() + coll.insert_many([{"i": i} for i in range(10)]) + self.exporter.clear() + + docs = (coll.aggregate([{"$match": {}}], batchSize=2)).to_list() + self.assertEqual(len(docs), 10) + + finished = self.exporter.get_finished_spans() + agg_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "aggregate" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(agg_op_spans), 1, [s.name for s in finished]) + op_span = agg_op_spans[0] + + getmore_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "getMore" + and "db.command.name" not in s.attributes + ] + self.assertEqual(getmore_op_spans, []) + + getmore_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "getMore" + ] + self.assertGreater(len(getmore_cmd_spans), 1) + for cmd_span in getmore_cmd_spans: + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + + def test_single_batch_aggregate_ends_span_promptly_not_at_gc(self): + # A command cursor whose first batch exhausts it is marked _killed in + # __init__ without ever calling close(); no getMore is sent, so + # _refresh()/_die_lock() never run. Without explicit attachment its + # operation span would only be ended by __del__, i.e. whenever GC + # happens to run (or never, if the cursor is retained; Important #2). + # Assert the span is already ended while a reference to the + # cursor is still held, proving it ended at construction rather than + # waiting on GC. + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.agg_single_batch + coll.drop() + coll.insert_many([{"i": i} for i in range(3)]) + self.exporter.clear() + + cursor = coll.aggregate([{"$match": {}}]) + # Confirm this test actually exercises the single-batch path. + self.assertTrue(cursor._killed) + + finished = self.exporter.get_finished_spans() + agg_op_spans = [ + s + for s in finished + if s.attributes.get("db.operation.name") == "aggregate" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(agg_op_spans), 1, [s.name for s in finished]) + self.assertIsNotNone(agg_op_spans[0].end_time) + + # The cursor reference is kept alive through this assertion: if + # __del__ were the only thing ending the span, get_finished_spans() + # above would not have included it yet. + self.assertIsNotNone(cursor) + + def test_abandoned_cursor_still_ends_operation_span(self): + import gc + + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.getmore_abandoned + coll.drop() + coll.insert_many([{"i": i} for i in range(10)]) + self.exporter.clear() + + cursor = coll.find({}, batch_size=2) + cursor.next() # Leaves the cursor open with batches pending. + del cursor + gc.collect() + + find_op_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "find" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(find_op_spans), 1) + 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. @@ -171,8 +688,26 @@ def test_sensitive_command_produces_no_span(self): 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) + # The inner command span must stay fully suppressed for sensitive commands. + command_span_names = [s.name for s in self.spans() if "db.command.name" in s.attributes] + self.assertNotIn("saslStart", command_span_names) + + # The sensitive command name must never leak onto the wrapping + # *operation* span either: Database.command() always runs with + # is_run_command=True, so the operation span is named/attributed + # "runCommand" regardless of the actual (sensitive) command sent; + # the bare "saslStart" name never appears anywhere. The operation + # span must still carry its Required db.namespace/db.operation.summary + # attributes (backfilled before start_command_span's sensitive-command + # early return), even though the command itself produced no span. + finished = self.exporter.get_finished_spans() + operation_names = [s.attributes.get("db.operation.name") for s in finished] + self.assertNotIn("saslStart", operation_names) + self.assertIn("runCommand", operation_names) + op_span = next(s for s in finished if s.attributes.get("db.operation.name") == "runCommand") + self.assertEqual(op_span.name, "runCommand admin") + self.assertEqual(op_span.attributes["db.namespace"], "admin") + self.assertEqual(op_span.attributes["db.operation.summary"], "runCommand admin") def test_admin_command_omits_collection_name(self): # usersInfo's command value is a username string, not a collection, and @@ -188,13 +723,39 @@ def test_admin_command_omits_collection_name(self): self.assertNotIn("db.collection.name", attrs) self.assertEqual(attrs["db.query.summary"], "usersInfo admin") + def test_database_command_produces_run_command_operation_span(self): + # The OTel driver spec names "runCommand" as the driver-operation name + # for any operation reached through the generic Database.command() + # API, so c.admin.command("ping") must produce an operation span named + # "runCommand admin" with db.operation.name="runCommand", not one + # named "ping"/"ping admin". + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + client.admin.command("ping") + + finished = self.exporter.get_finished_spans() + matching = [s for s in finished if s.attributes.get("db.operation.name") == "runCommand"] + self.assertEqual(len(matching), 1) + op_span = matching[0] + self.assertEqual(op_span.name, "runCommand admin") + self.assertEqual(op_span.attributes["db.namespace"], "admin") + # The wire-level command span is unaffected: it's still named/attributed + # after the actual command sent. + cmd_spans = [s for s in finished if s.attributes.get("db.command.name") == "ping"] + self.assertEqual(len(cmd_spans), 1) + self.assertEqual(cmd_spans[0].name, "ping") + 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() + # Operation spans wrap command spans, so this also produces an + # ERROR-status operation span alongside the command span; narrow to + # the command span specifically (it alone carries + # db.response.status_code) rather than asserting there's only one span. + spans = [s for s in self.spans() if "db.response.status_code" in s.attributes] self.assertEqual(len(spans), 1) span = spans[0] self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) @@ -207,29 +768,45 @@ def test_tracing_disabled_by_default(self): client.admin.command("ping") self.assertEqual(self.spans(), []) - # TODO(PYTHON-5947): once operation spans exist, also assert that the - # "ping" *operation* span (not just the command span) is absent/present - # here, and that self.spans() counts both. def test_prose_1_tracing_enable_disable_via_env_var(self): """Prose Test 1: Tracing Enable/Disable via Environment Variable.""" with patch.dict(os.environ, {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "false"}): client = self.rs_or_single_client() self.exporter.clear() client.admin.command("ping") + # Disabled must suppress both the operation span and the command span + # it wraps: db.command() routes through _retry_internal same as any + # CRUD call, so both would exist if tracing weren't fully off. 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()]) + finished = self.exporter.get_finished_spans() + # Disambiguate the command span (db.command.name) from the operation + # span (db.operation.name) that wraps it: start_command_span renames + # the operation span in place once the command runs, so span.name + # alone can't tell them apart, but these attributes can. The operation + # span reads "runCommand" (not "ping"): Database.command() always runs + # with is_run_command=True, per the OTel spec's runCommand naming rule. + self.assertIn("ping", [s.attributes.get("db.command.name") for s in finished]) + self.assertIn("runCommand", [s.attributes.get("db.operation.name") for s in finished]) - # 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.""" + + def command_spans(): + # self.spans("find") would also match the outer find *operation* + # span (renamed to a "find ." summary once the command + # runs, not literally "find"); filter on db.command.name instead + # to isolate the inner command span that carries db.query.text. + return [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.command.name") == "find" + ] + env = { "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED": "true", "OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024", @@ -238,7 +815,7 @@ def test_prose_2_command_payload_emission_via_env_var(self): client = self.rs_or_single_client() self.exporter.clear() client[self.db.name].test_otel.find({}).to_list() - spans = self.spans("find") + spans = command_spans() self.assertEqual(len(spans), 1) self.assertIn("db.query.text", spans[0].attributes) @@ -246,25 +823,10 @@ def test_prose_2_command_payload_emission_via_env_var(self): client = self.rs_or_single_client() self.exporter.clear() client[self.db.name].test_otel.find({}).to_list() - spans = self.spans("find") + spans = command_spans() 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. @@ -294,10 +856,590 @@ def test_query_text_truncation_shrinks_oversized_field_values(self): self.assertLessEqual(len(query_text), 200) self.assertNotIn("a" * 500, query_text) + @client_context.require_transactions + def test_transaction_span_parents_operation_and_command_spans(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test + coll.drop() + client[self.db.name].create_collection("test") + self.exporter.clear() + + with client.start_session() as session: + with session.start_transaction(): + coll.insert_one({"x": 2}, session=session) + coll.insert_one({"x": 3}, session=session) + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertEqual(dict(txn_span.attributes), {"db.system.name": "mongodb"}) + + insert_op_spans = [s for s in finished if s.attributes.get("db.operation.name") == "insert"] + self.assertEqual(len(insert_op_spans), 2) + for op_span in insert_op_spans: + self.assertEqual(op_span.parent.span_id, txn_span.context.span_id) + + commit_op_spans = [ + s for s in finished if s.attributes.get("db.operation.name") == "commitTransaction" + ] + self.assertEqual(len(commit_op_spans), 1) + self.assertEqual(commit_op_spans[0].parent.span_id, txn_span.context.span_id) + + @client_context.require_transactions + def test_aborted_transaction_still_ends_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test + coll.drop() + client[self.db.name].create_collection("test") + self.exporter.clear() + + with client.start_session() as session: + with session.start_transaction(): + coll.insert_one({"x": 4}, session=session) + session.abort_transaction() + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertTrue(txn_span.end_time is not None) + + @client_context.require_transactions + def test_committing_empty_transaction_ends_span(self): + # No operation is ever run against the server, so commit_transaction + # takes the STARTING/COMMITTED_EMPTY early-return path rather than + # actually sending a commitTransaction command. + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + + with client.start_session() as session: + session.start_transaction() + session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertTrue(txn_span.end_time is not None) + + @client_context.require_transactions + def test_aborting_empty_transaction_ends_span(self): + # No operation is ever run against the server, so abort_transaction + # takes the STARTING early-return path rather than actually sending + # an abortTransaction command. + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + + with client.start_session() as session: + session.start_transaction() + session.abort_transaction() + + finished = self.exporter.get_finished_spans() + txn_span = next(s for s in finished if s.name == "transaction") + self.assertTrue(txn_span.end_time is not None) + + @client_context.require_transactions + def test_direct_commit_retry_gives_each_span_its_own_end(self): + # Explicitly retrying a successful commit moves the transaction state + # COMMITTED -> IN_PROGRESS -> (back through the try/finally) -> + # COMMITTED again. The prior attempt's span was already ended and + # cleared, so the retry gets a fresh "transaction" span of its own + # (this is the direct-API path, not with_transaction; see + # test_with_transaction_retry_reuses_one_transaction_span for the + # with_transaction case, which shares a single span across retries + # instead); each span's ending finally block must run exactly once + # for its own span, never double-ending the same span and never + # leaving one unended. + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client[self.db.name].test + coll.drop() + client[self.db.name].create_collection("test") + self.exporter.clear() + + with client.start_session() as session: + with session.start_transaction(): + coll.insert_one({"x": 5}, session=session) + # The transaction context manager already committed on clean + # exit; retry the commit explicitly. + session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 2) + self.assertNotEqual(txn_spans[0].context.span_id, txn_spans[1].context.span_id) + for txn_span in txn_spans: + self.assertTrue(txn_span.end_time is not None) + + @client_context.require_transactions + def test_with_transaction_retry_reuses_one_transaction_span(self): + # A retried with_transaction() call must still produce exactly one + # "transaction" span for the whole logical call, not one sibling + # span per full-transaction retry, and no separately-named wrapper + # span either (the vendored transaction/convenient.json fixture + # pins "transaction" itself as the trace root for withTransaction). + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.with_txn_spans + coll.drop() + client.pymongo_test.create_collection("with_txn_spans") + + attempts = [] + + def callback(session): + attempts.append(1) + coll.insert_one({"n": len(attempts)}, session=session) + if len(attempts) == 1: + exc = OperationFailure("transient", 251) + exc._add_error_label("TransientTransactionError") + raise exc + + self.exporter.clear() + with client.start_session() as session: + session.with_transaction(callback) + + self.assertEqual(len(attempts), 2) + finished = self.exporter.get_finished_spans() + self.assertFalse( + [s.name for s in finished if s.name.startswith("withTransaction")], + [s.name for s in finished], + ) + + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + self.assertTrue(txn_spans[0].end_time is not None) + + insert_op_spans = [s for s in finished if s.attributes.get("db.operation.name") == "insert"] + self.assertEqual(len(insert_op_spans), 2) + for op_span in insert_op_spans: + self.assertEqual(op_span.parent.span_id, txn_spans[0].context.span_id) + + @client_context.require_transactions + def test_reentrant_with_transaction_raises_and_does_not_leak_span(self): + # A callback that illegally re-enters with_transaction() on the same + # session must be rejected with a clear InvalidOperation, and the + # outer call's "transaction" span must still end exactly once, + # never leaked (created but never ended) and never double-ended. + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.reentrant_with_txn + coll.drop() + client.pymongo_test.create_collection("reentrant_with_txn") + + def inner_callback(session): + coll.insert_one({"x": 1}, session=session) + + def outer_callback(session): + coll.insert_one({"x": 2}, session=session) + # Illegal: with_transaction() is not reentrant on one session. + session.with_transaction(inner_callback) + + self.exporter.clear() + with client.start_session() as session: + with self.assertRaises(InvalidOperation): + session.with_transaction(outer_callback) + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + # Only the outer call ever gets far enough to create a span; the + # guard rejects the inner call before it creates one of its own. + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + for txn_span in txn_spans: + self.assertIsNotNone(txn_span.end_time) + + @client_context.require_transactions + def test_with_transaction_while_direct_api_transaction_active_does_not_corrupt_span( + self, + ): + # Calling with_transaction() while a transaction started with the + # DIRECT API is already active on the same session is illegal: + # start_transaction() inside with_transaction() raises "Transaction + # already in progress", but the direct-API transaction's own + # "transaction" span must survive that failure: with_transaction()'s + # finally must not end/null it out from under the still-active + # transaction (Important #1). Operations run on the session + # afterwards must still parent to that span rather than becoming + # trace roots, and the failed call must not leave behind a second, + # spurious "transaction" span of its own. + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.direct_api_with_txn_conflict + coll.drop() + client.pymongo_test.create_collection("direct_api_with_txn_conflict") + + def callback(session): + raise AssertionError("never reached; start_transaction() raises first") + + self.exporter.clear() + with client.start_session() as session: + session.start_transaction() + coll.insert_one({"x": 1}, session=session) + + with self.assertRaises(InvalidOperation): + session.with_transaction(callback) + + # The original transaction is still active; this must still + # nest under its span, not become a trace root. + coll.insert_one({"x": 2}, session=session) + session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + txn_span = txn_spans[0] + self.assertIsNotNone(txn_span.end_time) + + insert_op_spans = [s for s in finished if s.attributes.get("db.operation.name") == "insert"] + self.assertEqual(len(insert_op_spans), 2) + for op_span in insert_op_spans: + self.assertEqual(op_span.parent.span_id, txn_span.context.span_id) + + @client_context.require_transactions + def test_retried_commit_has_a_transaction_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.retried_commit_spans + coll.drop() + client.pymongo_test.create_collection("retried_commit_spans") + + with client.start_session() as session: + session.start_transaction() + coll.insert_one({"x": 1}, session=session) + session.commit_transaction() + self.exporter.clear() + # An explicit second commit re-enters the COMMITTED -> IN_PROGRESS + # branch, which previously ran with no transaction span at all. + session.commit_transaction() + + finished = self.exporter.get_finished_spans() + txn_spans = [s for s in finished if s.name == "transaction"] + self.assertEqual(len(txn_spans), 1, [s.name for s in finished]) + commit_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "commitTransaction" + ] + self.assertGreaterEqual(len(commit_cmd_spans), 1) + for cmd_span in commit_cmd_spans: + self.assertIsNotNone(cmd_span.parent) + + @client_context.require_version_min(8, 0, 0, -24) + def test_bulk_write_acknowledged_gets_operation_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + client.bulk_write([InsertOne(namespace=f"{self.db.name}.test", document={"x": 1})]) + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "bulkWrite" + ] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0].attributes["db.namespace"], "admin") + self.assertNotIn("db.collection.name", matching[0].attributes) + + @client_context.require_version_min(8, 0, 0, -24) + def test_bulk_write_unacknowledged_gets_operation_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}, w=0) + self.exporter.clear() + client.bulk_write( + [InsertOne(namespace=f"{self.db.name}.test", document={"x": 1})], + ordered=False, + ) + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "bulkWrite" + ] + self.assertEqual(len(matching), 1) + self.assertEqual(matching[0].attributes["db.namespace"], "admin") + + @client_context.require_version_min(8, 0) + def test_client_bulk_write_results_cursor_getmores_nest_under_bulk_write(self): + # A successful InsertOne's verbose result doc is tiny (~{"ok": 1, "idx": + # i, "n": 1}) regardless of the inserted document's size, and the driver + # never sends more than maxWriteBatchSize (100_000 by default) ops in one + # bulkWrite command, so plain successful inserts can never make the + # results cursor's first batch exceed the 16MB per-batch limit, no + # matter how many operations are given. Duplicate-key write errors, + # whose result docs embed the offending key (here padded to 3000 bytes), + # blow past that limit at a much smaller, fast-running operation count + # while still exercising the exact same code path (a real + # CommandCursor built and iterated by _process_results_cursor). + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.bulk_results_cursor + coll.drop() + coll.create_index("dup", unique=True) + dup_value = "d" * 3000 + models = [ + InsertOne(namespace=coll.full_name, document={"dup": dup_value}) for _ in range(10000) + ] + self.exporter.clear() + with self.assertRaises(ClientBulkWriteException): + client.bulk_write(models, verbose_results=True, ordered=False) + + finished = self.exporter.get_finished_spans() + # Exactly one operation span, for the bulkWrite itself. + op_spans = [ + s + for s in finished + if "db.command.name" not in s.attributes + and s.attributes.get("db.operation.name") is not None + ] + self.assertEqual( + [s.attributes["db.operation.name"] for s in op_spans], + ["bulkWrite"], + [s.name for s in finished], + ) + (op_span,) = op_spans + + # Any getMore command spans parent directly to the bulkWrite span. + getmore_cmd_spans = [ + s for s in finished if s.attributes.get("db.command.name") == "getMore" + ] + self.assertGreater(len(getmore_cmd_spans), 0, "expected a multi-batch results cursor") + for cmd_span in getmore_cmd_spans: + self.assertEqual(cmd_span.parent.span_id, op_span.context.span_id) + + def test_operation_span_falls_back_to_bare_name_when_no_command_is_sent(self): + # An operation that fails during server selection never builds a + # command, so the lazy backfill in start_command_span never runs. + # insert_one doesn't go through a cursor (unlike find), so nothing + # eagerly threads dbname/collection to the operation span either: + # db.operation.summary (Required, per the OTel spec) still falls back + # to the bare operation name, but db.namespace/db.collection.name + # (only "Required if available") are simply absent. + client = self.rs_or_single_client( + "mongodb://localhost:1/", + tracing={"enabled": True}, + serverSelectionTimeoutMS=10, + connect=False, + ) + self.exporter.clear() + with self.assertRaises(ServerSelectionTimeoutError): + client.mydb.mycoll.insert_one({}) + (span,) = [s for s in self.exporter.get_finished_spans() if s.name == "insert"] + self.assertEqual(span.attributes["db.operation.name"], "insert") + self.assertEqual(span.attributes["db.operation.summary"], "insert") + self.assertNotIn("db.namespace", span.attributes) + self.assertNotIn("db.collection.name", span.attributes) + self.assertEqual(span.status.status_code, StatusCode.ERROR) + + def test_caller_owned_operation_telemetry_is_not_ended_by_retry_internal(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + telemetry = _OperationTelemetry( + client.options.tracing, + "find", + None, + dbname="mydb", + collection="c", + set_current=False, + ) + self.exporter.clear() + + def _noop_read(_session, _server, _conn, _read_pref): + return "ok" + + result = client._retryable_read( + _noop_read, + ReadPreference.PRIMARY, + None, + operation="find", + operation_telemetry=telemetry, + ) + self.assertEqual(result, "ok") + # _retry_internal must not have ended the caller's span. + self.assertEqual( + [s for s in self.exporter.get_finished_spans() if s.name.startswith("find")], [] + ) + telemetry.succeeded() + self.assertEqual( + len([s for s in self.exporter.get_finished_spans() if s.name.startswith("find")]), + 1, + ) + + def test_eager_namespace_for_collection_and_database_operations(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + db = client.pymongo_test + cases = [ + # (coroutine factory, expected span name) + (lambda: db.mycoll.insert_one({"x": 1}), "insert pymongo_test.mycoll"), + (lambda: db.mycoll.find_one({}), "find pymongo_test.mycoll"), + (lambda: db.mycoll.count_documents({}), "count pymongo_test.mycoll"), + (lambda: db.list_collection_names(), "listCollections pymongo_test"), + ] + for factory, expected_name in cases: + with self.subTest(expected_name=expected_name): + self.exporter.clear() + factory() + names = [s.name for s in self.exporter.get_finished_spans()] + self.assertIn(expected_name, names) + (span,) = [s for s in self.exporter.get_finished_spans() if s.name == expected_name] + self.assertEqual(span.attributes["db.operation.summary"], expected_name) + self.assertEqual(span.attributes["db.namespace"], "pymongo_test") + + def _aggregate_operation_span(self): + matching = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "aggregate" + ] + self.assertEqual(len(matching), 1) + return matching[0] + + @client_context.require_version_min(4, 2, 0) + @client_context.require_change_streams + def test_change_stream_collection_level_operation_span_has_full_namespace(self): + # A collection-level change stream's operation span carries both the + # database and the collection, derived from the aggregate command by + # _otel's lazy backfill. The database- and cluster-level cases below + # must omit db.collection.name, since neither targets one collection. + client = self.rs_or_single_client(tracing={"enabled": True}) + db = client.pymongo_test + coll = db.test_otel_change_stream_coll + coll.drop() + self.exporter.clear() + with coll.watch(): + pass + span = self._aggregate_operation_span() + self.assertEqual(span.attributes["db.namespace"], "pymongo_test") + self.assertEqual(span.attributes["db.collection.name"], "test_otel_change_stream_coll") + + @client_context.require_version_min(4, 2, 0) + @client_context.require_change_streams + def test_change_stream_database_level_operation_span_omits_collection_name(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + db = client.pymongo_test + self.exporter.clear() + with db.watch(): + pass + span = self._aggregate_operation_span() + self.assertEqual(span.attributes["db.namespace"], "pymongo_test") + self.assertNotIn("db.collection.name", span.attributes) + + @client_context.require_version_min(4, 2, 0) + @client_context.require_change_streams + def test_change_stream_cluster_level_operation_span_targets_admin(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with client.watch(): + pass + span = self._aggregate_operation_span() + self.assertEqual(span.attributes["db.namespace"], "admin") + self.assertNotIn("db.collection.name", span.attributes) + + def test_kill_cursors_gets_operation_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + coll = client.pymongo_test.kill_cursors_span + coll.drop() + coll.insert_many([{"i": i} for i in range(10)]) + cursor = coll.find({}, batch_size=2) + cursor.next() + self.exporter.clear() + cursor.close() # Sends killCursors, since batches remain. + + op_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "killCursors" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(op_spans), 1, [s.name for s in self.exporter.get_finished_spans()]) + (op_span,) = op_spans + self.assertEqual(op_span.name, "killCursors pymongo_test.kill_cursors_span") + self.assertEqual(op_span.attributes["db.namespace"], "pymongo_test") + self.assertEqual(op_span.attributes["db.collection.name"], "kill_cursors_span") + + cmd_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.command.name") == "killCursors" + ] + self.assertEqual(len(cmd_spans), 1) + self.assertEqual(cmd_spans[0].parent.span_id, op_span.context.span_id) + + def test_background_kill_cursors_span_is_a_trace_root(self): + # Regression test for PYTHON-5947: asyncio.create_task freezes the + # calling coroutine's contextvars.Context, and the kill-cursors + # executor is opened lazily from inside the client's first traced + # operation (_get_topology -> executor.open() -> create_task, reached + # while that operation's _OperationTelemetry has already made its + # span ambient-current). Without resetting the OTel context inside + # PeriodicExecutor._run, every killCursors span the background + # tick emits for the rest of the process's life gets parented under + # that first, long-since-ended operation and shares its trace id. + # + # Calling client._process_kill_cursors() directly from this test coroutine would NOT + # reproduce the bug: this coroutine's own context is clean, so the span would come out + # parentless even with the bug present. Instead we drive the *existing* kill-cursors + # executor task (the one whose context was frozen inside find_one() below) using + # wake()/skip_sleep(), so the tick actually runs inside that frozen context, and poll + # (wait_until) for the resulting span rather than sleeping a fixed amount. + import gc + + # connect=False is essential here: the test helper's default + # connect=True calls client._connect() -> _get_topology() right after + # construction, *before* any traced operation runs and thus with no + # span current, which would open (and freeze) the kill-cursors + # executor with a clean context and make this test pass regardless of + # the bug. With connect=False, _get_topology() (and therefore the + # executor's create_task) is only reached lazily, from inside the + # find_one() call below, while that operation's span is current. + client = self.rs_or_single_client(tracing={"enabled": True}, connect=False) + coll = client.pymongo_test.bg_kill_cursors + coll.drop() + coll.insert_many([{"i": i} for i in range(10)]) + + # This first traced operation is what opens the kill-cursors executor, + # freezing its context while this operation's span is current. + coll.find_one({}) + + cursor = coll.find({}, batch_size=2) + cursor.next() + del cursor + gc.collect() # Queues a deferred killCursors. + + self.exporter.clear() + + def _kill_op_spans(): + return [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "killCursors" + and "db.command.name" not in s.attributes + ] + + executor = client._kill_cursors_executor + executor.skip_sleep() + executor.wake() + wait_until(_kill_op_spans, "background killCursors span emitted") + + kill_spans = _kill_op_spans() + self.assertEqual(len(kill_spans), 1, [s.name for s in self.exporter.get_finished_spans()]) + # The background tick must not inherit a parent from whatever span + # happened to be current when the executor task was created. + self.assertIsNone(kill_spans[0].parent) + + def test_end_sessions_gets_operation_span(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + client.pymongo_test.end_sessions_span.find_one({}) # Uses an implicit session. + self.exporter.clear() + client.close() # Sends endSessions. + + op_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.operation.name") == "endSessions" + and "db.command.name" not in s.attributes + ] + self.assertEqual(len(op_spans), 1, [s.name for s in self.exporter.get_finished_spans()]) + (op_span,) = op_spans + self.assertEqual(op_span.name, "endSessions admin") + self.assertEqual(op_span.attributes["db.namespace"], "admin") + self.assertNotIn("db.collection.name", op_span.attributes) + + cmd_spans = [ + s + for s in self.exporter.get_finished_spans() + if s.attributes.get("db.command.name") == "endSessions" + ] + self.assertEqual(len(cmd_spans), 1) + self.assertEqual(cmd_spans[0].parent.span_id, op_span.context.span_id) + -# 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. +# The unified test format's expectTracingMessages/observeTracingMessages +# tests (test_open_telemetry_unified.py) now exercise this validator +# indirectly through real client construction, but these direct unit tests +# are kept for the validator's edge cases (rejection paths, the explicit-zero +# vs. unset distinction for query_text_max_length) that aren't necessarily +# covered by the vendored fixtures. class TestValidateTracingOrNone(unittest.TestCase): def test_none(self): self.assertIsNone(common.validate_tracing_or_none("tracing", None)) diff --git a/test/unified_format.py b/test/unified_format.py index 4892a91e52..469ffae5d4 100644 --- a/test/unified_format.py +++ b/test/unified_format.py @@ -29,6 +29,7 @@ import sys import time import traceback +import uuid from collections import defaultdict from collections.abc import Mapping from inspect import iscoroutinefunction @@ -38,8 +39,11 @@ import pytest import pymongo +import pymongo._otel as _otel from bson import SON, json_util +from bson.binary import Binary from bson.codec_options import DEFAULT_CODEC_OPTIONS +from bson.int64 import Int64 from bson.objectid import ObjectId from gridfs import GridFSBucket, GridOut, NoFile from gridfs.errors import CorruptGridFile @@ -91,6 +95,7 @@ PLACEHOLDER_MAP, EventListenerUtil, MatchEvaluatorUtil, + _shared_test_provider, coerce_result, parse_bulk_write_error_result, parse_bulk_write_result, @@ -112,6 +117,16 @@ _IS_SYNC = True +_HAS_OTEL_TEST_DEPS = False +if _otel._HAS_OPENTELEMETRY: + try: + 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_INTERRUPTED = False @@ -229,6 +244,11 @@ def __init__(self, test_class): self._entities: dict[str, Any] = {} self._listeners: dict[str, EventListenerUtil] = {} self._session_lsids: dict[str, Mapping[str, Any]] = {} + # The id of the (at most one, today) client entity created with + # observeTracingMessages. Spans carry no attribute identifying which + # client emitted them, so multi-client tracing correlation isn't + # supported; _create_entity fails loudly if a second one appears. + self._tracing_client_id: Optional[str] = None self.test: UnifiedSpecTestMixinV1 = test_class def __contains__(self, item): @@ -310,6 +330,25 @@ def _create_entity(self, entity_spec, uri=None): ) self._listeners[spec["id"]] = listener kwargs["event_listeners"] = [listener] + + observe_tracing = spec.get("observeTracingMessages") + if observe_tracing is not None: + if self._tracing_client_id is not None: + self.test.fail( + "Multiple clients with observeTracingMessages are not supported " + f"by the unified test format runner (already tracking " + f"{self._tracing_client_id!r}, got {spec['id']!r})" + ) + self._tracing_client_id = spec["id"] + enable_payload = observe_tracing.get("enableCommandPayload", False) + kwargs["tracing"] = { + "enabled": True, + # Tests asserting db.query.text match the full, untruncated + # command, so an effectively-unlimited length avoids + # truncating and failing that assertion. + "query_text_max_length": 1_000_000 if enable_payload else None, + } + if spec.get("useMultipleMongoses"): if client_context.load_balancer: kwargs["h"] = client_context.MULTI_MONGOS_LB_URI @@ -466,6 +505,36 @@ def advance_cluster_times(self, cluster_time) -> None: entity.advance_cluster_time(cluster_time) +def _normalize_span_attribute_for_match(key: str, value: Any) -> Any: + """Adapt an OTel span attribute value to what the generic unified-format + match evaluator expects, since span attributes are plain Python + primitives rather than the BSON-decoded documents/events the evaluator + normally matches against. + + - Widen plain Python ints (excluding bools) to ``bson.Int64``: span + attributes carry no int32/int64 distinction, but the $$type matcher's + "long" alias maps to ``Int64`` specifically (see BSON_TYPE_ALIAS_MAP in + unified_format_shared.py), so a bare ``int`` (e.g. ``server.port``) + would otherwise fail a ``$$type: ["long", "string"]`` check. ``Int64`` + is a subclass of ``int``, so this is safe for "int" checks too. + - Reconstruct ``db.mongodb.lsid`` (formatted by pymongo/_otel.py as a + plain UUID string, per the OTel spec's attribute table) back into the + ``{"id": Binary(...)}`` document shape the ``$$sessionLsid`` operator + (designed for command-monitoring-style raw command documents) compares + against. + """ + if key == "db.mongodb.lsid" and isinstance(value, str): + try: + return {"id": Binary.from_uuid(uuid.UUID(value))} + except ValueError: + return value + if isinstance(value, bool): + return value + if isinstance(value, int): + return Int64(value) + return value + + class UnifiedSpecTestMixinV1(IntegrationTest): """Mixin class to run test cases from test specification files. @@ -481,6 +550,8 @@ class UnifiedSpecTestMixinV1(IntegrationTest): TEST_SPEC: Any TEST_PATH = "" # This gets filled in by generate_test_classes mongos_clients: list[MongoClient] = [] + # Set in setUpClass, only for test files that use observeTracingMessages. + _tracing_exporter: Optional[Any] = None @staticmethod def should_run_on(run_on_spec): @@ -525,6 +596,23 @@ def insert_initial_data(self, initial_data): @classmethod def setUpClass(cls) -> None: + # Only register a span exporter (and the shared SDK TracerProvider it + # depends on) for test files that actually use observeTracingMessages, + # to avoid needlessly accumulating span processors on the process-wide + # provider for the (vast majority of) unified-format suites that don't. + cls._tracing_exporter = None + uses_tracing = any( + "observeTracingMessages" in entity.get("client", {}) + for entity in cls.TEST_SPEC.get("createEntities", []) + ) + if uses_tracing: + if not _HAS_OTEL_TEST_DEPS: + raise unittest.SkipTest( + "observeTracingMessages requires opentelemetry-sdk to be installed" + ) + cls._tracing_exporter = InMemorySpanExporter() + _shared_test_provider().add_span_processor(SimpleSpanProcessor(cls._tracing_exporter)) + # Speed up the tests by decreasing the heartbeat frequency. cls.knobs = client_knobs( heartbeat_frequency=0.1, @@ -537,6 +625,14 @@ def setUpClass(cls) -> None: @classmethod def tearDownClass(cls) -> None: cls.knobs.disable() + # The exporter's span processor can never be removed from the shared process-wide + # TracerProvider (see _shared_test_provider), so without this, every span emitted by any + # client anywhere in the process for the rest of the test run keeps getting appended to this + # (otherwise dead) class's exporter: an unbounded memory leak across a full test run, and + # needless per-span export overhead for every other tracing-enabled test class that runs + # afterwards. shutdown() makes further export() calls into this exporter no-ops. + if cls._tracing_exporter is not None: + cls._tracing_exporter.shutdown() def setUp(self): # super call creates internal client cls.client @@ -575,6 +671,26 @@ def maybe_skip_test(self, spec): self.skipTest("PyMongo does not support the symbol type") if "timeoutms applied to entire download" in description: self.skipTest("PyMongo's open_download_stream does not cap the stream's lifetime") + # Removed API: PyMongo no longer exposes map_reduce/inline_map_reduce at + # all (mapReduce is deprecated server-side), so there's no code path left + # that could send this command; this operation can never be exercised. + if class_name == "testoperationmapreduce" and description == "mapreduce": + self.skipTest( + "PyMongo removed the map_reduce/inline_map_reduce Collection methods " + "(mapReduce is deprecated server-side); this operation cannot be exercised" + ) + # Not a removed-API gap like the above: this is a genuine fixture-vs-driver + # divergence. PyMongo *can* run this update, it just always sends explicit + # multi/upsert fields (even at their default False value), which the + # vendored fixture's db.query.text $$matchAsRoot assertion doesn't allow for. + if class_name == "testoperationupdate" and description == "update one element": + self.skipTest( + "PyMongo always sends explicit multi/upsert fields in the update " + "statement (even at their default False value), but this vendored " + "fixture's db.query.text $$matchAsRoot expects an update statement " + "with only q/u: a real, narrow mismatch between this driver's wire " + "command shape and the fixture's assumption, not a tracing bug" + ) if any( x in description for x in [ @@ -1450,6 +1566,79 @@ def format_logs(log_list): self.match_evaluator.match_result(expected_data, actual_data) self.match_evaluator.match_result(expected_msg, actual_msg) + def check_tracing_messages(self, operations, spec): + # Like expectLogMessages/expectEvents, expectTracingMessages is a list of + # per-client blocks (even though only one client with + # observeTracingMessages is currently supported, see entity.py above). + exporter = self._tracing_exporter + if exporter is None: + self.fail( + "expectTracingMessages requires a client entity created with observeTracingMessages" + ) + + exporter.clear() + self.run_operations(operations) + finished_spans = exporter.get_finished_spans() + + # Reconstruct the parent/child span tree from the flat, finish-ordered + # list the in-memory exporter records, keyed by each span's parent id. + children_by_parent_id = defaultdict(list) + for span in finished_spans: + parent_id = span.parent.span_id if span.parent is not None else None + children_by_parent_id[parent_id].append(span) + + def check_span_list(expected_list, actual_list, ignore_extra_spans): + if ignore_extra_spans: + # Per the unified-test-format spec, "additional unexpected spans + # are allowed". Unlike ignoreExtraEvents (which only tolerates + # a trailing tail), spans from concurrent/out-of-band activity + # (e.g. a testRunner-issued configureFailPoint command) can + # finish interleaved anywhere among the expected ones, not just + # at the end. Filter down to just the spans that line up (by + # name, in order) with the expected list, dropping anything + # else, instead of naively truncating the tail. + filtered = [] + expected_iter = iter(expected_list) + current_expected = next(expected_iter, None) + for actual in actual_list: + if current_expected is not None and actual.name == current_expected["name"]: + filtered.append(actual) + current_expected = next(expected_iter, None) + actual_list = filtered + self.assertEqual( + len(expected_list), + len(actual_list), + f"expected spans {[e['name'] for e in expected_list]} but got " + f"{[a.name for a in actual_list]}", + ) + for expected, actual in zip(expected_list, actual_list): + self.assertEqual(expected["name"], actual.name) + actual_attributes = { + k: _normalize_span_attribute_for_match(k, v) + for k, v in actual.attributes.items() + } + self.match_evaluator.match_result(expected["attributes"], actual_attributes) + expected_nested = expected.get("nested") + if expected_nested is not None: + actual_children = children_by_parent_id[actual.context.span_id] + check_span_list(expected_nested, actual_children, ignore_extra_spans) + + for client_spec in spec: + expected_client_id = client_spec["client"] + tracing_client_id = self.entity_map._tracing_client_id + self.assertEqual( + expected_client_id, + tracing_client_id, + f"expectTracingMessages.client {expected_client_id!r} does not match the " + f"client with observeTracingMessages enabled ({tracing_client_id!r})", + ) + + ignore_extra_spans = client_spec.get("ignoreExtraSpans", False) + expected_spans = client_spec["spans"] + self.assertTrue(expected_spans, "expectTracingMessages spans must be non-empty") + + check_span_list(expected_spans, children_by_parent_id[None], ignore_extra_spans) + def verify_outcome(self, spec): for collection_data in spec: coll_name = collection_data["collectionName"] @@ -1536,6 +1725,10 @@ def _run_scenario(self, spec, uri=None): expect_log_messages = spec["expectLogMessages"] self.assertTrue(expect_log_messages, "expectEvents must be non-empty") self.check_log_messages(spec["operations"], expect_log_messages) + elif "expectTracingMessages" in spec: + expect_tracing_messages = spec["expectTracingMessages"] + self.assertTrue(expect_tracing_messages, "expectTracingMessages must be non-empty") + self.check_tracing_messages(spec["operations"], expect_tracing_messages) else: # process operations self.run_operations(spec["operations"]) diff --git a/test/unified_format_shared.py b/test/unified_format_shared.py index 8a0a3cd46b..77eea4d753 100644 --- a/test/unified_format_shared.py +++ b/test/unified_format_shared.py @@ -29,6 +29,7 @@ from collections.abc import MutableMapping from typing import Any, Union +import pymongo._otel as _otel from bson import ( RE_TYPE, Binary, @@ -252,6 +253,29 @@ def parse_client_bulk_write_error_result(error): return parse_client_bulk_write_result(write_result) +if _otel._HAS_OPENTELEMETRY: + try: + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + except ImportError: + pass + + +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 + + class EventListenerUtil( CMAPListener, CommandListener, ServerListener, ServerHeartbeatListener, TopologyListener ):