Documentation

Bug Capture — Share a Broken Transition

Encode state_before + state_after + vdom_patches into a URL fragment a teammate can paste back to reproduce a broken djust transition without your codebase

Canonical documentation is on docs.djust.org

This local page is a lightweight reference and fallback. The complete guide — with tutorials, theming, code examples, and more — lives on our dedicated docs site.

View on docs.djust.org

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, a djust replay CLI, and a framework-level time_travel_excluded_fields class 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:

  1. Ask for a sanitized minimal-repro (slow — hours to days for a complex template tree).
  2. 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).
  3. 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 the render_with_diff() wire-format string).
  • event_name: the handler that produced this transition (optional but recommended for context).
  • scrubbed_fields: names of fields a scrub callable 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_name and scrubbed_fields headers.
  • state_before / state_after side-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> (no allow-scripts, no allow-same-origin) when any patch carries an html payload — 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:

DEBUGDJUST_BUG_CAPTURE_PROD_OPT_INResult
Trueany200 for a valid blob
Falsenot set / not literal True404
FalseTrue200 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_name is 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 captured tenant_id is 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:

  1. Sends a bug_capture_share frame over the existing djust WebSocket.
  2. The server calls render_with_diff() (a side-effect-free re-diff, not a handler dispatch) to get the current patches, then encode_view_state() with the configured default scrub, and replies with the resulting blob.
  3. 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_storeBehaviour
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 oneYour 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 Referer header, 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:abc cannot 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.