Skip to content

Writing a whytrail plugin

A plugin teaches why() to explain a type it doesn't know about, or wires whytrail into a library's own hook system where one exists. There are two ways to build one, and they matter for a different reason each (ADR 0006):

  • Bundled -- add a module to src/whytrail/integrations/ in this repo, and an extra in pyproject.toml. This is how all 30 integrations below ship: one PyPI package (whytrail), pip install whytrail[X] pulls in the extra dependency, and (for explainer-shaped ones) why() picks it up automatically with zero further setup. Requires a PR against this repo.
  • External -- your own, separately published whytrail-mylib package, discovered via the whytrail.explainers entry point, reviewed by nobody but you. This is how the bundled 30 used to work, before ADR 0006 folded them into the core package for a simpler release process -- the mechanism itself wasn't removed, and it's still the right answer for an integration you want to own and release on your own schedule.

See src/whytrail/integrations/requests.py for a complete, tested reference implementation of the explainer shape, and src/whytrail/integrations/celery.py for the hook-based shape. Both read the same either way; only where the code lives and how it's registered differs.

Before writing one: read docs/adr/0003-ecosystem-scale-triage.md first. Most libraries don't need a plugin at all -- track()/@tracked already works on arbitrary objects via weakref and id()-based identity, no library-specific code required (this is exactly what the pandas integration found: it only earns its keep for the untracked-diagnostic case). A plugin is warranted when a library clears one of three bars: it carries structured error data a bare traceback throws away, it has a security-sensitive boundary needing safe defaults, or it needs a non-standard capture mechanism (signals, callbacks, hooks). If you can't point to which bar your idea clears, it probably shouldn't be a plugin.

python scripts/new_plugin.py <library> --kind explainer|integration scaffolds the boilerplate for an external plugin (pyproject.toml, package stub, README, starter test) -- it does not, and should not, generate the actual explainer logic; that judgment call is the point. For a bundled integration, there's no scaffold script: copy the shape of an existing module under src/whytrail/integrations/ closest to what you're building.

The shape of an explainer (registry-based plugins)

An explainer is a function (obj) -> Explanation | str | None:

from whytrail import Explanation, ExplanationStep, Confidence

def explain_my_type(obj: MyType) -> Explanation:
    return Explanation(
        subject=f"{obj!r}",
        steps=[
            ExplanationStep(
                description=f"created from {obj.source}",
                confidence=Confidence.EXPLICIT.value,
            ),
        ],
        tracked=True,
    )

Returning a plain str also works -- it gets wrapped in a single-step Explanation automatically. Returning None means "I don't actually know how to explain this one," and resolution falls through to the next strategy rather than treating it as an answer.

Never let your explainer raise. whytrail catches exceptions from explainers defensively, but a broken explainer still means the caller gets "unknown" instead of your plugin's actual explanation -- test it.

If any detail you're attaching could be sensitive -- a SQL parameter, a validation input, a task payload, anything that isn't safely public -- put it on ExplanationStep.locals (a dict[str, str]), not in description. That's the one thing Explanation.redacted() strips before an integration exports off-box (Sentry, OTel, a CI comment); text smashed into description can't be redacted after the fact. See the sqlalchemy integration (statement params) or pydantic (bad field values) for the pattern, and ADR 0002 §3 item 5 for why this matters.

Registering

Bundled (adding to this repo)

Add src/whytrail/integrations/mylib.py:

# src/whytrail/integrations/mylib.py
from ..registry import register_from_plugin

def register() -> None:
    register_from_plugin(MyType, explain_my_type)

Add mylib to registry._BUILTIN_EXPLAINERS in src/whytrail/registry.py -- that's the whole activation step. resolve_explainer() tries to import each name in that tuple lazily, once, the first time it needs to resolve an explainer it doesn't already have; a missing dependency just means ImportError, caught the same way a broken entry-point plugin's failure already is. Add a mylib = ["mylib>=X.Y"] extra to pyproject.toml so pip install whytrail[mylib] actually installs the library being explained.

External (your own separate package)

Add an entry point in your plugin's pyproject.toml:

[project.entry-points."whytrail.explainers"]
mylib = "whytrail_mylib:register"

And a zero-argument register() function that your entry point points to:

# whytrail_mylib/__init__.py
from whytrail.registry import register_from_plugin

def register() -> None:
    register_from_plugin(MyType, explain_my_type)

