Skip to main content

Spoiler

Spoiler reads session recordings and reports what users tried, how it ended, and what blocked them.

Code measures what happened. A model explains it. Code checks the explanation.

  • In: any rrweb recording, from a file or fetched from PostHog. Web, native iOS and Android.
  • Out: versioned JSON: a deterministic trace, and an analysis that cites it.
  • Model: any OpenRouter model, one call per visit. Compiling needs no model and no network.

Example

An admin invites a teammate, but every seat is taken. From the 22-second recording, Spoiler reports:

Task: Invite priya@example.com to the workspace. Outcome: workaround.

Send invite was refused three times because all 5 seats were in use. Deactivating Ben Ortiz did not free his seat. Removing him did.

Hypothesis: the message doesn't say that deactivated members still hold seats.

The why comes from the vocabulary term seat, drafted from members.server.ts:4. It reads: "Every member holds a seat until removed, deactivated members included."

Each claim cites refs into the trace, which code compiles from the recording with no model:

ref t_s target effect flags
e2 1.0 input[invite-email] "name@company.com" typed "priya@example.com"
e3 3.0 button[send-invite] "Send invite" net 402 /api/invites 170ms; +alert "Couldn't send invite: all 5 seats are i…" error_after,error_shown
e5 6.5 button[send-invite] "Send invite" net 402 /api/invites 170ms; +alert "Couldn't send invite: all 5 seats are i…" error_after,error_shown
e7 10.6 button[deactivate-member] "Deactivate" in cell[Actions] "Ben Ortiz" req /api/members/m-ben/deactivate 200 150ms; cell Status "Ben Ortiz": "Active" → "Deactivated"; cell Actions "Ben Ortiz": "Deactivate Remove" → "Remove"
e8 13.1 button[send-invite] "Send invite" net 402 /api/invites 170ms; +alert "Couldn't send invite: all 5 seats are i…" error_after,error_shown
e11 19.6 button[confirm-remove] "Remove" -dialog "Remove Ben Ortiz?"; req /api/members/m-ben 200 160ms; -row "Ben Ortiz"
e12 22.1 button[send-invite] "Send invite" req /api/invites 201 180ms; +status "Invitation sent to priya@example.com"

The answer also claimed "Rage-clicked Send invite after the first refusal", citing e3 and e5. Code dropped it: the clicks were 3.5 s apart, and neither carries a rage flag.

The session is synthetic and this narration is hand-written. With --model, a model writes it.

How it works

source files ─▶ vocab build ─▶ vocabulary ─┐
                (model, per release)       ├─▶ compile ─▶ trace ─▶ analyze ─▶ analysis
recording ─────────────────────────────────┘   (code only)         (model, then code)
  1. Vocabulary. vocab build has a model read source files you name. It drafts surfaces, controls, domain terms and rules, each citing its source line. What the sources don't cover is listed under gaps, not guessed. The snapshot pins every source by SHA-256 and is never regenerated implicitly. --candidate packages a vocabulary you wrote instead, with no model call.
  2. Compile. compile replays the recording's DOM log, one mirror per tab. Each click resolves to an element, a vocabulary feature, what changed, and how fast. Rules raise flags: dead, unresponsive, rage, slow, error_after, error_shown, thrash. The same recording, vocabulary and compiler version always give the same trace. The trace also carries a timeline: when the user was there (posthog-js's own activity rule), which tab was in front, the gaps nobody acted in, and per tab how much of the page the recording could show (exact, blind before its first snapshot, dropped while the recorder was idle or paused, stale until it caught up). Players and narration read it instead of re-deriving it.
  3. Narrate. analyze sends the trace as TSV, plus the vocabulary it touched. The recording itself is never sent. run makes one call per visit. The model returns tasks with a goal, outcome, obstacle and friction, all citing refs. Outcomes are done, workaround, gave_up or unclear.
  4. Gate. Code checks every answer before accepting it:
    • Off-schema, or over 15% of cited refs missing: rejected. A live call gets one corrected retry.
    • dead_click, rage_click, error and slow friction is dropped without a matching flag.
    • Timestamps, durations, paths and data changes are written from the trace, never by the model.
    • check lists everything dropped, unexplained, or uncited.

Install

curl -fsSL https://spoiler.sh/install | sh
  • Installs the binary for your OS and CPU to ~/.local/bin, after checking its sha256.
  • Linux gets the static musl build, which runs on any distribution.
  • … | SPOILER_VERSION=v0.1.0 sh pins a release. SPOILER_INSTALL_DIR picks the directory.

