6565 "_CURRENT_OPERATION_NAME" , default = None
6666)
6767
68+ # True while the driver is iterating a cursor of its own to build the return
69+ # value of one public API call (list_collection_names, index_information, ...).
70+ # Such a call gets a single operation span covering every getMore it sends,
71+ # whereas a cursor handed back to the caller gets a fresh operation span per
72+ # caller-driven getMore. See internal_cursor_iteration.
73+ _INTERNAL_CURSOR_ITERATION : ContextVar [bool ] = ContextVar (
74+ "_INTERNAL_CURSOR_ITERATION" , default = False
75+ )
76+
6877if TYPE_CHECKING :
6978 from opentelemetry .trace import Span , Tracer
7079
@@ -117,6 +126,28 @@ def _env_truthy(name: str) -> bool:
117126 return os .getenv (name , "" ).strip ().lower () in _TRUTHY
118127
119128
129+ @contextlib .contextmanager
130+ def internal_cursor_iteration () -> Iterator [None ]:
131+ """Mark the enclosing block as driver-internal cursor iteration.
132+
133+ Wrap the block in which a public API method creates a cursor and drains it
134+ itself to build its return value. Everything the block sends, including
135+ every getMore, then belongs to that method's one operation span, as the
136+ OTel spec requires. Outside such a block the cursor is assumed to reach the
137+ caller, whose iteration is a separate operation per getMore.
138+ """
139+ token = _INTERNAL_CURSOR_ITERATION .set (True )
140+ try :
141+ yield
142+ finally :
143+ _INTERNAL_CURSOR_ITERATION .reset (token )
144+
145+
146+ def is_internal_cursor_iteration () -> bool :
147+ """Return True inside an :func:`internal_cursor_iteration` block."""
148+ return _INTERNAL_CURSOR_ITERATION .get ()
149+
150+
120151def _is_tracing_enabled (tracing_options : Optional [TracingOptions ]) -> bool :
121152 """Return True if spans should be created for this client.
122153
@@ -300,6 +331,14 @@ def start_command_span(
300331 return None
301332
302333 collection = _extract_collection_name (command_name , dbname , cmd )
334+ # A getMore's own command value is the id of the cursor being read, which is
335+ # the value db.mongodb.cursor_id takes for a command operating on an
336+ # existing cursor: the id sent, not whatever the reply comes back with. It
337+ # has to be read here rather than from the reply because the reply is 0 once
338+ # the cursor is exhausted, and the attribute is required even then.
339+ sent_cursor_id = cmd .get (_GET_MORE ) if command_name == _GET_MORE else None
340+ if not isinstance (sent_cursor_id , int ):
341+ sent_cursor_id = None
303342 # Backfill the operation span's name/namespace/summary from the first command
304343 # built inside it. Before the sensitive-command return below, since the
305344 # operation span needs those attributes even when the command gets no span.
@@ -313,6 +352,8 @@ def start_command_span(
313352 current_span .set_attribute ("db.operation.summary" , summary )
314353 if collection :
315354 current_span .set_attribute ("db.collection.name" , collection )
355+ if sent_cursor_id :
356+ current_span .set_attribute ("db.mongodb.cursor_id" , sent_cursor_id )
316357
317358 if _is_sensitive_command (command_name , speculative_hello ):
318359 return None
@@ -334,6 +375,8 @@ def start_command_span(
334375 attributes ["db.collection.name" ] = collection
335376 if conn .server_connection_id is not None :
336377 attributes ["db.mongodb.server_connection_id" ] = conn .server_connection_id
378+ if sent_cursor_id :
379+ attributes ["db.mongodb.cursor_id" ] = sent_cursor_id
337380 lsid = cmd .get ("lsid" )
338381 if isinstance (lsid , Mapping ):
339382 formatted_lsid = _format_lsid (lsid )
@@ -350,15 +393,34 @@ def start_command_span(
350393 return _TRACER .start_span (command_name , kind = SpanKind .CLIENT , attributes = attributes )
351394
352395
396+ def _set_operation_cursor_id (cursor_id : int ) -> None :
397+ """Set db.mongodb.cursor_id on the ambient operation span, if there is one.
398+
399+ Guarded on the operation-name contextvar for the same reason
400+ ``start_command_span``'s backfill is: without it the "current span" could be
401+ an unrelated span belonging to the host application.
402+ """
403+ if _CURRENT_OPERATION_NAME .get () is None :
404+ return
405+ current_span = trace .get_current_span ()
406+ if current_span .is_recording ():
407+ current_span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
408+
409+
353410def end_command_span_success (span : Optional [Span ], reply : _DocumentOut ) -> None :
354411 """Set the cursor id (if any open cursor) and end the span."""
355412 if span is None :
356413 return
357414 cursor = reply .get ("cursor" )
358415 if isinstance (cursor , Mapping ) and cursor .get ("id" ):
359416 # Per the spec the attribute is omitted rather than set to 0, so a
360- # cursor-creating command that leaves no cursor open reports nothing.
361- span .set_attribute ("db.mongodb.cursor_id" , cursor ["id" ])
417+ # cursor-creating command that leaves no cursor open reports nothing. A
418+ # getMore keeps the id it sent, which this does not overwrite with a 0.
419+ cursor_id = cursor ["id" ]
420+ span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
421+ # The operation span carries the same attribute: this reply's id for a
422+ # cursor-creating command, or the already-set sent id for a getMore.
423+ _set_operation_cursor_id (cursor_id )
362424 span .end ()
363425
364426
@@ -430,6 +492,7 @@ def start_operation_span(
430492 dbname : Optional [str ] = None ,
431493 collection : Optional [str ] = None ,
432494 set_current : bool = True ,
495+ cursor_id : Optional [int ] = None ,
433496) -> Optional [_OperationSpanHandle ]:
434497 """Start a CLIENT-kind span for one logical operation, or None.
435498
@@ -448,6 +511,11 @@ def start_operation_span(
448511 avoid a concurrently-running unrelated session's operations picking up
449512 this transaction by accident. Pass None outside of a transaction.
450513
514+ ``cursor_id`` sets ``db.mongodb.cursor_id`` up front, for an operation
515+ reading a cursor that already exists: the id is known before the command is
516+ even built, and the operation span needs it even if the operation fails
517+ before any command span exists.
518+
451519 With ``set_current=False`` the span is created but not made current, and
452520 the operation-name contextvar is left alone. That suits a span created
453521 outside the ``_retry_internal`` call it covers, where the caller makes it
@@ -468,6 +536,8 @@ def start_operation_span(
468536 if collection :
469537 attributes ["db.collection.name" ] = collection
470538 attributes ["db.operation.summary" ] = name
539+ if cursor_id :
540+ attributes ["db.mongodb.cursor_id" ] = cursor_id
471541 if not set_current :
472542 span = _TRACER .start_span (
473543 name , kind = SpanKind .CLIENT , context = context , attributes = attributes
0 commit comments