Skip to content

Commit f407469

Browse files
committed
PYTHON-5947 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 2649974 commit f407469

22 files changed

Lines changed: 1794 additions & 104 deletions

pymongo/_otel.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,15 @@
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+
7180
if 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+
126157
def _is_tracing_enabled(tracing_options: Optional[TracingOptions]) -> bool:
127158
"""Return True if spans should be created for this client.
128159
@@ -369,6 +400,20 @@ def start_command_span(
369400
return _TRACER.start_span(command_name, kind=SpanKind.CLIENT, attributes=attributes)
370401

371402

403+
def _set_operation_cursor_id(cursor_id: int) -> None:
404+
"""Set db.mongodb.cursor_id on the ambient operation span, if there is one.
405+
406+
Guarded on the operation-name contextvar for the same reason
407+
``start_command_span``'s backfill is: without it the "current span" could be
408+
an unrelated span belonging to the host application.
409+
"""
410+
if _CURRENT_OPERATION_NAME.get() is None:
411+
return
412+
current_span = trace.get_current_span()
413+
if current_span.is_recording():
414+
current_span.set_attribute("db.mongodb.cursor_id", cursor_id)
415+
416+
372417
def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None:
373418
"""Set the cursor id (if any open cursor) and end the span."""
374419
if span is None:
@@ -378,8 +423,14 @@ def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None:
378423
# A cursor id of 0 means the cursor is already exhausted, i.e. there is
379424
# no cursor left to track, so per the OTel spec the attribute is
380425
# omitted, never set to 0, when a cursor-creating command's reply
381-
# returns 0.
382-
span.set_attribute("db.mongodb.cursor_id", cursor["id"])
426+
# returns 0. A getMore keeps the id it sent, set in start_command_span,
427+
# which this deliberately does not overwrite with a 0 reply id.
428+
cursor_id = cursor["id"]
429+
span.set_attribute("db.mongodb.cursor_id", cursor_id)
430+
# The enclosing operation span carries the same attribute. For a
431+
# cursor-creating command that is this reply's id; for a getMore it is
432+
# the id already set from the sent value, which this repeats unchanged.
433+
_set_operation_cursor_id(cursor_id)
383434
span.end()
384435

385436

@@ -451,6 +502,7 @@ def start_operation_span(
451502
dbname: Optional[str] = None,
452503
collection: Optional[str] = None,
453504
set_current: bool = True,
505+
cursor_id: Optional[int] = None,
454506
) -> Optional[_OperationSpanHandle]:
455507
"""Start a CLIENT-kind span for one logical operation, or None.
456508
@@ -469,6 +521,11 @@ def start_operation_span(
469521
avoid a concurrently-running unrelated session's operations picking up
470522
this transaction by accident. Pass None outside of a transaction.
471523
524+
``cursor_id`` sets ``db.mongodb.cursor_id`` up front, for an operation
525+
reading a cursor that already exists: the id is known before the command is
526+
even built, and the operation span needs it even if the operation fails
527+
before any command span exists.
528+
472529
With ``set_current=False`` the span is created but not made current, and
473530
the operation-name contextvar is left alone. That suits a span created
474531
outside the ``_retry_internal`` call it covers, where the caller makes it
@@ -489,6 +546,8 @@ def start_operation_span(
489546
if collection:
490547
attributes["db.collection.name"] = collection
491548
attributes["db.operation.summary"] = name
549+
if cursor_id:
550+
attributes["db.mongodb.cursor_id"] = cursor_id
492551
if not set_current:
493552
span = _TRACER.start_span(
494553
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

pymongo/asynchronous/cursor.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from bson.code import Code
3535
from bson.son import SON
3636
from pymongo import helpers_shared
37+
from pymongo._otel import is_internal_cursor_iteration
3738
from pymongo._telemetry import _operation_telemetry_or_none
3839
from pymongo.asynchronous.cursor_base import _AsyncCursorBase, _ConnectionManager
3940
from pymongo.asynchronous.helpers import anext
@@ -1088,7 +1089,11 @@ async def _refresh(self) -> int:
10881089
collection=self._collection.name,
10891090
set_current=False,
10901091
)
1091-
await self._send_message_in_operation_span(q)
1092+
# The query's span covers the query alone unless this cursor is
1093+
# being drained by the public API call that created it, in which
1094+
# case the span stays open to cover that call's getMores too.
1095+
own_span = not is_internal_cursor_iteration()
1096+
await self._send_message_in_operation_span(q, own_span)
10921097
elif self._id: # Get More
10931098
if self._limit:
10941099
limit = self._limit - self._retrieved
@@ -1111,18 +1116,24 @@ async def _refresh(self) -> int:
11111116
self._exhaust,
11121117
self._comment,
11131118
)
1114-
await self._send_message(g)
1119+
own_span = self._start_getmore_operation_telemetry(self._dbname, self._collname)
1120+
await self._send_message_in_operation_span(g, own_span)
11151121

11161122
return len(self._data)
11171123

1118-
async def _send_message_in_operation_span(self, operation: Union[_Query, _GetMore]) -> None:
1119-
"""Send ``operation``, ending the operation span once it completes.
1124+
async def _send_message_in_operation_span(
1125+
self, operation: Union[_Query, _GetMore], own_span: bool
1126+
) -> None:
1127+
"""Send ``operation``, ending the operation span after it when we own it.
11201128
11211129
``_send_message``'s own error handling already ends the span with the
11221130
error on every failure path, and an exhausted cursor's close() ends it
11231131
on the way out; both are idempotent, so this only has to cover the
11241132
remaining case of a successful send that leaves the cursor open.
11251133
"""
1134+
if not own_span:
1135+
await self._send_message(operation)
1136+
return
11261137
try:
11271138
await self._send_message(operation)
11281139
except BaseException as exc:

0 commit comments

Comments
 (0)