From PyPI. The wheel only puts the spoiler binary on PATH; there is no Python API.

pip install spoiler

uv tool install spoiler works the same way. With Rust 1.88+, from crates.io:

cargo install spoiler --locked

Releases has an archive per target, each with a .sha256:

  • aarch64-apple-darwin, x86_64-apple-darwin
  • x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu (glibc 2.28+)
  • x86_64-unknown-linux-musl, aarch64-unknown-linux-musl (static; any Linux)

From a checkout: cargo install --path crates/cli --locked.

Quick start

Offline, from a checkout, using committed fixtures. No account or API key.

Build, and put the binary on PATH:

cargo build --release --locked
export PATH="$PWD/target/release:$PATH"
mkdir -p artifacts

Compile a recording into a trace. No model, no network:

spoiler compile \
  --recording corpus/click_changes_text.json \
  --vocab corpus/vocabulary.yaml \
  --app demo \
  --out artifacts/trace.json

Read the trace the way a model does:

jq -r .tsv artifacts/trace.json

Validate a stored answer against the trace:

spoiler analyze \
  --trace artifacts/trace.json \
  --vocab corpus/vocabulary.yaml \
  --response examples/click_changes_text.response.json \
  --out artifacts/analysis.json

Swap --response FILE for --prepare-only to write the exact model request instead. Neither sends anything.

On your product

Set credentials, which Spoiler reads only from the environment, and two variables used below:

export POSTHOG_API_KEY=phx_…
export OPENROUTER_API_KEY=sk-or-…
MODEL=…       # any OpenRouter model id
SESSION_ID=…  # a PostHog recording id

Describe each app in product.json. project is its PostHog project id:

{
  "apps": {
    "web": { "project": 123, "host": "app.example.com", "audience": "workspace admins" }
  }
}

Build a vocabulary from the source files that name your routes and controls:

spoiler vocab build \
  --config product.json \
  --source app/routes.ts \
  --source app/routes/members.tsx \
  --source app/members.server.ts \
  --source-revision "$(git rev-parse HEAD)" \
  --model "$MODEL" \
  --out vocab.json

Check it, and review the drafted matchers before relying on them:

spoiler vocab check --vocab vocab.json

Fetch, compile and narrate one PostHog session:

spoiler run \
  --project 123 \
  --session "$SESSION_ID" \
  --vocab vocab.json \
  --app web \
  --model "$MODEL" \
  --out session.json
  • run narrates every visit with user gestures. --visit N picks one.
  • Rebuild the vocabulary when you ship. Each trace records the digest it was compiled against.
  • After upgrading spoiler, rerun with --previous session.json: a visit whose model request is unchanged reuses its analysis ("via": "reused", or "regated" when a newer gate judged the stored answer again), so only changed questions reach the model. With --prepare-only, what is left as a request is exactly what would be paid for.

Commands

Command Reads → writes Network
run recording or PostHog session → session (trace + per-visit analyses) PostHog with --session, OpenRouter with --model
compile recording + vocabulary → trace none
analyze trace → analysis_request or analysis OpenRouter with --model
vocab build product config + sources → vocabulary_snapshot OpenRouter, unless --candidate
vocab check vocabulary (+ --extract) → vocabulary_check none
vocab extract app source → vocabulary_extract (routes, visible literals, tracked events) none
decode recording file → normalized recording none
recordings list, fetch PostHog project → recording_page, recording PostHog
versions → the artifact shapes, compiler, gate and prompt this build writes none
  • One JSON artifact per command, to stdout or --out (written atomically).
  • Any input path accepts - for stdin, so commands pipe.
  • Configuration is flags only. Credentials come only from the environment.
  • No model is ever chosen for you.
  • spoiler <command> --help lists every flag.

Each stage also runs alone, and they pipe:

spoiler recordings fetch --project 123 --session "$SESSION_ID" \
  | spoiler compile --recording - --vocab vocab.json --app web \
  | spoiler analyze --trace - --vocab vocab.json --prepare-only

Failures are JSON on stderr: {"error": "…", "retryable": false}.

Exit Meaning
0 Success
1 Invalid input or configuration, or a permanent upstream failure
2 Invalid invocation
75 Transient upstream failure: 429, 5xx, timeout, or connection error

Reference

Vocabulary

version: 1
apps:
  workspace: { project: 1, host: app.test, audience: "workspace admins" }
