6868 "_CURRENT_OPERATION_NAME" , default = None
6969)
7070
71+ # True while the driver is iterating a cursor of its own to build the return
72+ # value of one public API call (list_collection_names, index_information, ...).
73+ # Such a call gets a single operation span covering every getMore it sends,
74+ # whereas a cursor handed back to the caller gets a fresh operation span per
75+ # caller-driven getMore. See internal_cursor_iteration.
76+ _INTERNAL_CURSOR_ITERATION : ContextVar [bool ] = ContextVar (
77+ "_INTERNAL_CURSOR_ITERATION" , default = False
78+ )
79+
7180if TYPE_CHECKING :
7281 from opentelemetry .trace import Span , Tracer
7382
@@ -123,6 +132,28 @@ def _env_truthy(name: str) -> bool:
123132 return os .getenv (name , "" ).strip ().lower () in _TRUTHY
124133
125134
135+ @contextlib .contextmanager
136+ def internal_cursor_iteration () -> Iterator [None ]:
137+ """Mark the enclosing block as driver-internal cursor iteration.
138+
139+ Wrap the block in which a public API method creates a cursor and drains it
140+ itself to build its return value. Everything the block sends, including
141+ every getMore, then belongs to that method's one operation span, as the
142+ OTel spec requires. Outside such a block the cursor is assumed to reach the
143+ caller, whose iteration is a separate operation per getMore.
144+ """
145+ token = _INTERNAL_CURSOR_ITERATION .set (True )
146+ try :
147+ yield
148+ finally :
149+ _INTERNAL_CURSOR_ITERATION .reset (token )
150+
151+
152+ def is_internal_cursor_iteration () -> bool :
153+ """Return True inside an :func:`internal_cursor_iteration` block."""
154+ return _INTERNAL_CURSOR_ITERATION .get ()
155+
156+
126157def _is_tracing_enabled (tracing_options : Optional [TracingOptions ]) -> bool :
127158 """Return True if spans should be created for this client.
128159
@@ -369,6 +400,20 @@ def start_command_span(
369400 return _TRACER .start_span (command_name , kind = SpanKind .CLIENT , attributes = attributes )
370401
371402
403+ def _set_operation_cursor_id (cursor_id : int ) -> None :
404+ """Set db.mongodb.cursor_id on the ambient operation span, if there is one.
405+
406+ Guarded on the operation-name contextvar for the same reason
407+ ``start_command_span``'s backfill is: without it the "current span" could be
408+ an unrelated span belonging to the host application.
409+ """
410+ if _CURRENT_OPERATION_NAME .get () is None :
411+ return
412+ current_span = trace .get_current_span ()
413+ if current_span .is_recording ():
414+ current_span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
415+
416+
372417def end_command_span_success (span : Optional [Span ], reply : _DocumentOut ) -> None :
373418 """Set the cursor id (if any open cursor) and end the span."""
374419 if span is None :
@@ -378,8 +423,14 @@ def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None:
378423 # A cursor id of 0 means the cursor is already exhausted, i.e. there is
379424 # no cursor left to track, so per the OTel spec the attribute is
380425 # omitted, never set to 0, when a cursor-creating command's reply
381- # returns 0.
382- span .set_attribute ("db.mongodb.cursor_id" , cursor ["id" ])
426+ # returns 0. A getMore keeps the id it sent, set in start_command_span,
427+ # which this deliberately does not overwrite with a 0 reply id.
428+ cursor_id = cursor ["id" ]
429+ span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
430+ # The enclosing operation span carries the same attribute. For a
431+ # cursor-creating command that is this reply's id; for a getMore it is
432+ # the id already set from the sent value, which this repeats unchanged.
433+ _set_operation_cursor_id (cursor_id )
383434 span .end ()
384435
385436
@@ -451,6 +502,7 @@ def start_operation_span(
451502 dbname : Optional [str ] = None ,
452503 collection : Optional [str ] = None ,
453504 set_current : bool = True ,
505+ cursor_id : Optional [int ] = None ,
454506) -> Optional [_OperationSpanHandle ]:
455507 """Start a CLIENT-kind span for one logical operation, or None.
456508
@@ -469,6 +521,11 @@ def start_operation_span(
469521 avoid a concurrently-running unrelated session's operations picking up
470522 this transaction by accident. Pass None outside of a transaction.
471523
524+ ``cursor_id`` sets ``db.mongodb.cursor_id`` up front, for an operation
525+ reading a cursor that already exists: the id is known before the command is
526+ even built, and the operation span needs it even if the operation fails
527+ before any command span exists.
528+
472529 With ``set_current=False`` the span is created but not made current, and
473530 the operation-name contextvar is left alone. That suits a span created
474531 outside the ``_retry_internal`` call it covers, where the caller makes it
@@ -489,6 +546,8 @@ def start_operation_span(
489546 if collection :
490547 attributes ["db.collection.name" ] = collection
491548 attributes ["db.operation.summary" ] = name
549+ if cursor_id :
550+ attributes ["db.mongodb.cursor_id" ] = cursor_id
492551 if not set_current :
493552 span = _TRACER .start_span (
494553 name , kind = SpanKind .CLIENT , context = context , attributes = attributes
0 commit comments