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
@@ -307,6 +338,14 @@ def start_command_span(
307338 return None
308339
309340 collection = _extract_collection_name (command_name , dbname , cmd )
341+ # A getMore's own command value is the id of the cursor being read, which is
342+ # the value db.mongodb.cursor_id takes for a command operating on an
343+ # existing cursor: the id sent, not whatever the reply comes back with. It
344+ # has to be read here rather than from the reply because the reply is 0 once
345+ # the cursor is exhausted, and the attribute is required even then.
346+ sent_cursor_id = cmd .get (_GET_MORE ) if command_name == _GET_MORE else None
347+ if not isinstance (sent_cursor_id , int ):
348+ sent_cursor_id = None
310349 # Backfill the ambient operation span's name/namespace/summary from the
311350 # first command built inside it, before the sensitive-command early return
312351 # below: the operation span still needs its (Required, per the OTel spec)
@@ -322,6 +361,8 @@ def start_command_span(
322361 current_span .set_attribute ("db.operation.summary" , summary )
323362 if collection :
324363 current_span .set_attribute ("db.collection.name" , collection )
364+ if sent_cursor_id :
365+ current_span .set_attribute ("db.mongodb.cursor_id" , sent_cursor_id )
325366
326367 if _is_sensitive_command (command_name , speculative_hello ):
327368 return None
@@ -343,6 +384,8 @@ def start_command_span(
343384 attributes ["db.collection.name" ] = collection
344385 if conn .server_connection_id is not None :
345386 attributes ["db.mongodb.server_connection_id" ] = conn .server_connection_id
387+ if sent_cursor_id :
388+ attributes ["db.mongodb.cursor_id" ] = sent_cursor_id
346389 lsid = cmd .get ("lsid" )
347390 if isinstance (lsid , Mapping ):
348391 formatted_lsid = _format_lsid (lsid )
@@ -359,6 +402,20 @@ def start_command_span(
359402 return _TRACER .start_span (command_name , kind = SpanKind .CLIENT , attributes = attributes )
360403
361404
405+ def _set_operation_cursor_id (cursor_id : int ) -> None :
406+ """Set db.mongodb.cursor_id on the ambient operation span, if there is one.
407+
408+ Guarded on the operation-name contextvar for the same reason
409+ ``start_command_span``'s backfill is: without it the "current span" could be
410+ an unrelated span belonging to the host application.
411+ """
412+ if _CURRENT_OPERATION_NAME .get () is None :
413+ return
414+ current_span = trace .get_current_span ()
415+ if current_span .is_recording ():
416+ current_span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
417+
418+
362419def end_command_span_success (span : Optional [Span ], reply : _DocumentOut ) -> None :
363420 """Set the cursor id (if any open cursor) and end the span."""
364421 if span is None :
@@ -368,8 +425,14 @@ def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None:
368425 # A cursor id of 0 means the cursor is already exhausted, i.e. there is
369426 # no cursor left to track, so per the OTel spec the attribute is
370427 # omitted, never set to 0, when a cursor-creating command's reply
371- # returns 0.
372- span .set_attribute ("db.mongodb.cursor_id" , cursor ["id" ])
428+ # returns 0. A getMore keeps the id it sent, set in start_command_span,
429+ # which this deliberately does not overwrite with a 0 reply id.
430+ cursor_id = cursor ["id" ]
431+ span .set_attribute ("db.mongodb.cursor_id" , cursor_id )
432+ # The enclosing operation span carries the same attribute. For a
433+ # cursor-creating command that is this reply's id; for a getMore it is
434+ # the id already set from the sent value, which this repeats unchanged.
435+ _set_operation_cursor_id (cursor_id )
373436 span .end ()
374437
375438
@@ -441,6 +504,7 @@ def start_operation_span(
441504 dbname : Optional [str ] = None ,
442505 collection : Optional [str ] = None ,
443506 set_current : bool = True ,
507+ cursor_id : Optional [int ] = None ,
444508) -> Optional [_OperationSpanHandle ]:
445509 """Start a CLIENT-kind span for one logical operation, or None.
446510
@@ -459,6 +523,11 @@ def start_operation_span(
459523 avoid a concurrently-running unrelated session's operations picking up
460524 this transaction by accident. Pass None outside of a transaction.
461525
526+ ``cursor_id`` sets ``db.mongodb.cursor_id`` up front, for an operation
527+ reading a cursor that already exists: the id is known before the command is
528+ even built, and the operation span needs it even if the operation fails
529+ before any command span exists.
530+
462531 With ``set_current=False`` the span is created but not made current, and
463532 the operation-name contextvar is left alone. That suits a span created
464533 outside the ``_retry_internal`` call it covers, where the caller makes it
@@ -479,6 +548,8 @@ def start_operation_span(
479548 if collection :
480549 attributes ["db.collection.name" ] = collection
481550 attributes ["db.operation.summary" ] = name
551+ if cursor_id :
552+ attributes ["db.mongodb.cursor_id" ] = cursor_id
482553 if not set_current :
483554 span = _TRACER .start_span (
484555 name , kind = SpanKind .CLIENT , context = context , attributes = attributes
0 commit comments