surfaces:
  - { id: workspace.members, app: workspace, route: /settings/members, name: "Members" }
features:
  - id: members.invite.send
    surface: workspace.members
    name: "Send invite"
    matchers: { testid: [send-invite] }
    source: "members.tsx:11"
terms:
  - term: seat
    means: "Every member holds a seat until removed, deactivated members included."
    source: "members.server.ts:4"
statuses:
  - { kind: member, value: deactivated, label: Deactivated }
gaps: ["Billing page: billing.tsx is not among the sources, so seat purchases are unnamed."]
  • Matchers, most specific first: testid, data_attr, aria, title, placeholder, href, text, role, class_contains. aria, text and title also take *_template forms.
  • surface: "*" marks app chrome and requires app.
  • Optional: statuses, grid (row identity), telemetry (URLs to ignore), error_text, thresholds.
  • Defaults: English error patterns, common monitoring requests ignored, slow_ms: 1000.
  • vocab check reports invalid matchers and inert entries.
  • vocab extract --app web --routes app/routes reads a React Router flat-routes app with a parser: every route (and whether it is a page), every literal a person sees or a matcher keys on (aria-label, placeholder, title, data-testid, link targets, text, label props), with file:line and the routes that render it, through imports, tsconfig paths and workspace packages. The file has one record per line, so it diffs well when committed.
  • vocab check --extract extract.json [--strict] reports pages without a surface, surfaces without a route, matcher values no source writes, citations to files no route imports, and declared events no source sends. --strict exits 1 when there are any: a CI gate.

Recordings

  • Accepted: rrweb event arrays, JSONL, compressed input, and PostHog snapshot lines.
  • Native iOS and Android: taps resolve against PostHog wireframes. Native routes are screen names such as SettingsScreen, matched case-sensitively.
  • Screenshot-mode frames, including Flutter and React Native, are opaque images. A tap on one is reported as screen (x,y), with no invented control.
  • A visit ends after 30 minutes without actions (thresholds.visit_gap_ms).

PostHog

  • recordings list returns one page. Pass its next_cursor back as --cursor.
  • It lists recordings that started over 24 hours ago and ended by --until. Use settled windows, and overlap consecutive syncs.
  • Segments more than 7 days outside the window can make a recording look complete.
  • The default host is https://eu.posthog.com. Set --host for US or self-hosted.
  • Listings and snapshot ranges share PostHog's per-key throttle (paid: 60/min, 300/h). A paid key fetches about 75 typical recordings an hour.
  • On 429, fetch honours Retry-After for up to --max-wait seconds (60), then exits 75.
  • --max-requests caps requests per fetch (default 50, at most 59).

Data and security

  • Recordings are untrusted input. --max-input-mib (512) bounds decoded size. HogQL and model responses are capped at 32 MiB.
  • Recordings are sensitive. Review what you send to PostHog and OpenRouter.
  • Model calls request zero-data-retention routing. Each analysis records model, tokens and cost.
  • Credentials travel only over HTTPS, and redirects are disabled.
  • Report vulnerabilities privately through GitHub security advisories.

Limits

  • The DOM mirror can't see iframe documents, canvas pixels, or shadow-root internals. Events from unrecognized rrweb plugins are not interpreted. coverage counts all of these.
  • rrweb records no key presses, so a keyboard-shortcut change has no gesture.
  • Masked inputs reveal only their length.
  • Tests cover the compiler and the gate, not narration quality or live PostHog behaviour.

Development

cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
  • corpus/ holds synthetic recordings with golden traces. They define compiler behaviour.
  • Edit cases in scripts/corpus.py, then run python3 scripts/corpus.py.
  • Accept an intended output change with SPOILER_BLESS=1 cargo test --test corpus. Review every golden diff.
  • A compile-rule change bumps COMPILER_VERSION. Readers reject traces from other versions.
  • Fixtures stay synthetic: no real recordings, credentials, or customer data.
  • Releasing is documented at the top of .github/workflows/release.yml.

License

Apache-2.0

Metadata

