Skip to content

Commit 5fddbd6

Browse files
committed
PYTHON-5993 Add OpenTelemetry operation spans for cursor getMores
Give each caller-driven getMore an operation span of its own, as the specification requires: the application may do unrelated work between batches, so nesting them under the operation that created the cursor would misrepresent the timing. A public API call that creates a cursor and drains it itself, such as list_collection_names or index_information, is the exception. Those mark the block with internal_cursor_iteration(), and every getMore inside it belongs to that call's single operation span. The client bulk-write results cursor is a second exception, reusing the enclosing bulkWrite span rather than creating spurious siblings. Change streams deliberately get neither treatment: they can tail indefinitely, so a span covering the whole lifetime would never end. Also vendors the getMore spec fixture, the one fixture that needs this support.
1 parent 81de518 commit 5fddbd6

22 files changed

Lines changed: 1804 additions & 104 deletions

pymongo/_otel.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,15 @@
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+
6776
if 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+
118149
def _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+
339396
def 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

pymongo/_telemetry.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,9 @@ class _OperationTelemetry:
276276
That suits a span started outside the ``_retry_internal`` call it covers,
277277
such as a cursor-creating command's, whose span has to exist before the
278278
cursor does; that call makes it current with :meth:`use`.
279+
280+
``cursor_id`` presets ``db.mongodb.cursor_id`` for an operation reading a
281+
cursor that already exists, whose id is known before the command is built.
279282
"""
280283

281284
__slots__ = ("handle",)
@@ -289,6 +292,7 @@ def __init__(
289292
dbname: Optional[str] = None,
290293
collection: Optional[str] = None,
291294
set_current: bool = True,
295+
cursor_id: Optional[int] = None,
292296
) -> None:
293297
parent_span = None
294298
if session is not None and session.in_transaction:
@@ -300,6 +304,7 @@ def __init__(
300304
dbname=dbname,
301305
collection=collection,
302306
set_current=set_current,
307+
cursor_id=cursor_id,
303308
)
304309

305310
def use(self) -> Any:
@@ -330,6 +335,7 @@ def _operation_telemetry_or_none(
330335
dbname: Optional[str] = None,
331336
collection: Optional[str] = None,
332337
set_current: bool = True,
338+
cursor_id: Optional[int] = None,
333339
) -> Optional[_OperationTelemetry]:
334340
"""Return an :class:`_OperationTelemetry`, or None if tracing is disabled.
335341
@@ -346,6 +352,7 @@ def _operation_telemetry_or_none(
346352
dbname=dbname,
347353
collection=collection,
348354
set_current=set_current,
355+
cursor_id=cursor_id,
349356
)
350357

351358

