Exception Observers#
The conda_exception_observers plugin hook allows plugins to observe
exceptions as they pass through conda's error-reporting path. This is
useful for telemetry, logging, and demand tracking -- for example,
reporting which packages users tried to install but could not find in
the configured channels.
Exception observer plugins are purely observational (modelled after
CPython's sys.excepthook()): they cannot suppress, modify, or
redirect the exception. Their return value is ignored. Any exception
raised inside an observer is caught and logged at DEBUG level, so a
buggy plugin can never disrupt conda.
Observers fire for every exception conda handles: CondaError and
its subclasses, MemoryError, KeyboardInterrupt, SystemExit,
and plain Python exceptions such as RuntimeError or KeyError
that conda would otherwise report as an unexpected error. The
watch_for parameter selects which of those trigger a given observer;
see Choosing a watch_for scope below.
Tutorial: reporting missing packages to channels#
Suppose you maintain a private conda channel and want to know which
packages your users are looking for but cannot find. You can write a
small plugin that fires whenever a
PackagesNotFoundInChannelsError is raised
and sends a lightweight report to each channel.
Step 1 -- write the hook#
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from conda import plugins
if TYPE_CHECKING:
from conda.plugins.types import CondaExceptionEvent
log = logging.getLogger(__name__)
def report_missing(event: CondaExceptionEvent) -> None:
"""
Send a fire-and-forget GET request to each channel's ``/missing``
endpoint so that channel maintainers can track demand.
Uses conda's built-in session so that proxy settings, SSL
configuration, and per-channel auth are picked up automatically.
"""
if event.offline or event.dry_run:
return
from conda.gateways.connection.session import get_session
exc = event.exc_value
specs = ",".join(str(s) for s in exc.packages)
for url in {u.rstrip("/") for u in exc.channel_urls}:
target = f"{url}/missing?specs={specs}"
try:
get_session(target).get(target, timeout=2)
except Exception:
log.debug("Failed to report to %s", target, exc_info=True)
@plugins.hookimpl
def conda_exception_observers():
yield plugins.types.CondaExceptionObserver(
name="missing-package-reporter",
hook=report_missing,
watch_for={"PackagesNotFoundInChannelsError"},
)
watch_for accepts a set of exception class names. Matching uses the
exception's full MRO, so parent class names automatically match
subclasses.
For non-CondaError exceptions, the conda runtime fields on
CondaExceptionEvent may be None (see
below).
Choosing a watch_for scope#
Pick the narrowest scope that covers the exceptions you care about:
|
Captures |
Typical use |
|---|---|---|
|
All conda errors and their subclasses (missing packages, solver failures, channel errors). |
Plugins focused on conda's own errors (channel demand tracking, solver analytics). |
|
One error class and its subclasses. |
Narrowly targeted integrations. |
|
Every standard exception, including non- |
Sensible default for error-tracking backends such as Sentry or Rollbar. |
|
Every exception, including |
Diagnostics or auditing where every exit path matters. |
|
Specific non- |
Narrowly targeted observers. |
|
Union of scopes. The observer fires when any class in the exception's MRO matches any entry in the set. |
Combining a domain scope with a specific non-conda type. |
Step 2 -- package and register#
Package the hook as a standard conda plugin using entry points. See
the Plugins Quick start for a full
walkthrough of pyproject.toml setup, entry-point configuration,
and installation.
Step 3 -- test it#
You can test the plugin without packaging it by registering the plugin class directly with the plugin manager:
import sys
from conda import CondaError
from conda.exceptions import PackagesNotFoundInChannelsError
from conda.plugins.manager import CondaPluginManager
class FakeReporter:
"""Collects exception events for assertions."""
def __init__(self):
self.calls = []
@conda.plugins.hookimpl
def conda_exception_observers(self):
yield conda.plugins.types.CondaExceptionObserver(
name="fake-reporter",
hook=self.calls.append,
watch_for={"PackagesNotFoundInChannelsError"},
)
pm = CondaPluginManager()
reporter = FakeReporter()
pm.register(reporter)
exc = PackagesNotFoundInChannelsError(
packages=["numpy"],
channel_urls=["https://repo.anaconda.com/pkgs/main"],
)
try:
raise exc
except CondaError:
_, exc_val, exc_tb = sys.exc_info()
pm.invoke_exception_observers(exc_val, exc_tb)
assert len(reporter.calls) == 1
event = reporter.calls[0]
assert event.exc_value is exc
assert event.exc_type is PackagesNotFoundInChannelsError
What the observer receives#
Observers are called with a single frozen
CondaExceptionEvent dataclass.
The exception triple is always populated:
Field |
Description |
|---|---|
|
The exception class. |
|
The exception instance. For |
|
The traceback object. |
The remaining fields describe the conda runtime state. They are
populated all-or-nothing: either the runtime was available and all
fields are set, or it wasn't and they're all None. Check
conda_version is not None to tell the two cases apart.
active_prefix is the one exception -- it can be None even
when the runtime is available (meaning no environment is active):
Field |
Description |
|---|---|
|
A frozen copy of |
|
The conda version string. |
|
The exit code conda will use for this error. |
|
The currently active conda environment prefix, or |
|
The prefix the command was operating on. |
|
The configured channel names at the time of error (canonical
names, e.g. |
|
The platform subdirectory (e.g., |
|
Whether conda is running in offline mode ( |
|
Whether conda is running in dry-run mode ( |
|
Whether conda is running in quiet mode ( |
|
Whether conda is running in JSON output mode ( |
Warning
Observers run synchronously. exc_traceback is released as soon as
dispatch returns, and keeping references to exc_value or
exc_traceback can create reference cycles that prevent garbage
collection. Capture or serialize any traceback data you need inside
the callback. Do not hand these objects off to a background thread
or deferred queue.
Design notes#
Observational only -- observers cannot change conda's behavior. This follows CPython's
sys.excepthook()model.All exception types -- dispatch happens at the top of
handle_exception(), before conda's own error-report dispatch, so observers see every exception regardless of how conda would classify it. Usewatch_forto control scope.Fault-tolerant -- any exception (including
SystemExit) raised by an observer is caught at theBaseExceptionlevel, logged, and swallowed.MRO-based matching --
watch_foris checked against every class in the exception's method resolution order, so parent class names automatically match subclasses.Frozen event object --
CondaExceptionEventis a frozen dataclass, preventing plugins from mutating shared state.Optional runtime fields -- conda-specific fields are
Nonewhen the runtime isn't initialized, following CPython's flat args pattern (threading.ExceptHookArgs,sys.UnraisableHookArgs).
API reference#
- class CondaExceptionEvent#
Structured exception event passed to exception observer plugin callbacks.
Frozen to prevent plugins from mutating exception state. Structured args follow the
threading.ExceptHookArgs/sys.UnraisableHookArgspattern for forward compatibility.The exception triple (
exc_type,exc_value,exc_traceback) is always populated. The remaining fields describe the conda runtime state and default toNonewhen the runtime isn't initialized (e.g.MemoryErrorduring early startup). Runtime fields are populated all-or-nothing: ifconda_versionis notNone, the runtime was available and all other fields are populated (active_prefixmay still beNonewhen no environment is active).Warning
Do not store references to
exc_valueorexc_tracebackbeyond the lifetime of the callback. This can create reference cycles and prevent garbage collection.- Parameters:
exc_type -- The exception class.
exc_value -- The exception instance.
exc_traceback -- The traceback object.
argv -- The command-line arguments at the time of error (frozen copy of
sys.argv).Noneif unavailable.conda_version -- The conda version string.
Noneif unavailable.return_code -- The exit code conda will return for this error.
Noneif unavailable.active_prefix -- The currently active conda environment prefix, or
Noneif no environment is active (alsoNonewhen the runtime is unavailable).target_prefix -- The prefix the command was operating on.
channels -- The configured channel names at the time of error (canonical names, e.g.
defaults,conda-forge).subdir -- The platform subdirectory (e.g.,
linux-64,osx-arm64).offline -- Whether conda is running in offline mode (
--offline).dry_run -- Whether conda is running in dry-run mode (
--dry-run).quiet -- Whether conda is running in quiet mode (
--quiet).json -- Whether conda is running in JSON output mode (
--json).
- exc_traceback: types.TracebackType#
- exc_type: type[BaseException]#
- exc_value: BaseException#
- class CondaExceptionObserver#
Return type to use when defining a conda exception observer plugin hook.
Exception observers are purely observational, modelled after CPython's
sys.excepthook. They cannot suppress, modify, or redirect the exception. Their return value is ignored.For details on how this is used, see
conda_exception_observers().Warning
Do not store references to
exc_valueorexc_tracebackbeyond the lifetime of the callback. This can create reference cycles and prevent garbage collection.- Parameters:
name -- Observer name (e.g.,
missing-package-reporter).hook -- Callable invoked with a
CondaExceptionEventinstance. Must not raise; any exception is caught and logged.watch_for --
Set of exception class names this observer watches for. Matches against the full MRO. Examples:
{"BaseException"}— fires for every exception.{"Exception"}— all standard exceptions (excludesKeyboardInterrupt,SystemExit).{"CondaError"}— all conda errors and subclasses.{"PackagesNotFoundError"}— a specific error and its subclasses (e.g.PackagesNotFoundInChannelsError).{"MemoryError"},{"KeyboardInterrupt"},{"SystemExit"}— specific non-conda exceptions.{"CondaError", "MemoryError"}— combine scopes.
For non-
CondaErrorexceptions the conda-specific fields onCondaExceptionEventmay beNone.
- hook: collections.abc.Callable[[CondaExceptionEvent], None]#
- conda_exception_observers() collections.abc.Iterable[conda.plugins.types.CondaExceptionObserver]#
Register exception observer callbacks in conda.
Exception observers are invoked when any exception is handled by the
ExceptionHandler. They are purely observational: they cannot suppress, modify, or redirect the exception. Their return value is ignored. This follows the same model as CPython'ssys.excepthook.Any exception raised by an observer is caught at the
BaseExceptionlevel, logged at DEBUG, and swallowed -- a buggy plugin can never disrupt conda's error reporting path.Observers run synchronously on the error path. They must return promptly. Network I/O, large file operations, or any potentially blocking call should be deferred to a daemon thread or subprocess.
Observers receive a frozen
CondaExceptionEventdataclass. The exception triple (exc_type,exc_value,exc_traceback) is always populated. Conda runtime fields (argv,conda_version,return_code,active_prefix,target_prefix,channels,subdir,offline,dry_run,quiet,json) areNonewhen the runtime isn't initialized.watch_forcontrols which exceptions trigger the observer via MRO matching:{"CondaError"}catches all conda errors,{"BaseException"}catches everything,{"MemoryError"}catches only OOM, etc. SeeCondaExceptionObserver.Example:
from conda import plugins def report_missing(event): print(f"Missing packages: {event.exc_value.packages}") print(f"Command was: {' '.join(event.argv)}") @plugins.hookimpl def conda_exception_observers(): yield plugins.types.CondaExceptionObserver( name="missing-package-reporter", hook=report_missing, watch_for={"PackagesNotFoundInChannelsError"}, )
- Returns:
An iterable of
CondaExceptionObserverentries.