Release files for spoiler 0.1.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spoiler 0.1.2
File Size Uploaded
spoiler-0.1.2.tar.gz 204.5 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for spoiler 0.1.2
File
spoiler-0.1.2-py3-none-musllinux_1_2_x86_64.whl Python 3 none Linux musl 1.2+ x86-64 Details
spoiler-0.1.2-py3-none-musllinux_1_2_aarch64.whl Python 3 none Linux musl 1.2+ ARM64 Details
spoiler-0.1.2-py3-none-manylinux_2_28_x86_64.whl Python 3 none Linux glibc 2.28+ x86-64 Details
spoiler-0.1.2-py3-none-manylinux_2_28_aarch64.whl Python 3 none Linux glibc 2.28+ ARM64 Details
spoiler-0.1.2-py3-none-macosx_11_0_arm64.whl Python 3 none macOS 11.0+ ARM64 Details
spoiler-0.1.2-py3-none-macosx_10_12_x86_64.whl Python 3 none macOS 10.12+ x86-64 Details

Total release size: 24.9 MB

Release files / spoiler-0.1.2.tar.gz

Download URL spoiler-0.1.2.tar.gz
Size 204.5 kB
Tags Source
SHA-256 checksum
How to use checksums
60889166404842321837cadc8a7bcf9358dfa0efad5e330a1411a828adaa5307
BLAKE2b-256 checksum
How to use checksums
1ae1325b4f071b5173daa48ab29ba01faf00799a88ac620811df4f84e3031413
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release files / spoiler-0.1.2-py3-none-musllinux_1_2_x86_64.whl

Download URL spoiler-0.1.2-py3-none-musllinux_1_2_x86_64.whl
Size 4.4 MB
Tags Linux musl 1.2+ x86-64 Python 3
SHA-256 checksum
How to use checksums
f37ddc293dad456767c9a9466707173d17f5e50ba0f2c539580d84cf97a7482a
BLAKE2b-256 checksum
How to use checksums
1997a40de3ade8dd7669056a0a10c5ade43fafb3296a88f1c77312301f9e22cd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release files / spoiler-0.1.2-py3-none-musllinux_1_2_aarch64.whl

Download URL spoiler-0.1.2-py3-none-musllinux_1_2_aarch64.whl
Size 4.0 MB
Tags Linux musl 1.2+ ARM64 Python 3
SHA-256 checksum
How to use checksums
346395cf81671eab049bcbd8bd247dfb18869591b975a9c9cc2a6c952932dc90
BLAKE2b-256 checksum
How to use checksums
57e96b8277f8d5dd9c3cf8db0418b5f3bbb35a4b508ac7ac3c2168741a9d6df3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release files / spoiler-0.1.2-py3-none-manylinux_2_28_x86_64.whl

Download URL spoiler-0.1.2-py3-none-manylinux_2_28_x86_64.whl
Size 4.3 MB
Tags Linux glibc 2.28+ x86-64 Python 3
SHA-256 checksum
How to use checksums
4688c8e67e2e7257d25901c02939ac9e93591aaa5f16eef3bb09831c219cba7d
BLAKE2b-256 checksum
How to use checksums
c13a2b6e174c9f8e0e2fefea9c9b6f71782415b82d10a53c30b52bc5a933a15b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release files / spoiler-0.1.2-py3-none-manylinux_2_28_aarch64.whl

Download URL spoiler-0.1.2-py3-none-manylinux_2_28_aarch64.whl
Size 4.0 MB
Tags Linux glibc 2.28+ ARM64 Python 3
SHA-256 checksum
How to use checksums
054c8d7223c212e4df521f3a91e8059871a337776961e5ed96b843e168d8a35d
BLAKE2b-256 checksum
How to use checksums
d9d66fd7732df4863ffc8cdcfb945e7bcdbea3123b452c8093c5c76ebcfb324a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release files / spoiler-0.1.2-py3-none-macosx_11_0_arm64.whl

Download URL spoiler-0.1.2-py3-none-macosx_11_0_arm64.whl
Size 3.9 MB
Tags Python 3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
13f2172ad681a87c5b89768a7f46f5e2588ef04bc3b80a02f5416507ea5f68c2
BLAKE2b-256 checksum
How to use checksums
f172d48771aca4a3d760b8067cd32dc162cef2f5319eb538f92cd3cb4c1ae684
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release files / spoiler-0.1.2-py3-none-macosx_10_12_x86_64.whl

Download URL spoiler-0.1.2-py3-none-macosx_10_12_x86_64.whl
Size 4.1 MB
Tags Python 3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
b88144040a1a44a6f4d11f9d551d581b04c7c4b131183869ca3dd2e77ea73ff5
BLAKE2b-256 checksum
How to use checksums
2cf4388a30a05d5801c035b1c2248f8f63bd907e34cb372c1b74c7afa1d47e42
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.3

7 release files

This release

0.1.2 This release

7 release files

0.1.1

7 release files

0.1.0

7 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page