Skip to content

Commit b70f3b6

Browse files
committed
PYTHON-5947 Add OpenTelemetry transaction spans
Wrap a transaction's operation spans in a "transaction" pseudo-span, per the OpenTelemetry driver specification. The span is stored on the session's _Transaction and passed as the explicit parent when an operation span starts, rather than read from ambient context, so a concurrently running unrelated session cannot pick up this transaction by accident. with_transaction() pins one span across all of its retries, so a retried call still yields a single span rather than one per attempt. Its retry loop moves into a helper to keep the span bookkeeping readable. A nested with_transaction() call on the same session now raises instead of clobbering and leaking the outer call's span, and a direct-API commit retry starts a fresh span, the previous attempt having already ended its own.
1 parent 22bb53b commit b70f3b6

6 files changed

Lines changed: 1106 additions & 3 deletions

File tree

pymongo/_otel.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,3 +522,27 @@ def end_operation_span_failure(handle: Optional[_OperationSpanHandle], exc: Base
522522
return
523523
_CURRENT_OPERATION_NAME.reset(handle._name_token)
524524
handle._cm.__exit__(None, None, None)
525+
526+
527+
def start_transaction_span(tracing_options: Optional[TracingOptions]) -> Optional[Span]:
528+
"""Start (but do not make current) the ``"transaction"`` pseudo-span, or None.
529+
530+
Not pushed as ambient/current context; it's stored explicitly on
531+
``session._transaction.span`` and passed as the explicit ``parent_span``
532+
wherever an operation span is started under this transaction (see
533+
:func:`start_operation_span`). Per the OTel driver spec, this span has
534+
exactly one attribute.
535+
"""
536+
if not _is_tracing_enabled(tracing_options):
537+
return None
538+
assert _TRACER is not None
539+
return _TRACER.start_span(
540+
"transaction", kind=SpanKind.CLIENT, attributes={"db.system.name": "mongodb"}
541+
)
542+
543+
544+
def end_transaction_span(span: Optional[Span]) -> None:
545+
"""End the transaction span, if any."""
546+
if span is None:
547+
return
548+
span.end()

pymongo/_telemetry.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,13 @@ def __init__(
290290
collection: Optional[str] = None,
291291
set_current: bool = True,
292292
) -> None:
293+
parent_span = None
294+
if session is not None and session.in_transaction:
295+
parent_span = session._transaction.span
293296
self.handle = _otel.start_operation_span(
294297
tracing_options,
295298
_otel._build_operation_name(operation, is_run_command),
296-
None,
299+
parent_span,
297300
dbname=dbname,
298301
collection=collection,
299302
set_current=set_current,

pymongo/asynchronous/client_session.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@
156156
from bson.binary import Binary
157157
from bson.int64 import Int64
158158
from bson.timestamp import Timestamp
159-
from pymongo import _csot
159+
from pymongo import _csot, _otel
160160
from pymongo.asynchronous.cursor_base import _ConnectionManager
161161
from pymongo.errors import (
162162
ConfigurationError,
@@ -427,6 +427,7 @@ def __init__(self, opts: Optional[TransactionOptions], client: AsyncMongoClient[
427427
self.attempt = 0
428428
self.client = client
429429
self.has_completed_command = False
430+
self.span: Optional[Any] = None
430431

431432
def active(self) -> bool:
432433
return self.state in (_TxnState.STARTING, _TxnState.IN_PROGRESS)
@@ -467,6 +468,7 @@ async def reset(self) -> None:
467468
self.recovery_token = None
468469
self.attempt = 0
469470
self.has_completed_command = False
471+
self.span = None
470472

471473
def __del__(self) -> None:
472474
if self.conn_mgr:
@@ -562,6 +564,10 @@ def __init__(
562564
# Is this an implicitly created session?
563565
self._implicit = implicit
564566
self._transaction = _Transaction(None, client)
567+
# The one "transaction" span shared across every retry of a single
568+
# with_transaction() call, or None outside of it, where
569+
# start/commit/abort_transaction each manage their own span.
570+
self._with_transaction_span: Optional[Any] = None
565571
# Is this session attached to a cursor?
566572
self._attached_to_cursor = False
567573
# Should we leave the session alive when the cursor is closed?
@@ -769,6 +775,43 @@ async def callback(session, custom_arg, custom_kwarg=None):
769775
.. _transactions specification:
770776
https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/transactions-convenient-api.md#handling-errors-inside-the-callback
771777
"""
778+
if self._with_transaction_span is not None:
779+
# Raise before any span bookkeeping, so a nested call cannot
780+
# clobber and leak the outer call's span.
781+
raise InvalidOperation(
782+
"Cannot call with_transaction() while a previous with_transaction() "
783+
"call on this session has not returned; sessions do not support "
784+
"nested or concurrent with_transaction() calls"
785+
)
786+
# One span for the whole call: start_transaction reuses it and
787+
# commit/abort leave it open, so a retried with_transaction() yields a
788+
# single span. Skipped when a direct-API transaction is already active,
789+
# since start_transaction() raises below and the span would be empty.
790+
tracing_options = self._client.options.tracing
791+
if _otel._is_tracing_enabled(tracing_options) and not self.in_transaction:
792+
self._with_transaction_span = _otel.start_transaction_span(tracing_options)
793+
try:
794+
return await self._with_transaction_retry_loop(
795+
callback, read_concern, write_concern, read_preference, max_commit_time_ms
796+
)
797+
finally:
798+
if self._with_transaction_span is not None:
799+
_otel.end_transaction_span(self._with_transaction_span)
800+
# Only clear the span this call owns; a concurrent direct-API
801+
# transaction's span belongs to that transaction.
802+
if self._transaction.span is self._with_transaction_span:
803+
self._transaction.span = None
804+
self._with_transaction_span = None
805+
806+
async def _with_transaction_retry_loop(
807+
self,
808+
callback: Callable[[AsyncClientSession], Awaitable[_T]],
809+
read_concern: Optional[ReadConcern],
810+
write_concern: Optional[WriteConcern],
811+
read_preference: Optional[_ServerMode],
812+
max_commit_time_ms: Optional[int],
813+
) -> _T:
814+
"""Run with_transaction's retry loop; see with_transaction."""
772815
start_time = time.monotonic()
773816
retry = 0
774817
last_error: Optional[BaseException] = None
@@ -864,9 +907,30 @@ async def start_transaction(
864907
)
865908
await self._transaction.reset()
866909
self._transaction.state = _TxnState.STARTING
910+
if self._with_transaction_span is not None:
911+
# Reuse with_transaction's shared span so a retried call still
912+
# produces exactly one "transaction" span.
913+
self._transaction.span = self._with_transaction_span
914+
elif _otel._is_tracing_enabled(self._transaction.client.options.tracing):
915+
self._transaction.span = _otel.start_transaction_span(
916+
self._transaction.client.options.tracing
917+
)
867918
self._start_retryable_write()
868919
return _TransactionContext(self)
869920

921+
def _end_own_transaction_span(self) -> None:
922+
"""End and clear the transaction span, unless with_transaction() owns it.
923+
924+
with_transaction() pins one shared span across all of its retries in
925+
``self._with_transaction_span`` (see its comments); while that's set,
926+
the span must survive until with_transaction() itself ends it, so this
927+
is a no-op here. Otherwise a retried with_transaction() would end the
928+
shared span prematurely on the first failed attempt.
929+
"""
930+
if self._transaction.span is not None and self._with_transaction_span is None:
931+
_otel.end_transaction_span(self._transaction.span)
932+
self._transaction.span = None
933+
870934
async def commit_transaction(self) -> None:
871935
"""Commit a multi-statement transaction.
872936
@@ -879,13 +943,23 @@ async def commit_transaction(self) -> None:
879943
elif state in (_TxnState.STARTING, _TxnState.COMMITTED_EMPTY):
880944
# Server transaction was never started, no need to send a command.
881945
self._transaction.state = _TxnState.COMMITTED_EMPTY
946+
self._end_own_transaction_span()
882947
return
883948
elif state is _TxnState.ABORTED:
884949
raise InvalidOperation("Cannot call commitTransaction after calling abortTransaction")
885950
elif state is _TxnState.COMMITTED:
886951
# We're explicitly retrying the commit, move the state back to
887952
# "in progress" so that in_transaction returns true.
888953
self._transaction.state = _TxnState.IN_PROGRESS
954+
# A direct-API retry needs a fresh span: the prior attempt's
955+
# finally block already ended and cleared it. with_transaction
956+
# pins its shared span instead, see _end_own_transaction_span.
957+
if self._transaction.span is None and _otel._is_tracing_enabled(
958+
self._transaction.client.options.tracing
959+
):
960+
self._transaction.span = _otel.start_transaction_span(
961+
self._transaction.client.options.tracing
962+
)
889963

890964
try:
891965
await self._finish_transaction_with_retry("commitTransaction")
@@ -909,6 +983,7 @@ async def commit_transaction(self) -> None:
909983
_reraise_with_unknown_commit(exc)
910984
finally:
911985
self._transaction.state = _TxnState.COMMITTED
986+
self._end_own_transaction_span()
912987

913988
async def abort_transaction(self) -> None:
914989
"""Abort a multi-statement transaction.
@@ -923,6 +998,7 @@ async def abort_transaction(self) -> None:
923998
elif state is _TxnState.STARTING:
924999
# Server transaction was never started, no need to send a command.
9251000
self._transaction.state = _TxnState.ABORTED
1001+
self._end_own_transaction_span()
9261002
return
9271003
elif state is _TxnState.ABORTED:
9281004
raise InvalidOperation("Cannot call abortTransaction twice")
@@ -936,6 +1012,7 @@ async def abort_transaction(self) -> None:
9361012
pass
9371013
finally:
9381014
self._transaction.state = _TxnState.ABORTED
1015+
self._end_own_transaction_span()
9391016
await self._unpin()
9401017

9411018
async def _finish_transaction_with_retry(self, command_name: str) -> dict[str, Any]:

pymongo/synchronous/client_session.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@
155155
from bson.binary import Binary
156156
from bson.int64 import Int64
157157
from bson.timestamp import Timestamp
158-
from pymongo import _csot
158+
from pymongo import _csot, _otel
159159
from pymongo.errors import (
160160
ConfigurationError,
161161
ConnectionFailure,
@@ -426,6 +426,7 @@ def __init__(self, opts: Optional[TransactionOptions], client: MongoClient[Any])
426426
self.attempt = 0
427427
self.client = client
428428
self.has_completed_command = False
429+
self.span: Optional[Any] = None
429430

430431
def active(self) -> bool:
431432
return self.state in (_TxnState.STARTING, _TxnState.IN_PROGRESS)
@@ -466,6 +467,7 @@ def reset(self) -> None:
466467
self.recovery_token = None
467468
self.attempt = 0
468469
self.has_completed_command = False
470+
self.span = None
469471

470472
def __del__(self) -> None:
471473
if self.conn_mgr:
@@ -561,6 +563,10 @@ def __init__(
561563
# Is this an implicitly created session?
562564
self._implicit = implicit
563565
self._transaction = _Transaction(None, client)
566+
# The one "transaction" span shared across every retry of a single
567+
# with_transaction() call, or None outside of it, where
568+
# start/commit/abort_transaction each manage their own span.
569+
self._with_transaction_span: Optional[Any] = None
564570
# Is this session attached to a cursor?
565571
self._attached_to_cursor = False
566572
# Should we leave the session alive when the cursor is closed?
@@ -768,6 +774,43 @@ def callback(session, custom_arg, custom_kwarg=None):
768774
.. _transactions specification:
769775
https://github.com/mongodb/specifications/blob/master/source/transactions-convenient-api/transactions-convenient-api.md#handling-errors-inside-the-callback
770776
"""
777+
if self._with_transaction_span is not None:
778+
# Raise before any span bookkeeping, so a nested call cannot
779+
# clobber and leak the outer call's span.
780+
raise InvalidOperation(
781+
"Cannot call with_transaction() while a previous with_transaction() "
782+
"call on this session has not returned; sessions do not support "
783+
"nested or concurrent with_transaction() calls"
784+
)
785+
# One span for the whole call: start_transaction reuses it and
786+
# commit/abort leave it open, so a retried with_transaction() yields a
787+
# single span. Skipped when a direct-API transaction is already active,
788+
# since start_transaction() raises below and the span would be empty.
789+
tracing_options = self._client.options.tracing
790+
if _otel._is_tracing_enabled(tracing_options) and not self.in_transaction:
791+
self._with_transaction_span = _otel.start_transaction_span(tracing_options)
792+
try:
793+
return self._with_transaction_retry_loop(
794+
callback, read_concern, write_concern, read_preference, max_commit_time_ms
795+
)
796+
finally:
797+
if self._with_transaction_span is not None:
798+
_otel.end_transaction_span(self._with_transaction_span)
799+
# Only clear the span this call owns; a concurrent direct-API
800+
# transaction's span belongs to that transaction.
801+
if self._transaction.span is self._with_transaction_span:
802+
self._transaction.span = None
803+
self._with_transaction_span = None
804+
805+
def _with_transaction_retry_loop(
806+
self,
807+
callback: Callable[[ClientSession], _T],
808+
read_concern: Optional[ReadConcern],
809+
write_concern: Optional[WriteConcern],
810+
read_preference: Optional[_ServerMode],
811+
max_commit_time_ms: Optional[int],
812+
) -> _T:
813+
"""Run with_transaction's retry loop; see with_transaction."""
771814
start_time = time.monotonic()
772815
retry = 0
773816
last_error: Optional[BaseException] = None
@@ -861,9 +904,30 @@ def start_transaction(
861904
)
862905
self._transaction.reset()
863906
self._transaction.state = _TxnState.STARTING
907+
if self._with_transaction_span is not None:
908+
# Reuse with_transaction's shared span so a retried call still
909+
# produces exactly one "transaction" span.
910+
self._transaction.span = self._with_transaction_span
911+
elif _otel._is_tracing_enabled(self._transaction.client.options.tracing):
912+
self._transaction.span = _otel.start_transaction_span(
913+
self._transaction.client.options.tracing
914+
)
864915
self._start_retryable_write()
865916
return _TransactionContext(self)
866917

918+
def _end_own_transaction_span(self) -> None:
919+
"""End and clear the transaction span, unless with_transaction() owns it.
920+
921+
with_transaction() pins one shared span across all of its retries in
922+
``self._with_transaction_span`` (see its comments); while that's set,
923+
the span must survive until with_transaction() itself ends it, so this
924+
is a no-op here. Otherwise a retried with_transaction() would end the
925+
shared span prematurely on the first failed attempt.
926+
"""
927+
if self._transaction.span is not None and self._with_transaction_span is None:
928+
_otel.end_transaction_span(self._transaction.span)
929+
self._transaction.span = None
930+
867931
def commit_transaction(self) -> None:
868932
"""Commit a multi-statement transaction.
869933
@@ -876,13 +940,23 @@ def commit_transaction(self) -> None:
876940
elif state in (_TxnState.STARTING, _TxnState.COMMITTED_EMPTY):
877941
# Server transaction was never started, no need to send a command.
878942
self._transaction.state = _TxnState.COMMITTED_EMPTY
943+
self._end_own_transaction_span()
879944
return
880945
elif state is _TxnState.ABORTED:
881946
raise InvalidOperation("Cannot call commitTransaction after calling abortTransaction")
882947
elif state is _TxnState.COMMITTED:
883948
# We're explicitly retrying the commit, move the state back to
884949
# "in progress" so that in_transaction returns true.
885950
self._transaction.state = _TxnState.IN_PROGRESS
951+
# A direct-API retry needs a fresh span: the prior attempt's
952+
# finally block already ended and cleared it. with_transaction
953+
# pins its shared span instead, see _end_own_transaction_span.
954+
if self._transaction.span is None and _otel._is_tracing_enabled(
955+
self._transaction.client.options.tracing
956+
):
957+
self._transaction.span = _otel.start_transaction_span(
958+
self._transaction.client.options.tracing
959+
)
886960

887961
try:
888962
self._finish_transaction_with_retry("commitTransaction")
@@ -906,6 +980,7 @@ def commit_transaction(self) -> None:
906980
_reraise_with_unknown_commit(exc)
907981
finally:
908982
self._transaction.state = _TxnState.COMMITTED
983+
self._end_own_transaction_span()
909984

910985
def abort_transaction(self) -> None:
911986
"""Abort a multi-statement transaction.
@@ -920,6 +995,7 @@ def abort_transaction(self) -> None:
920995
elif state is _TxnState.STARTING:
921996
# Server transaction was never started, no need to send a command.
922997
self._transaction.state = _TxnState.ABORTED
998+
self._end_own_transaction_span()
923999
return
9241000
elif state is _TxnState.ABORTED:
9251001
raise InvalidOperation("Cannot call abortTransaction twice")
@@ -933,6 +1009,7 @@ def abort_transaction(self) -> None:
9331009
pass
9341010
finally:
9351011
self._transaction.state = _TxnState.ABORTED
1012+
self._end_own_transaction_span()
9361013
self._unpin()
9371014

9381015
def _finish_transaction_with_retry(self, command_name: str) -> dict[str, Any]:

0 commit comments

Comments
 (0)