Skip to content

PYTHON-5947 Add OpenTelemetry operation spans - #2991

Open
blink1073 wants to merge 19 commits into
otelfrom
PYTHON-5947-otel-1-operations
Open

PYTHON-5947 Add OpenTelemetry operation spans#2991
blink1073 wants to merge 19 commits into
otelfrom
PYTHON-5947-otel-1-operations

Conversation

@blink1073

@blink1073 blink1073 commented Aug 17, 2026

Copy link
Copy Markdown
Member

PYTHON-5947

First of five PRs splitting #2964. Base is otel; review in order.

Branch Contents
1 PYTHON-5947-otel-1-operations operation spans (this PR)
2 PYTHON-5947-otel-2-transactions transaction spans
3 PYTHON-5947-otel-3-unified unified runner and vendored fixtures
4 PYTHON-5947-otel-4-getmore getMore spans
5 PYTHON-5947-otel-5-error-type error.type command span attribute

Changes in this PR

Adds one span per public API call, containing one command span per command sent to the server, per the OpenTelemetry driver specification. Without it a retried operation's command spans share no parent, so a reader cannot tell which attempts belong to the same call.

  • One operation span covers every retry attempt of a call, so retries show up as sibling command spans instead of collapsing into one.
  • killCursors, endSessions, and unacknowledged client bulk writes get operation spans too.
  • An operation that fails before anything reaches the wire still produces a conformant span, so a server selection timeout is reported rather than lost.
  • Cursor-creating calls (find, aggregate, listCollections, listIndexes) are covered, but only for the command that creates the cursor. The getMore spec has pending changes, so getMore spans are deferred to PR 4.
  • Fixed truncated output dropping falsy values (0, False, ""). This affected structured command logging as well as spans.
  • Background tasks no longer inherit a caller's span.
  • The otel Evergreen variant now covers all three topologies, but remains at 22 tasks.

Opt-in: no behavior change unless the tracing client option or OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED is set. With tracing off there is no added allocation per command.

Around 1,000 of the added lines are just synchro output and another ~745 are tests.

Test Plan

  • 49 new synchronized tests for span attributes and naming, retry and failure paths, sensitive-command redaction, and config handling, plus a regression test that enabling tracing through the environment variable does not trace background heartbeats.
  • Regression tests for the truncation bug and for collection names containing dots.
  • Locally against a replica set: 98 otel tests pass, along with the cursor, collection, common, json_util, and operation-id suites. just lint clean.

Checklist

Checklist for Author

  • Did you update the changelog (if necessary)?
  • Is there test coverage?
  • Is any followup work tracked in a JIRA ticket? If so, add link(s).

Checklist for Reviewer

  • Does the title of the PR reference a JIRA Ticket?
  • Do you fully understand the implementation? (Would you be comfortable explaining how this code works to someone else?)
  • Is all relevant documentation (README or docstring) updated?

@blink1073
blink1073 force-pushed the PYTHON-5947-otel-1-operations branch 3 times, most recently from 4a8db0c to ba21bb8 Compare August 17, 2026 23:09
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

@blink1073
blink1073 force-pushed the PYTHON-5947-otel-1-operations branch 12 times, most recently from 4e0c468 to 6638ae1 Compare August 18, 2026 12:55
Comment thread .github/workflows/test-python.yml Fixed
@semgrep-code-mongodb

Copy link
Copy Markdown

Semgrep found 2 github-actions-mutable-action-tag findings:

GitHub Actions step uses a mutable tag or branch reference. Tags and branch names can be silently repointed by the action owner, enabling supply-chain attacks — as seen in the trivy-action and kics-github-action compromises. Pin the reference to a full 40-character commit SHA instead, e.g. uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608.

🛟 Help? Slack #semgrep-help or go/semgrep-help.

Resolution Options:

  • Fix the code
  • Reply /fp $reason (if security gap doesn’t exist)
  • Reply /ar $reason (if gap is valid but intentional; add mitigations/monitoring)
  • Reply /other $reason (e.g., test-only)

