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
@@ -116,6 +125,28 @@ def _env_truthy(name: str) -> bool:
116125 return os .getenv (name , "" ).strip ().lower () in _TRUTHY
117126
118127
128+ @contextlib .contextmanager
129+ def internal_cursor_iteration () -> Iterator [None ]:
130+ """Mark the enclosing block as driver-internal cursor iteration.
131+
132+ Wrap the block in which a public API method creates a cursor and drains it
133+ itself to build its return value. Everything the block sends, including
134+ every getMore, then belongs to that method's one operation span, as the
135+ OTel spec requires. Outside such a block the cursor is assumed to reach the
136+ caller, whose iteration is a separate operation per getMore.
137+ """
138+ token = _INTERNAL_CURSOR_ITERATION .set (True )
139+ try :
140+ yield
141+ finally :
142+ _INTERNAL_CURSOR_ITERATION .reset (token )
143+
144+
145+ def is_internal_cursor_iteration () -> bool :
146+ """Return True inside an :func:`internal_cursor_iteration` block."""
147+ return _INTERNAL_CURSOR_ITERATION .get ()
148+
149+
119150def _is_tracing_enabled (tracing_options : Optional [TracingOptions ]) -> bool :
120151 """Return True if spans should be created for this client.
121152
@@ -287,6 +318,14 @@ def start_command_span(
287318 return None
288319
289320 collection = _extract_collection_name (command_name , dbname , cmd )
321+ # A getMore's own command value is the id of the cursor being read, which is
322+ # the value db.mongodb.cursor_id takes for a command operating on an
323+ # existing cursor: the id sent, not whatever the reply comes back with. It
324+ # has to be read here rather than from the reply because the reply is 0 once
325+ # the cursor is exhausted, and the attribute is required even then.
326+ sent_cursor_id = cmd .get (_GET_MORE ) if command_name == _GET_MORE else None
327+ if not isinstance (sent_cursor_id , int ):
328+ sent_cursor_id = None
290329 # Backfill the operation span's name/namespace/summary from the first command
291330 # built inside it. Before the sensitive-command return below, since the
292331 # operation span needs those attributes even when the command gets no span.
@@ -300,6 +339,8 @@ def start_command_span(
300339 current_span .set_attribute ("db.operation.summary" , summary )
301340 if collection :
302341 current_span .set_attribute ("db.collection.name" , collection )
342+ if sent_cursor_id :
343+ current_span .set_attribute ("db.mongodb.cursor_id" , sent_cursor_id )
303344
304345 if _is_sensitive_command (command_name , speculative_hello ):
305346 return None
@@ -321,6 +362,8 @@ def start_command_span(
321362 attributes ["db.collection.name" ] = collection
322363 if conn .server_connection_id is not None :
323364 attributes ["db.mongodb.server_connection_id" ] = conn .server_connection_id
365+ if sent_cursor_id :
366+ attributes ["db.mongodb.cursor_id" ] = sent_cursor_id
324367 lsid = cmd .get ("lsid" )
325368 if isinstance (lsid , Mapping ):
326369 formatted_lsid = _format_lsid (lsid )
@@ -337,15 +380,34 @@ def start_command_span(
337380 return _TRACER .start_span (command_name , kind = SpanKind .CLIENT , attributes = attributes )
338381
339382
383+ def _set_operation_cursor_id (cursor_id : int ) -> None :
384+ """Set db.mongodb.cursor_id on the ambient operation span, if there is one.
385+
386+ Guarded on the operation-name contextvar for the same reason
387+ ``start_command_span``'s backfill is: without it the "current span" could be
388+ an unrelated span belonging to the host application.
389+ """
390+ if _CURRENT_OPERATION_NAME .get () is None :
391+ return
392+ current_span = trace .get_current_span ()
393+ if current_span .is_recording ():
394+ current_span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
395+
396+
340397def end_command_span_success (span : Optional [Span ], reply : _DocumentOut ) -> None :
341398 """Set the cursor id (if any open cursor) and end the span."""
342399 if span is None :
343400 return
344401 cursor = reply .get ("cursor" )
345402 if isinstance (cursor , Mapping ) and cursor .get ("id" ):
346403 # Per the spec the attribute is omitted rather than set to 0, so a
347- # cursor-creating command that leaves no cursor open reports nothing.
348- span .set_attribute ("db.mongodb.cursor_id" , cursor ["id" ])
404+ # cursor-creating command that leaves no cursor open reports nothing. A
405+ # getMore keeps the id it sent, which this does not overwrite with a 0.
406+ cursor_id = cursor ["id" ]
407+ span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
408+ # The operation span carries the same attribute: this reply's id for a
409+ # cursor-creating command, or the already-set sent id for a getMore.
410+ _set_operation_cursor_id (cursor_id )
349411 span .end ()
350412
351413
@@ -413,6 +475,7 @@ def start_operation_span(
413475 dbname : Optional [str ] = None ,
414476 collection : Optional [str ] = None ,
415477 set_current : bool = True ,
478+ cursor_id : Optional [int ] = None ,
416479) -> Optional [_OperationSpanHandle ]:
417480 """Start a CLIENT-kind span for one logical operation, or None.
418481
@@ -425,6 +488,11 @@ def start_operation_span(
425488 ``parent_span`` becomes an *explicit* parent rather than being read from
426489 ambient context, so a concurrent unrelated session cannot be captured.
427490
491+ ``cursor_id`` sets ``db.mongodb.cursor_id`` up front, for an operation
492+ reading a cursor that already exists: the id is known before the command is
493+ even built, and the operation span needs it even if the operation fails
494+ before any command span exists.
495+
428496 ``set_current=False`` leaves the span and the operation-name contextvar
429497 alone, for a caller that makes it current with ``use_operation_span``.
430498 """
@@ -443,6 +511,8 @@ def start_operation_span(
443511 if collection :
444512 attributes ["db.collection.name" ] = collection
445513 attributes ["db.operation.summary" ] = name
514+ if cursor_id :
515+ attributes ["db.mongodb.cursor_id" ] = cursor_id
446516 if not set_current :
447517 span = _TRACER .start_span (
448518 name , kind = SpanKind .CLIENT , context = context , attributes = attributes
0 commit comments