6464 "_CURRENT_OPERATION_NAME" , default = None
6565)
6666
67+ # True while the driver is iterating a cursor of its own to build the return
68+ # value of one public API call (list_collection_names, index_information, ...).
69+ # Such a call gets a single operation span covering every getMore it sends,
70+ # whereas a cursor handed back to the caller gets a fresh operation span per
71+ # caller-driven getMore. See internal_cursor_iteration.
72+ _INTERNAL_CURSOR_ITERATION : ContextVar [bool ] = ContextVar (
73+ "_INTERNAL_CURSOR_ITERATION" , default = False
74+ )
75+
6776if TYPE_CHECKING :
6877 from opentelemetry .trace import Span , Tracer
6978
@@ -115,6 +124,28 @@ def _env_truthy(name: str) -> bool:
115124 return os .getenv (name , "" ).strip ().lower () in _TRUTHY
116125
117126
127+ @contextlib .contextmanager
128+ def internal_cursor_iteration () -> Iterator [None ]:
129+ """Mark the enclosing block as driver-internal cursor iteration.
130+
131+ Wrap the block in which a public API method creates a cursor and drains it
132+ itself to build its return value. Everything the block sends, including
133+ every getMore, then belongs to that method's one operation span, as the
134+ OTel spec requires. Outside such a block the cursor is assumed to reach the
135+ caller, whose iteration is a separate operation per getMore.
136+ """
137+ token = _INTERNAL_CURSOR_ITERATION .set (True )
138+ try :
139+ yield
140+ finally :
141+ _INTERNAL_CURSOR_ITERATION .reset (token )
142+
143+
144+ def is_internal_cursor_iteration () -> bool :
145+ """Return True inside an :func:`internal_cursor_iteration` block."""
146+ return _INTERNAL_CURSOR_ITERATION .get ()
147+
148+
118149def _is_tracing_enabled (tracing_options : Optional [TracingOptions ]) -> bool :
119150 """Return True if spans should be created for this client.
120151
@@ -286,6 +317,14 @@ def start_command_span(
286317 return None
287318
288319 collection = _extract_collection_name (command_name , dbname , cmd )
320+ # A getMore's own command value is the id of the cursor being read, which is
321+ # the value db.mongodb.cursor_id takes for a command operating on an
322+ # existing cursor: the id sent, not whatever the reply comes back with. It
323+ # has to be read here rather than from the reply because the reply is 0 once
324+ # the cursor is exhausted, and the attribute is required even then.
325+ sent_cursor_id = cmd .get (_GET_MORE ) if command_name == _GET_MORE else None
326+ if not isinstance (sent_cursor_id , int ):
327+ sent_cursor_id = None
289328 # Backfill the operation span's name/namespace/summary from the first command
290329 # built inside it. Before the sensitive-command return below, since the
291330 # operation span needs those attributes even when the command gets no span.
@@ -299,6 +338,8 @@ def start_command_span(
299338 current_span .set_attribute ("db.operation.summary" , summary )
300339 if collection :
301340 current_span .set_attribute ("db.collection.name" , collection )
341+ if sent_cursor_id :
342+ current_span .set_attribute ("db.mongodb.cursor_id" , sent_cursor_id )
302343
303344 if _is_sensitive_command (command_name , speculative_hello ):
304345 return None
@@ -320,6 +361,8 @@ def start_command_span(
320361 attributes ["db.collection.name" ] = collection
321362 if conn .server_connection_id is not None :
322363 attributes ["db.mongodb.server_connection_id" ] = conn .server_connection_id
364+ if sent_cursor_id :
365+ attributes ["db.mongodb.cursor_id" ] = sent_cursor_id
323366 lsid = cmd .get ("lsid" )
324367 if isinstance (lsid , Mapping ):
325368 formatted_lsid = _format_lsid (lsid )
@@ -336,15 +379,34 @@ def start_command_span(
336379 return _TRACER .start_span (command_name , kind = SpanKind .CLIENT , attributes = attributes )
337380
338381
382+ def _set_operation_cursor_id (cursor_id : int ) -> None :
383+ """Set db.mongodb.cursor_id on the ambient operation span, if there is one.
384+
385+ Guarded on the operation-name contextvar for the same reason
386+ ``start_command_span``'s backfill is: without it the "current span" could be
387+ an unrelated span belonging to the host application.
388+ """
389+ if _CURRENT_OPERATION_NAME .get () is None :
390+ return
391+ current_span = trace .get_current_span ()
392+ if current_span .is_recording ():
393+ current_span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
394+
395+
339396def end_command_span_success (span : Optional [Span ], reply : _DocumentOut ) -> None :
340397 """Set the cursor id (if any open cursor) and end the span."""
341398 if span is None :
342399 return
343400 cursor = reply .get ("cursor" )
344401 if isinstance (cursor , Mapping ) and cursor .get ("id" ):
345402 # Per the spec the attribute is omitted rather than set to 0, so a
346- # cursor-creating command that leaves no cursor open reports nothing.
347- span .set_attribute ("db.mongodb.cursor_id" , cursor ["id" ])
403+ # cursor-creating command that leaves no cursor open reports nothing. A
404+ # getMore keeps the id it sent, which this does not overwrite with a 0.
405+ cursor_id = cursor ["id" ]
406+ span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
407+ # The operation span carries the same attribute: this reply's id for a
408+ # cursor-creating command, or the already-set sent id for a getMore.
409+ _set_operation_cursor_id (cursor_id )
348410 span .end ()
349411
350412
@@ -412,6 +474,7 @@ def start_operation_span(
412474 dbname : Optional [str ] = None ,
413475 collection : Optional [str ] = None ,
414476 set_current : bool = True ,
477+ cursor_id : Optional [int ] = None ,
415478) -> Optional [_OperationSpanHandle ]:
416479 """Start a CLIENT-kind span for one logical operation, or None.
417480
@@ -424,6 +487,10 @@ def start_operation_span(
424487 ``parent_span`` becomes an *explicit* parent rather than being read from
425488 ambient context, so a concurrent unrelated session cannot be captured.
426489
490+ ``cursor_id`` sets ``db.mongodb.cursor_id`` up front, for an operation
491+ reading an existing cursor: the id is known before the command is built and
492+ is needed even if the operation fails before any command span exists.
493+
427494 ``set_current=False`` leaves the span and the operation-name contextvar
428495 alone, for a caller that makes it current with ``use_operation_span``.
429496 """
@@ -442,6 +509,8 @@ def start_operation_span(
442509 if collection :
443510 attributes ["db.collection.name" ] = collection
444511 attributes ["db.operation.summary" ] = name
512+ if cursor_id :
513+ attributes ["db.mongodb.cursor_id" ] = cursor_id
445514 if not set_current :
446515 span = _TRACER .start_span (
447516 name , kind = SpanKind .CLIENT , context = context , attributes = attributes
0 commit comments