@blink1073
blink1073 requested a review from NoahStapp August 18, 2026 17:24
@blink1073
blink1073 marked this pull request as ready for review August 18, 2026 17:24
@blink1073
blink1073 requested a review from a team as a code owner August 18, 2026 17:24

@NoahStapp NoahStapp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you run a micro benchmark measuring performance impact both when OTel is enabled as well as disabled?

Comment thread pymongo/asynchronous/mongo_client.py Outdated
:param is_run_command: If this is a runCommand operation, defaults to False.
:param is_aggregate_write: If this is a aggregate operation with a write, defaults to False.
:param operation_id: Stable operation id shared across retries, defaults to None
:param operation_telemetry: A caller-owned operation span outliving this call,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can the operation span outlive this call and this method own a fresh span? Not clear what that means.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I clarified that AsyncCursor._refresh is the intended user.

Comment thread pymongo/asynchronous/mongo_client.py Outdated
dbname: str,
collection: Optional[str] = None,
) -> _CommandCursor:
"""Run a command-cursor read within its own operation span.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Run a command-cursor read within its own operation span.
"""Run a command cursor read within its own operation span.

No hyphen between command and cursor.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied

Comment thread pymongo/asynchronous/mongo_client.py Outdated
Comment on lines +3012 to +3013
# One span covering every attempt. A caller needing it to outlive this
# object (a cursor) passes its own and keeps ownership.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self._owns_telemetry = True means that we create the span here, otherwise it's passed in because a cursor owns it? Comment doesn't make that clear.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified the comment

Comment thread pymongo/_otel.py Outdated
Comment on lines +78 to +80
``query_text_max_length`` is None as validated from user input; the options
a client holds have been through :func:`_resolve_tracing_options`, so both
fields are resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that query_text_max_length is always None? Not sure what this comment means.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None is to allow the env var to override when resolved. I created _UnresolvedTracingOptions to distinguish between the two states.

Comment thread pymongo/_otel.py Outdated
return f"{command_name} {dbname}"


# Some `_Op` values are the wire command name ("drop"/"create") rather than the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not rename _Op for consistency if it's entirely internal?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging wants the wire name, OTel wants the logical name (good times).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know the motivation behind that choice in the spec? Having two of our telemetry APIs record different names for the same operation seems very confusing for both us and users.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out I was confused as well, there are two different fields, db.operation.name and db.command.name. I made the comment more explicit.

Comment thread test/asynchronous/test_otel.py
Comment thread pymongo/_otel.py
# spec's db.operation.name ("dropCollection"/"createCollection"). Translate here
# instead of renaming `_Op`: `_WRITES_WITH_CLUSTER_TIME` in operations.py matches
# these exact strings to pick which writes get afterClusterTime.
_OPERATION_NAME_OVERRIDES = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need "rename -> renameCollection" here too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename is not one of the supported operations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that list prefaced with "including but not limited to the following operations"? Does that imply we should support rename if it's easy to do so?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Forgot to reply, updated to point to DRIVERS-3625 for the follow up work.

Comment thread pymongo/_otel.py
# built inside it. Before the sensitive-command return below, since the
# operation span needs those attributes even when the command gets no span.
current_operation = _CURRENT_OPERATION_NAME.get()
if current_operation is not None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this backfill run on each retry attempt too? If so, does it need to?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified the comment. Yes, repeating is necessary in case an attempt dies before the command is built, and it is idempotent.

Comment thread pymongo/asynchronous/cursor_base.py
Comment thread pymongo/asynchronous/client_bulk.py Outdated
Comment on lines +647 to +653
except BaseException as exc:
if operation_telemetry is not None:
operation_telemetry.failed(exc)
raise
else:
if operation_telemetry is not None:
operation_telemetry.succeeded()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this not use the with telemetry or contextlib.nullcontext() pattern used elsewhere instead? Same question twice in mongo_client.py too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, done

@blink1073

Copy link
Copy Markdown
Member Author