whytrail discovers and calls register() lazily, once, the first time it needs to resolve an explainer it doesn't already have -- your package is never imported at whytrail's import time, and whytrail is never a required dependency your users have to think about beyond pip install.

From a notebook or script

No package, no entry point needed, whether you're extending a bundled or external integration:

import whytrail
whytrail.register(MyType, explain_my_type)

A user's whytrail.register() call always wins over a plugin's register_from_plugin() for the same type, regardless of which runs first -- if someone wants to override a bundled or third-party explanation locally, they can, without forking anything.

Protocol version

The explainer contract above -- the (obj) -> Explanation | str | None shape, None/exceptions meaning "fall through, not an error," the MRO walk in resolve_explainer(), and register() always beating register_from_plugin() for the same type -- is frozen as whytrail.registry.EXPLAINER_PROTOCOL_VERSION = 1, tracked independently of whytrail's own package version (ADR 0002 §3 item 6). This matters for external plugins specifically: whytrail's release number moves for reasons that have nothing to do with the protocol (a new bundled integration, a CLI flag, a bug fix in Explanation.graph()), and an external plugin author shouldn't have to guess which whytrail releases are safe to depend on. They don't have to guess: protocol version 1 is a promise, not a moving target, and stays valid across whytrail's 0.x/1.x/ 2.x releases until something below actually needs to change.

Covered by v1, and won't change without a v2:

  • The Explainer callable's signature and return contract.
  • register(type_, explainer) semantics (manual, always wins).
  • register_from_plugin(type_, explainer) semantics (entry-point-driven, setdefault, never overrides a manual registration).
  • The whytrail.explainers entry-point group name and its zero-argument-register()-function calling convention.
  • MRO-based resolution order (own class first, then base classes).

Not covered -- these can change in a whytrail minor/major release without a protocol version bump, because no plugin should depend on them: Explanation's exact dataclass fields beyond what docs/plugin-guide.md already asks you to set, the internal shape of ProvenanceGraph, and anything under whytrail.core/whytrail.runtime not re-exported here.

If protocol v1 ever needs a breaking change, it becomes v2 with both resolvable simultaneously for a deprecation window -- the same non-negotiable a packaged plugin ecosystem needs from any host library, made explicit here instead of left implicit and then broken by accident.

The other shape: hook-based integrations

Some libraries don't have a type worth registering an explainer for -- they have their own lifecycle (a signal, a callback system, a middleware protocol), and the useful thing is calling whytrail.why() at the right point in it, not teaching why() about a new type. There's no auto-registration for this shape, bundled or external: the user imports the module and wires it in explicitly, the same way they already configure the library's other hooks:

# src/whytrail/integrations/mylib.py (bundled) or whytrail_mylib/__init__.py (external)
import whytrail

def install(*, log_locals: bool = False) -> None:
    @mylib.signals.something_failed.connect
    def _on_failure(sender, error, **kwargs):
        explanation = whytrail.why(error)
        logging.getLogger("whytrail.mylib").error(
            (explanation if log_locals else explanation.redacted()).text
        )

The celery integration (signals), pytest (pytest hooks, via the pytest11 entry-point group -- see its own module docstring for why that one's registration is unconditional, unlike every other bundled integration), and fastapi/django (exception middleware, with a safe-by-default redaction posture) are the reference implementations.

Naming

Bundled: the extra name matches the module name under src/whytrail/integrations/ (hyphenated in pyproject.toml for multi-word ones, e.g. google-cloud -> google_cloud.py).

External: convention is whytrail-<library>. Depend on whytrail and the library you're explaining; never the other way around.

Testing your plugin

Write tests against the real installed integration, not a mock of the registry or the hook. Every integration in tests/plugin_contract/ follows this pattern: install the extra (pip install -e ".[mylib]" for a bundled one), then exercise real instances of the type/hook you're explaining (a real sqlalchemy.exc.IntegrityError from an in-memory SQLite database, a real ClientError via botocore's Stubber, a real LangChain chain invocation) and assert on the result. If your plugin touches anything that could be sensitive, test the redaction default explicitly, not just the happy path -- see tests/plugin_contract/test_fastapi_plugin.py for the thoroughness bar a security-relevant integration needs to clear.

The integrations that exist today

33, all bundled (ADR 0006), growing toward 60 (see CHANGELOG.md for batch-by-batch progress). Each earns its place by clearing one of the three bars in ADR 0003, verified against real objects, not assumed from documentation -- several of these (marked *) were corrected after their own tests caught the library's own message text leaking a value that was supposed to be redacted. Not every popular library clears the bar: see "Checked and not built" below for candidates found, on inspection, to carry no structured data beyond what tier 1 already shows for free.

Extra Shape What it adds
requests explainer Response/RequestException detail (method, URL, status, body)
httpx explainer Same, for httpx.HTTPStatusError/RequestError
aiohttp explainer Same, for aiohttp.ClientResponseError/ClientConnectionError
huggingface-hub explainer HfHubHTTPError -- not covered by httpx (different base class)
openai explainer APIStatusError/APIConnectionError, redacted response body
anthropic explainer Same, for the anthropic SDK
boto3 explainer ClientError structured AWS error response
google-cloud explainer GoogleAPICallError -- one registration covers storage/bigquery/pubsub/etc.
sqlalchemy explainer StatementError statement + redacted params
asyncpg* explainer PostgresError sqlstate/constraint + redacted detail
pymongo* explainer PyMongoError code; message/details fully redacted (no safe driver string exists)
grpcio explainer RpcError status code + redacted .details()
pydantic explainer Per-field ValidationError breakdown, redacted bad values
marshmallow explainer Per-field ValidationError breakdown (nested schemas too)
jsonschema* explainer Path/validator; .message/.instance fully redacted
pyyaml* explainer Location; .problem/snippet fully redacted
pandas explainer Diagnostic for untracked DataFrame/Series; steps aside once tracked
polars explainer Same, for polars
stripe explainer StripeError code/param/http_status, redacted response body
alembic explainer ResolutionError/MultipleHeads -- the actual bad revision id / ambiguous heads
paramiko explainer BadHostKeyException as key fingerprints, never raw key material
sentry integration Attaches explanations to Sentry events via before_send
otel (core module, always bundled) integration Attaches explanations to the current OpenTelemetry span
ddtrace integration Same, for Datadog spans
celery integration Logs explanations (+ redacted task args) on task_failure
rq integration Same, via RQ's exception-handler chain
dramatiq integration Same, via dramatiq middleware
prefect integration Same, via Prefect's on_failure hook (no arg capture -- see module docstring)
scrapy integration Logs explanations on spider_error, with the URL being parsed
pytest integration Explanation section on failing test reports
fastapi integration Safe-by-default exception handler for FastAPI/Starlette
django integration Safe-by-default exception middleware for Django
flask integration Same, for Flask
langchain integration Chain/LLM/tool/retriever provenance via LangChain callbacks

† required weak=False on the signal connection -- the default weak reference let the handler closure get garbage-collected immediately after install() returned, so the signal silently reached zero receivers. Found by checking send_catch_log's return value in this plugin's own tests, not by reading pydispatch's docs.

Not built, with reasons, not silence: psycopg2 (needs a real PostgreSQL server -- its .pgcode/.pgerror are C-level read-only attributes that can't be populated outside a real connection, unlike asyncpg's equivalent fields); redis-py (checked directly: its exceptions carry no structured data beyond the message string, so there's nothing to add over tier 1); Playwright/Selenium (need browser binaries this environment doesn't have); Airflow (heavy transitive dependency footprint, deferred); LlamaIndex (architecturally identical to the langchain integration's already-proven callback pattern -- building it would mostly duplicate work, not test anything new); PyJWT and cryptography (checked directly, same reasoning as redis-py: their exceptions carry no structured fields beyond the message -- real value captured instead as gloss/fix-table entries for .plain_text, not a full plugin that would add code without adding information). See docs/adr/0003-ecosystem-scale-triage.md for the full reasoning and the much larger list of libraries that don't need a plugin at all.

On test coverage: every integration above is verified against a real object from the real library, including its redaction behavior where that applies -- not a mock, and several bugs (noted with * and †) were only caught because of that. Every one's stated minimum dependency version is also confirmed to actually install and pass its tests, on real ubuntu-latest CI, not assumed from a version number or a Windows-only local check -- that process alone found 20 real version-compatibility bugs across two rounds (see docs/testing-maturity.md for the full breakdown, and .github/workflows/ci.yml's plugin-version-matrix job for exactly which floor was corrected and why). That's a meaningfully higher bar than "the code looks correct," and it's still not the same claim as "battle-tested in every condition" -- docs/testing-maturity.md lists what's still open: the Python 3.10-3.12 range (only 3.13 has run this matrix so far), concurrency beyond the three web frameworks, and full exception-surface breadth per integration.