pymongo/asynchronous/change_stream.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,14 @@ async def _run_aggregation_cmd(
250250
result_processor=self._process_result,
251251
comment=self._comment,
252252
)
253+
# Deliberately no operation_telemetry is attached to the resulting
254+
# cursor here: a change stream can tail indefinitely, so an operation
255+
# span covering its whole lifetime (initial query + every getMore,
256+
# like other command cursors) would never end while it's watching.
257+
# Leaving it unattached means each getMore instead gets its own
258+
# short-lived sibling "getMore" operation span, less ideal nesting,
259+
# but not a leaked/never-exported span. Do not "fix" this without
260+
# addressing that tradeoff.
253261
return await self._client._retryable_read(
254262
cmd.get_cursor,
255263
self._target._read_preference_for(session),

pymongo/asynchronous/client_bulk.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ async def _process_results_cursor(
335335
session=session,
336336
comment=self.comment,
337337
)
338+
# This cursor's getMores run inside the enclosing bulkWrite
339+
# operation span, so their command spans belong under it directly;
340+
# a getMore operation span of their own would be spurious. The
341+
# cursor is also per-batch and never surfaces to the caller, so
342+
# there is no cursor-lifetime span to own here.
343+
cmd_cursor._reuse_current_span_for_getmore = True
338344
await cmd_cursor._maybe_pin_connection(conn)
339345

340346
# Iterate the cursor to get individual write results.

pymongo/asynchronous/collection.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from bson.son import SON
3939
from bson.timestamp import Timestamp
4040
from pymongo import ASCENDING, _csot, common, helpers_shared, message
41+
from pymongo._otel import internal_cursor_iteration
4142
from pymongo.asynchronous.aggregation import (
4243
_CollectionAggregationCommand,
4344
_CollectionRawAggregationCommand,
@@ -2639,12 +2640,13 @@ async def index_information(
26392640
.. versionchanged:: 3.6
26402641
Added ``session`` parameter.
26412642
"""
2642-
cursor = await self._list_indexes(session=session, comment=comment)
2643-
info = {}
2644-
async for index in cursor:
2645-
index["key"] = list(index["key"].items())
2646-
index = dict(index) # noqa: PLW2901
2647-
info[index.pop("name")] = index
2643+
with internal_cursor_iteration():
2644+
cursor = await self._list_indexes(session=session, comment=comment)
2645+
info = {}
2646+
async for index in cursor:
2647+
index["key"] = list(index["key"].items())
2648+
index = dict(index) # noqa: PLW2901
2649+
info[index.pop("name")] = index
26482650
return info
26492651

26502652
async def list_search_indexes(
@@ -2910,14 +2912,15 @@ async def options(
29102912
self.write_concern,
29112913
self.read_concern,
29122914
)
2913-
cursor = await dbo.list_collections(
2914-
session=session, filter={"name": self._name}, comment=comment
2915-
)
2915+
with internal_cursor_iteration():
2916+
cursor = await dbo.list_collections(
2917+
session=session, filter={"name": self._name}, comment=comment
2918+
)
29162919

2917-
result = None
2918-
async for doc in cursor:
2919-
result = doc
2920-
break
2920+
result = None
2921+
async for doc in cursor:
2922+
result = doc
2923+
break
29212924

29222925
if not result:
29232926
return {}

pymongo/asynchronous/command_cursor.py

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from pymongo.asynchronous.cursor_base import _AsyncCursorBase, _ConnectionManager
3030
from pymongo.cursor_shared import _CURSOR_CLOSED_ERRORS
3131
from pymongo.errors import ConnectionFailure, InvalidOperation, OperationFailure
32+
from pymongo.helpers_shared import _split_namespace
3233
from pymongo.message import _GetMore, _OpMsg, _RawBatchGetMore
3334
from pymongo.response import PinnedResponse
3435
from pymongo.typings import _Address, _DocumentOut, _DocumentType
@@ -173,9 +174,14 @@ async def _send_message(self, operation: _GetMore) -> None:
173174
client = self._collection.database.client
174175
try:
175176
response = await client._run_operation(
176-
operation, self._run_with_conn, address=self._address
177+
operation,
178+
self._run_with_conn,
179+
address=self._address,
180+
operation_telemetry=self._operation_telemetry,
181+
reuse_current_span=self._reuse_current_span_for_getmore,
177182
)
178183
except OperationFailure as exc:
184+
self._end_operation_telemetry(exc)
179185
if exc.code in _CURSOR_CLOSED_ERRORS:
180186
# Don't send killCursors because the cursor is already closed.
181187
self._killed = True
@@ -185,13 +191,15 @@ async def _send_message(self, operation: _GetMore) -> None:
185191
# Return the session and pinned connection, if necessary.
186192
await self.close()
187193
raise
188-
except ConnectionFailure:
194+
except ConnectionFailure as exc:
195+
self._end_operation_telemetry(exc)
189196
# Don't send killCursors because the cursor is already closed.
190197
self._killed = True
191198
# Return the session and pinned connection, if necessary.
192199
await self.close()
193200
raise
194-
except Exception:
201+
except Exception as exc:
202+
self._end_operation_telemetry(exc)
195203
await self.close()
196204
raise
197205

@@ -218,24 +226,36 @@ async def _refresh(self) -> int:
218226
return len(self._data)
219227

220228
if self._id: # Get More
221-
dbname, collname = self._ns.split(".", 1)
229+
dbname, collname = _split_namespace(self._ns)
222230
read_pref = self._collection._read_preference_for(self.session)
223-
await self._send_message(
224-
self._getmore_class(
225-
dbname,
226-
collname,
227-
self._batch_size,
228-
self._id,
229-
self._collection.codec_options,
230-
read_pref,
231-
self._session,
232-
self._collection.database.client,
233-
self._max_await_time_ms,
234-
self._sock_mgr,
235-
False,
236-
self._comment,
237-
)
231+
getmore = self._getmore_class(
232+
dbname,
233+
collname,
234+
self._batch_size,
235+
self._id,
236+
self._collection.codec_options,
237+
read_pref,
238+
self._session,
239+
self._collection.database.client,
240+
self._max_await_time_ms,
241+
self._sock_mgr,
242+
False,
243+
self._comment,
238244
)
245+
own_span = self._start_getmore_operation_telemetry(dbname, collname)
246+
if not own_span:
247+
await self._send_message(getmore)
248+
else:
249+
# _send_message ends the span itself on every failure path, and
250+
# an exhausted cursor's close() ends it on the way out; both are
251+
# idempotent, so only a successful send leaving the cursor open
252+
# is left to handle here.
253+
try:
254+
await self._send_message(getmore)
255+
except BaseException as exc:
256+
self._end_operation_telemetry(exc)
257+
raise
258+
self._end_operation_telemetry()
239259
else: # Cursor id is zero nothing else to return
240260
await self._die_lock()
241261

0 commit comments

Comments
 (0)