Can you run a micro benchmark measuring performance impact both when OTel is enabled as well as disabled?

I ran a test with find_one() on a spawn host with PyPy 3.9, Python 3.14, and Python 3.14t. When tracing is disabled the the op/s is not measurably affected. Adding tracing costs 32–48% of throughput across all variations of interpreter and sync/async.

Give every public API call an operation span containing one command span
per command sent to the server, per the OpenTelemetry driver specification.

The span covers all retry attempts of one _retry_internal call, so retries
appear as sibling command spans rather than being collapsed into one.
killCursors and endSessions bypass the retry layer and start their spans at
the call site instead, as does an unacknowledged client bulk write, which
never reaches the command-span code that would fill in its namespace.

Cursor-creating operations (find, aggregate, listCollections, listIndexes)
are covered here, but only for the command that creates the cursor. Spans
for caller-driven getMores come in a later change, as do transaction spans.

_otel.py also takes over the specification's naming and attribute rules, so
_telemetry.py deals only with span lifecycles and a specification change
need not touch it. Namespace parsing moves to helpers_shared._split_namespace.
Lead with the condition rather than the bare adjective.
The vendored unified spec fixtures it pointed at arrive in a later change, so
the note described tests absent from this one.
…d test

Evergreen only runs on PRs whose base is a configured branch, so the upper PRs
in this stack get no Evergreen coverage, and the otel tests only run in
Evergreen's otel variant. Add a temporary GitHub Actions job that runs them on
every branch in the stack, against a replica set so the transaction span tests
are included. Remove the job before merging.

Also drop test_operation_name_normalizes_enum_operation. All 22 vendored
fixtures assert db.operation.name with literal values, so the _Op formatting
regression it guards fails them loudly, and adding a test here only to delete
it once the fixtures land is churn.
…the end

The mutable-action-tag scanners reported two findings in test_minimum, which
this branch does not touch: inserting the otel job mid-file shifted those lines
by 31 and the diff-based scan attributed them to this change. Appending the job
instead leaves every pre-existing line where it was.

Pin the job's own actions to commit hashes so the newly added lines do not trip
the same rule. drivers-evergreen-tools has no pinned use elsewhere in this file,
so v1.0.1 is spelled out; its commit is the same one master points at, so the
topology input this job relies on is unchanged.
The parameter's description packed two mutually exclusive branches into
one clause, so it read as though the span both outlived the call and was
freshly owned by it. Split the two cases apart and name AsyncCursor as
the one caller that passes a span, along with why it keeps ownership.

Also drops the claim that a cursor's span is shared by its getMores,
which is not true yet: the cursor clears its span once the creating
command completes.
The unacknowledged path skips _retryable_write, which is what opens the
operation span on the acknowledged path, so open one at the call site. Matches
what the client-level bulk write already did, and both now use the telemetry
object as a context manager instead of hand-written try/except/else, as does
_retryable_read_cursor.

Split the tracing option TypedDict in two. As validated from user input,
query_text_max_length is None when unset, which is distinct from an explicit 0;
once _resolve_tracing_options has folded in the environment variables it is
always an int. Giving the two states separate types lets the resolved reader
stop re-checking for None.

