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 inpyproject.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-mylibpackage, discovered via thewhytrail.explainersentry 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
Explainercallable'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.explainersentry-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.