On this page
Bug Capture — Share a Broken Transition¶
djust.bug_capture lets a developer encode the minimum information needed to reproduce a broken event transition — state_before, state_after, and the vdom_patches djust generated — into a single URL-safe string. A teammate (or a maintainer) decodes the string and sees exactly what the framework did with what state. No need to clone your repo; no template files to ship.
v1.1 status. Iter A (this page) ships the data shape, encoder/decoder, and PII-scrub hook. Iter B (#1562, shipped below) adds the read-only replay viewer at
/__djust__/replay/<blob>and the debug panel's Share button. Iter C (#1561) will add a Redis store for large payloads, adjust replayCLI, and a framework-leveltime_travel_excluded_fieldsclass attribute.
When to use this¶
The use case that motivated this feature: a downstream consumer files a bug like "VDOM diff appends new subtree instead of replacing when {% if %}/{% elif %} swaps between {% include %}d templates". The reproducer is locked behind hundreds of private template/view/model files. Without bug-capture the maintainer's options are:
- Ask for a sanitized minimal-repro (slow — hours to days for a complex template tree).
- Guess from the description (risky — the previous fix attempt got the root cause wrong precisely because it tested a synthetic shape, not the real one).
- Get screen-sharing access (expensive).
With bug-capture the reporter shares one URL fragment. The maintainer pastes the blob into the replay viewer or a local REPL and sees the real state + patches.
Quick start¶
In the reporter's local dev REPL — at the moment the broken transition happens:
from djust.bug_capture import encode_view_state, scrub_fields
# `my_view` is the LiveView instance with time_travel_enabled = True
# that just emitted the broken transition. Patches come straight from
# render_with_diff() — iter A intentionally does not couple to the
# render pipeline (iter B's debug-panel button will wire this up).
_html, patches, _version = my_view.render_with_diff()
blob = encode_view_state(
my_view,
patches=patches,
scrub=scrub_fields("password", "ssn", "credit_card"),
)
# blob is now a string like:
# "djbug1.eyJ2IjoiZGpidWcxIiwic3RhdGVfYmVmb3JlIjp7Li4ufX0..."
# Share this string with a teammate.
The maintainer pastes it back:
from djust.bug_capture import BugCapture
capture = BugCapture.decode(blob)
print(capture.event_name) # "next_step"
print(capture.state_before) # {"step": "claimant", "filing_for": "self", ...}
print(capture.state_after) # {"step": "vehicle", "filing_for": "self", ...}
print(capture.vdom_patches) # [{"op": "insert", "path": [0, 2], "html": "..."}, ...]
print(capture.scrubbed_fields) # ["password", "ssn"] — names only, never values
Or open the blob directly in a browser — see Browser-based replay below.
Security model¶
Read this before sharing any encoded blob.bug_capture is a power tool — used carelessly it leaks user PII.
Captured state may contain user PII¶
state_before and state_after are the view's public state at the moment of an event. That includes anything the developer assigned to public attributes: form values, model field contents, user IDs, search queries, multi-tenant context. The encoded blob is the same data, URL-safely transcoded. Treat the URL fragment as sensitive data. Don't paste it into shared bug trackers, Slack channels, or email without reviewing what's inside.
Always use the scrub hook for known-sensitive fields¶
# Built-in helper — removes named fields from state_before AND state_after
blob = capture.encode(scrub=scrub_fields("password", "ssn", "credit_card"))
# Or supply your own callable for arbitrary redaction policies:
def redact_emails(cap: BugCapture) -> BugCapture:
def mask(d):
return {k: ("<redacted>" if "@" in str(v) else v) for k, v in d.items()}
return BugCapture(
state_before=mask(cap.state_before),
state_after=mask(cap.state_after),
vdom_patches=cap.vdom_patches,
event_name=cap.event_name,
scrubbed_fields=cap.scrubbed_fields + ["<email-pattern>"],
)
blob = capture.encode(scrub=redact_emails)
The names of scrubbed fields are recorded on the wire (scrubbed_fields) so a reviewer reading the decoded capture knows what was held back. Values are not. Always scrub at the encoding boundary, never trust the recipient to scrub on receive.
The encoded blob is NOT authenticated¶
Anyone can hand-craft a syntactically-valid djbug1.<base64> payload. Consumers that decode a BugCapture and render it MUST treat the resulting state as untrusted input:
- Escape on render (don't innerHTML a captured string).
- Don't dispatch handlers against captured state.
- Don't let a captured tenant context cross your multi-tenant boundary.
Iter B's replay viewer is purely read-only for this reason. If you write your own consumer of BugCapture.decode(), apply the same defenses.
Default-off in production¶
BugCapture.encode() and encode_view_state() raise RuntimeError when settings.DEBUG is falsy. To opt in for production:
# settings.py — deliberate, ugly opt-in
DJUST_BUG_CAPTURE_PROD_OPT_IN = True
The opt-in must be the literal Python value True — truthy-but-not-True (e.g. the string "yes") is rejected. This is defensive against accidental-enable via config-loader workarounds. Decoding works regardless of DEBUG (a maintainer can paste a capture URL into any REPL and inspect it).
Wire format is JSON, never pickle¶
Encoded blobs are URL-safe base64 of compact JSON. The decoder validates types and rejects malformed input with a clear ValueError. A regression test pins not raw.startswith(b"\x80") so a future maintainer reaching for pickle for "efficiency" trips an immediate test failure.
API reference¶
BugCapture (dataclass)¶
@dataclass
class BugCapture:
state_before: dict
state_after: dict
vdom_patches: list[dict]
event_name: str = ""
scrubbed_fields: list[str] = []
state_before/state_after: the view's public state, JSON-safe.vdom_patches: list of patches as JSON-decoded dicts (already parsed from therender_with_diff()wire-format string).event_name: the handler that produced this transition (optional but recommended for context).scrubbed_fields: names of fields ascrubcallable removed during encoding. Names only, never values.
BugCapture.encode(scrub=None) -> str¶
Encode into a djbug1.<base64url> string. See the security model above. When a snapshot store is configured AND the payload exceeds the inline limit, returns djbug1.store.<opaque-id> instead — see Snapshot store for large captures. With no store configured (the default) the result is always the inline form, however large.
BugCapture.decode(blob: str) -> BugCapture¶
Decode a djbug1.<base64url> string. Raises ValueError on any malformed input (non-string, missing version prefix, unknown version, bad base64, bad JSON, missing required fields, wrong field types).
Also accepts djbug1.store.<opaque-id>, resolving the id through the configured store. Raises ValueError for a malformed id (checked before the store is consulted), an id the store cannot resolve, or an indirect blob when no store is configured.
encode_view_state(view, patches, event_name="", scrub=None) -> str¶
Convenience: pulls the most recent EventSnapshot from a view's time-travel buffer + the caller-supplied patches, builds a BugCapture, encodes it. Requires the view to have time_travel_enabled = True and at least one event captured.
patches is required and must be either the JSON string render_with_diff() returns or an already-decoded list of patch dicts. Why caller-supplied: iter A intentionally does not couple to the render pipeline — djust's render_with_diff() returns patches into the WebSocket / SSE / runtime frame paths without stashing them on the view, so there's no framework attribute to introspect. Iter B's debug-panel Share button (below) calls render_with_diff() + this function in one click.
Pass event_name=... to pick a specific past event rather than the latest.
scrub_fields(*names) -> Callable[[BugCapture], BugCapture]¶
Ready-made scrub callable. Removes each named field from both state_before and state_after. Absent fields are silently ignored. Field names removed (but values held back) are appended to scrubbed_fields for wire-visible transparency.
Browser-based replay (iter B)¶
GET /__djust__/replay/<blob> decodes a djbug1.<base64> blob and renders it as a page — no REPL required. Paste the blob a teammate sent you (or one your own debug panel produced, see below) straight into the URL bar:
http://localhost:8000/__djust__/replay/djbug1.eyJ2IjoiZGpidWcxIiwic3RhdGVfYmVmb3JlIjp7Li4ufX0
The page shows:
event_nameandscrubbed_fieldsheaders.state_before/state_afterside-by-side, plus a per-key diff table (added/removed/changed/same) so you don't have to eyeball two JSON blobs.vdom_patches, one block per patch (op+path+ payload), rendered as escaped text inside<pre><code>— captured HTML is untrusted, so it is never parsed as markup in the page itself.- A best-effort captured-HTML preview inside a fully
sandbox-ed<iframe srcdoc>(noallow-scripts, noallow-same-origin) when any patch carries anhtmlpayload — a rough visual sanity-check that can't execute script or touch the parent page. - A "Copy as
djbug1.URL" button to re-share the exact blob you're looking at.
Routing. The route lives in djust.urls — include it from your project's URLconf:
# urls.py
urlpatterns = [
path("", include("djust.urls")),
# ... your routes
]
DEBUG-gated at two layers.djust.urls.urlpatterns omits the route entirely when DEBUG=False (so a stray include("djust.urls") costs nothing in production), and the view itself re-checks the identical gate as defense in depth. Both read the same DJUST_BUG_CAPTURE_PROD_OPT_IN opt-in bug_capture._enforce_prod_gate() uses for encoding — "opted into bug-capture in prod" is one decision, not two:
DEBUG | DJUST_BUG_CAPTURE_PROD_OPT_IN | Result |
|---|---|---|
True | any | 200 for a valid blob |
False | not set / not literal True | 404 |
False | True | 200 for a valid blob |
A malformed blob returns 400 (as text/plain, never text/html, so a crafted blob can't get its error message parsed as markup by the browser).
Strictly read-only. The replay viewer is a plain Django view, not a LiveView — a deliberate choice: a LiveView mounts over a WebSocket and accepts event frames by design, and giving this route that machinery would mean actively disarming a dispatch surface a plain HTTP view never has in the first place. Nothing on the page can:
- Dispatch an event handler (
event_nameis display-only — never resolved to a callable). - Mutate any application state (no writes anywhere in the view).
- Scope a database query by the captured
tenant_id(the view issues zero database queries; a capturedtenant_idis shown like any other state key and nothing more).
Share button in the debug panel¶
When a view has time_travel_enabled = True and the app is running under DEBUG=True, the debug panel's Time Travel tab grows a "📋 Share bug" button next to the timeline header. Clicking it:
- Sends a
bug_capture_shareframe over the existing djust WebSocket. - The server calls
render_with_diff()(a side-effect-free re-diff, not a handler dispatch) to get the current patches, thenencode_view_state()with the configured default scrub, and replies with the resulting blob. - The client copies the blob to the clipboard via
navigator.clipboard.writeText()— the server never touches the clipboard itself.
Configure a default scrub list so the button never has to be told which fields to redact per-click:
# settings.py
LIVEVIEW_CONFIG = {
"bug_capture_default_scrub": ["password", "ssn", "credit_card"],
}
The Share button is dev-only plumbing on top of the same encode_view_state() documented above — it doesn't widen what can be captured or bypass the DEBUG / prod-opt-in gate on BugCapture.encode().
Snapshot store for large captures (iter C)¶
A capture of a busy view does not fit in a URL. Browsers, proxies and issue trackers all start truncating somewhere in the low kilobytes, and the failure is silent — the recipient gets a ValueError about malformed base64 and no idea why.
Configure a snapshot store and any payload over the inline limit travels by reference instead:
djbug1.store.hK3n-Qz8Xr2pLm4Ba9CdEf
# settings.py
LIVEVIEW_CONFIG = {
"bug_capture_store": {
"backend": "redis",
"url": "redis://:s3cret@redis.internal:6379/3",
"ttl": 3600, # seconds; the ONLY bound on a leaked id
},
}
bug_capture_store | Behaviour |
|---|---|
absent / None (default) | No store. Every blob is inline, exactly as in iter A/B. Nothing in djust.bug_capture_store is constructed. |
"memory" | InMemorySnapshotStore. Process-local — not shareable, and gone on reload. Dev and tests only. |
{"backend": "redis", "url": ..., "ttl": ..., "key_prefix": ..., "require_auth": ...} | RedisSnapshotStore. The shareable one. |
a SnapshotStore instance, or a dotted path to one | Your own backend. |
LIVEVIEW_CONFIG['bug_capture_inline_limit'] (default 1536) is the base64 length above which a payload goes to the store.
Why the default is no store rather than in-memory. An indirect blob is only as shareable as the store behind it. Defaulting to a process-local store would silently turn a blob you could paste to a teammate into a reference only your own dev server can resolve — a worse failure than a long URL, because it looks like it worked. A store is a deployment decision, so it is opt-in. A misconfigured store raises rather than falling back to an inline blob, for the same reason: if you asked for Redis, you should not quietly get process-local URLs.
The opaque id is a bearer capability¶
<opaque-id> is secrets.token_urlsafe(16) — 128 bits, not guessable. But anyone who obtains one can fetch the snapshot in full, from any client that can reach the store. There is no per-recipient authorization, no revocation, and no audit trail. That is what makes the blob shareable by paste, and it means:
- The TTL is the only bound on exposure. A leaked id — from browser history, a proxy log, a screenshot, a
Refererheader, a chat backlog — is live until it expires. Pick the shortest TTL your workflow tolerates; an hour is a default, not a recommendation. - The store changes where PII lives, never whether it is PII. Keep using
scrub/time_travel_excluded_fields, and keep the production opt-in off unless you mean it. - Ids are validated against the exact 22-character base64url shape before the store is consulted, so a crafted
djbug1.store.djust:session:abccannot turn the replay viewer into a reader for other keys in the same Redis. Key prefixing is a second, independent bound. - "Unknown id" and "expired id" produce one identical error, so a probe cannot confirm that a guessed id was ever valid.
Redis must actually require authentication¶
RedisSnapshotStore refuses by default to attach to a Redis that accepts unauthenticated clients:
UnauthenticatedRedisError: RedisSnapshotStore refuses to attach to
redis://<redacted>@10.0.0.4:6379/0: the server answered a PING from a
connection carrying NO credentials, so anyone who can route to it can read
every captured snapshot — which may contain user PII.
The check is not a config flag taken on trust, and it does not read the URL. It opens a second, credential-stripped connection to the same server and tries to run a command. A password in your URL proves only that you authenticated; it says nothing about whether the server demands credentials from anyone else — and redis://:@host, redis://host?password=x and redis://u:p%40ss@host can all point at a server with no requirepass at all. The only question that matters is what the server does with a client presenting nothing, so that is the question it asks. An inconclusive probe (host unreachable, timeout) refuses too — it fails closed.
If your connection is authenticated by a mechanism Redis itself cannot see — mutual TLS, a unix socket bounded by filesystem permissions — pass require_auth=False. It logs a warning naming the server every time. Do not reach for it merely because the URL has a password in it.
djust replay — the terminal path (iter C)¶
Not every capture is worth a browser. djust replay takes a djbug1. blob — or the whole replay URL a teammate pasted at you — and does one of three things:
# Open it in the local browser.
djust replay djbug1.eyJ2IjoiZGpidWcxIi...
# Print the decoded capture as ONE JSON document. Pipe it anywhere.
djust replay --inspect "$BLOB" | jq '.state_after.claimant_id'
djust replay --inspect "$BLOB" | jq '.vdom_patches | length'
# See what the transition actually changed.
djust replay --diff "$BLOB"
--- state_before
+++ state_after
@@ -1,5 +1,4 @@
{
- "count": 0,
- "gone": 1,
+ "count": 1,
"user": "ada"
}
--inspect emits a single JSON object (event_name, scrubbed_fields, state_before, state_after, vdom_patches), not three separate ones, so jq can consume it directly. --diff writes a unified diff to stdout; when the two states are identical it writes nothing to stdout and says so on stderr, so djust replay --diff "$BLOB" > patch still gives you a valid empty patch rather than a file with prose in it.
Where the URL points.--base-url, else $DJUST_REPLAY_BASE_URL, else http://127.0.0.1:8000. The path comes from your URLconf when one is available, so a project that mounts djust.urls under a prefix gets the right link:
export DJUST_REPLAY_BASE_URL=http://localhost:8002
djust replay "$BLOB"
The blob argument is validated before anything is opened. A blob reaches you by paste, so "run djust replay <this thing I sent you>" is a real way to get a URL opened on your machine. The argument must resolve to something starting with djbug1. or the command refuses; the URL handed to your browser is always one the command built itself. --base-url is restricted to http/https for the same reason.
Strategy connection¶
This feature lands as part of the v1.1.0 milestone after promotion from "Path D killer demo" status to load-bearing v1.1 capability. The promotion was triggered by the #1552 reporter's data point about upstream-bug-velocity friction — the reporter's own words: "the gap between 'I see it broken' and 'you can see it broken' is the full source tree." The v1.1 readiness session recommended Path E (defer the headline-path decision until launch-soak data exists, with the hedge "refuse to commit before data exists"); the #1552 filing supplied that data.