Also record why the operation-name overrides exist: "dropCollection" and
"createCollection" are deviations the spec is expected to correct, and
"dropSearchIndexes" is only our `_Op` member being plural.
Several of these grew while answering review questions and ended up explaining
spec history or restating the code. Cut each to the invariant a reader needs.
@blink1073
blink1073 force-pushed the PYTHON-5947-otel-1-operations branch from e73f9f0 to a698db5 Compare August 21, 2026 15:59
Trimming took out four things that carry information: the AsyncCursor._refresh
pointer, the distinction between db.operation.name and db.command.name, that
start_command_span is a no-op with tracing off, and that the backfill has to
precede the sensitive-command return.
"Ambient" is not OpenTelemetry's term; the spec and the API both say current.
@blink1073
blink1073 requested a review from NoahStapp September 1, 2026 10:58
Comment thread .evergreen/scripts/generate_config.py Outdated
[
# All three topologies, subset to keep the task count at 22.
#
# Replica set in full: the only topology where transaction spans

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "in full" mean here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It meant the replica set variant isn't subsetted the way the other two are. Reworded to "Replica set keeps every task".

error on every failure path, and an exhausted cursor's close() ends it
on the way out; both are idempotent, so this only has to cover the
remaining case of a successful send that leaves the cursor open.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excessively long docstring here for an internal helper method. This also appears to be the only use of this method, can we just inline it instead?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trimmed the docstring. I kept the method rather than inlining it, because PR 4 gives it a second call site and an own_span parameter when getMores get their own spans, so inlining here would be reverted there.

Comment thread pymongo/asynchronous/mongo_client.py Outdated


T = TypeVar("T")
_CommandCursor = TypeVar("_CommandCursor", bound=AsyncCommandCursor[Any])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the typevar for? Can we just use AsyncCommandCursor[Any] in place of it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, removed

Comment thread pymongo/asynchronous/mongo_client.py Outdated
@@ -633,7 +640,10 @@ def __init__(
.. seealso:: The MongoDB documentation on `connections <https://dochub.mongodb.org/core/connections>`_.

.. versionchanged:: 4.18

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should all of these versionchanged notes for OTel be 4.XX to avoid missing them later?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread pymongo/asynchronous/mongo_client.py Outdated
:param is_run_command: If this is a runCommand operation, defaults to False.
:param is_aggregate_write: If this is a aggregate operation with a write, defaults to False.
:param operation_id: Stable operation id shared across retries, defaults to None
:param operation_telemetry: As for ``_retry_internal``, defaults to None.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "as for" mean here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to "Same as _retry_internal's". Do you think we should repeat the reasoning here?


The span ends with the command that created the cursor.
"""
operation_telemetry = _operation_telemetry_or_none(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to pass is_run_command here too.

@blink1073 blink1073 Sep 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment thread pymongo/asynchronous/cursor.py Outdated
operation_telemetry=self._operation_telemetry,
)
except OperationFailure as exc:
self._end_operation_telemetry(exc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to wait to call this until the tailable error check below that returns normally. Otherwise we'll emit an error span even if we don't raise an error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, I added test_tailable_rollover_is_not_an_error_span as a regression test

Comment thread pymongo/_otel.py Outdated
handle.span.end()
return
_CURRENT_OPERATION_NAME.reset(handle._name_token)
handle._cm.__exit__(None, None, None)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can any of these calls raise an error and interrupt this cleanup process?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrapped the recording calls in try/finally.

Comment thread pymongo/_otel.py Outdated
Comment on lines 384 to 392
if span is None:
return
span.record_exception(exc)
_set_exception_attributes(span, exc)
code = failure.get("code")
if code is not None:
span.set_attribute("db.response.status_code", str(code))
span.set_status(Status(StatusCode.ERROR, description=failure.get("errmsg")))
span.end()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can any of these calls raise an error and interrupt this cleanup process?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrapped this one in try/finally as well.

Comment thread pymongo/_otel.py
Comment on lines +305 to +310
summary = _build_query_summary(current_operation, dbname, collection)
current_span.update_name(summary)
current_span.set_attribute("db.namespace", dbname)
current_span.set_attribute("db.operation.summary", summary)
if collection:
current_span.set_attribute("db.collection.name", collection)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we detect if these are already set correctly and skip this if so to save the cost of these calls?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OTel API has no getter for a span's attributes, so detecting "already set" means tracking it ourselves in another contextvar. I don't think it is worth it.

Ends a tailable cursor's operation span successfully when a rollover returns
instead of raising, threads is_run_command into the command cursor span, and
ends every span in a finally so a failed attribute write cannot strand it.
Drops a typevar that carried no type information and renames
_retryable_read_cursor after what it adds.
@blink1073
blink1073 requested a review from NoahStapp September 1, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants