Skip to content

util-genai: record cancellation as a failure, not a success - #520

Open
ordemri wants to merge 1 commit into
open-telemetry:mainfrom
ordemri:fix/util-genai-cancellation
Open

util-genai: record cancellation as a failure, not a success#520
ordemri wants to merge 1 commit into
open-telemetry:mainfrom
ordemri:fix/util-genai-cancellation

Conversation

@ordemri

@ordemri ordemri commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Description

GenAIInvocation.__exit__ called fail() only when the in-flight exception derived from
Exception, so asyncio.CancelledError took the stop() branch: a cancelled operation was exported
with StatusCode.UNSET, no error.type, and a duration metric with no error dimension —
indistinguishable from one that completed. This guards on BaseException instead, matching the guard
the stream wrappers in stream.py already use.

fail() already accepts a BaseException, so nothing else changed. __exit__ still returns None
— suppressing CancelledError would break asyncio cancellation for every caller — and two tests
cover that, one of them cancelling a real task mid-await.

Known gaps

  • The wider guard also catches KeyboardInterrupt, SystemExit and GeneratorExit. GeneratorExit
    is not reachable from any shipped instrumentation today — no with <invocation>: block in this
    repository contains a yield — but it is covered by a test. Happy to narrow this; it is one line.
  • This fixes the context-manager path only. Packages that finalize in their own except Exception:
    handler are worse off than unfixed, not equal: on cancellation neither branch runs and the span
    is never ended, so nothing is exported at all. Happy to follow up separately.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How has this been tested?

New tests in util/opentelemetry-util-genai/tests/test_invocation_base_exception.py, written before
the fix and confirmed red against unmodified main: 6 failed, 342 passed — five with
AssertionError: <StatusCode.UNSET: 0> != <StatusCode.ERROR: 2>, one with KeyError: 'error.type'.
The file's other tests passed before and after, so it discriminates rather than failing wholesale.

  • tox -e py310-test-util-genai — 348 passed
  • tox -e py314-test-util-genai — 348 passed
  • tox -e py310-test-instrumentation-genai-agno-oldest — 20 passed
  • tox -e py310-test-instrumentation-genai-agno-latest — 20 passed
  • tox -e py314-test-instrumentation-genai-agno-latest — 20 passed
  • google-genai and smolagents oldest / latest — passed
  • Conformance environments not verified: they skip locally because the weaver binary is not
    installed, so no assertion ran. Relying on CI for those.

Checklist

  • Followed the style guidelines of this project
  • Changelog updated
  • Unit tests added
  • Documentation updated — not applicable

Copilot AI 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.

Pull request overview

This PR fixes util-genai’s invocation context-manager finalization so cancellations and other BaseException cases are recorded as failures (via fail()), preventing cancelled operations from being exported indistinguishably from successful ones.

Changes:

  • Update GenAIInvocation.__exit__ to treat any non-None exc_value: BaseException as failure, aligning behavior with existing stream wrapper guards.
  • Add new unit tests covering CancelledError, KeyboardInterrupt, SystemExit, GeneratorExit, and ensuring cancellation is not suppressed.
  • Add a towncrier changelog fragment describing the bug fix.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
util/opentelemetry-util-genai/src/opentelemetry/util/genai/_invocation.py Treat BaseException in __exit__ as failure to correctly mark cancellation/interruption as errors.
util/opentelemetry-util-genai/tests/test_invocation_base_exception.py New tests verifying __exit__ failure semantics for BaseException and that cancellation still propagates.
util/opentelemetry-util-genai/.changelog/PRNUMBER.fixed Changelog fragment documenting the cancellation-as-failure fix.
Suppressed comments (2)

util/opentelemetry-util-genai/tests/test_invocation_base_exception.py:162

  • test_cancellation_of_a_task_still_cancels_it can hang for up to 60s (or indefinitely on started.wait()) if cancellation doesn’t propagate as expected. Adding a short asyncio.wait_for timeout makes the test fail fast instead of stalling the suite.
            task = asyncio.ensure_future(work())
            await started.wait()
            task.cancel()
            with pytest.raises(asyncio.CancelledError):
                await task

util/opentelemetry-util-genai/tests/test_invocation_base_exception.py:119

  • This assertion hardcodes the CancelledError module path (asyncio.exceptions...), which can change between Python versions. Compute the expected error.type using fq_exception_type to avoid version-specific failures.
        self.assertEqual(
            span.attributes["error.type"], "asyncio.exceptions.CancelledError"
        )

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +107 to +111
task = asyncio.ensure_future(work())
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, applied. Both scenarios now bound the startup wait and the awaited
task with asyncio.wait_for on a shared 5s constant, at both sites.

The assertions are unchanged. I checked that wait_for is transparent here: when
the task is cancelled externally it still propagates CancelledError and
task.cancelled() is still true, on 3.10 through 3.14. I also checked the guard
can actually fire. With task.cancel() removed, the two tests fail in about 10s
total instead of stalling for two minutes.

Comment on lines +60 to +63
self.assertEqual(
self._only_span().attributes["error.type"],
"asyncio.exceptions.CancelledError",
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining this one.

Deriving the expected value with fq_exception_type, the same helper the code
under test uses to produce error.type, would make the assertion tautological. It
would pass even if fq_exception_type returned the wrong string, which is
precisely what the test exists to pin. The literal is the oracle.

On brittleness: I ran the string myself on CPython 3.10.21, 3.11.16, 3.12.2,
3.13.15 and 3.14.7, which cover every version in the package's classifiers. It is
asyncio.exceptions.CancelledError on all five. If it ever does change, this
assertion failing is the correct outcome, since error.type is an exported
attribute value and a change there is a change users would see.

@@ -0,0 +1 @@
Record cancellation as a failure: the invocation context manager finalized any non-`Exception` `BaseException` (`asyncio.CancelledError`, `KeyboardInterrupt`, `SystemExit`, `GeneratorExit`) through the success path, so cancelled operations were exported with an unset span status and no `error.type`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already fixed. The fragment is .changelog/520.fixed on the current head; this
review ran against the first push.

@ordemri
ordemri force-pushed the fix/util-genai-cancellation branch from 02d832e to 009e789 Compare September 1, 2026 08:17
@opentelemetry-pr-dashboard

opentelemetry-pr-dashboard Bot commented Sep 1, 2026

Copy link
Copy Markdown

Pull request dashboard status

Waiting on maintainers · refreshed 2026-09-02 14:52 UTC

Merge when ready.

Status above doesn't look right?
  • Just replied or pushed? Anything around or after the refresh time above may not be picked up yet — give it a few minutes.
  • Anything look wrong? Report it with what you expected; it helps us improve the dashboard.

@lmolkova lmolkova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you!

Comment on lines +230 to +237
# Guard on BaseException, not Exception: asyncio.CancelledError,
# KeyboardInterrupt, SystemExit and GeneratorExit do not derive from
# Exception, and treating them as success reports a cancelled or
# interrupted operation as a completed one. Matches the guard already
# used by the stream wrappers in ``stream.py``.
#
# Returning None (never a truthy value) is load-bearing: suppressing
# CancelledError here would break asyncio cancellation for every caller.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
# Guard on BaseException, not Exception: asyncio.CancelledError,
# KeyboardInterrupt, SystemExit and GeneratorExit do not derive from
# Exception, and treating them as success reports a cancelled or
# interrupted operation as a completed one. Matches the guard already
# used by the stream wrappers in ``stream.py``.
#
# Returning None (never a truthy value) is load-bearing: suppressing
# CancelledError here would break asyncio cancellation for every caller.

I think this code is self-explanatory

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, applied. Comment block removed.

@@ -0,0 +1,187 @@
# Copyright The OpenTelemetry Authors

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can you please write this test against the actual langgraph public API. I think it's an existing pattern, but this is very synthetic, does not show how it'd work in real life and we're trying to stop using it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Happy to. I went to write it and hit something I wanted to check with you first.

langgraph is instrumented through the langchain package, and that instrumentation
is callback-based: on_chain_end calls invocation.stop() and on_chain_error
calls invocation.fail(error) directly. It never uses the invocation as a context
manager, so a langgraph-driven test doesn't reach GenAIInvocation.__exit__, and
it passes the same with and without this fix. on_chain_error is already typed to
take a BaseException and hands it straight to fail().

The place a real-API cancellation test would land in __exit__ is agno:
_tool_call_aexecute awaits inside with _start_tool_invocation(...). Happy to
open that as a separate PR against agno.

Want me to add the langgraph test anyway, or go the agno route?

`GenAIInvocation.__exit__` finalized the invocation through `fail()` only
when the in-flight exception derived from `Exception`. `asyncio.CancelledError`,
`KeyboardInterrupt`, `SystemExit` and `GeneratorExit` derive from
`BaseException` but not from `Exception`, so they took the `stop()` branch: a
cancelled operation was exported with `StatusCode.UNSET`, no `error.type`, and
a duration metric carrying no error dimension.

Guard on `BaseException` instead, matching the guard the stream wrappers in
`stream.py` already use (`if exc_val is not None:`).

`fail()` already accepts a `BaseException` and routes it through
`Error.from_exception`, so no other change is needed; `error.type` resolves via
`fq_exception_type` and stays consistent with the exception event.

`__exit__` still returns `None`, so nothing is suppressed: suppressing
`CancelledError` would break asyncio cancellation for every caller. Tests cover
that explicitly, including a real cancelled task that must remain cancelled.

Assisted-by: Claude Opus 5
@ordemri
ordemri force-pushed the fix/util-genai-cancellation branch from 009e789 to 39fbbab Compare September 2, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants