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.3

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.3
File Size Uploaded
spoiler-0.1.3.tar.gz 204.9 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for spoiler 0.1.3
File
spoiler-0.1.3-py3-none-musllinux_1_2_x86_64.whl Python 3 none Linux musl 1.2+ x86-64 Details
spoiler-0.1.3-py3-none-musllinux_1_2_aarch64.whl Python 3 none Linux musl 1.2+ ARM64 Details
spoiler-0.1.3-py3-none-manylinux_2_28_x86_64.whl Python 3 none Linux glibc 2.28+ x86-64 Details
spoiler-0.1.3-py3-none-manylinux_2_28_aarch64.whl Python 3 none Linux glibc 2.28+ ARM64 Details
spoiler-0.1.3-py3-none-macosx_11_0_arm64.whl Python 3 none macOS 11.0+ ARM64 Details
spoiler-0.1.3-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.3.tar.gz

Download URL spoiler-0.1.3.tar.gz
Size 204.9 kB
Tags Source
SHA-256 checksum
How to use checksums
254a8887729d691f9e599cc6c9104b9ec05466db2ea24369f5198144ca6e7aee
BLAKE2b-256 checksum
How to use checksums
65ff11bb617cff059f66db29a360b32a61425bd698d8e42b8d8fba35cfc70573
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.3-py3-none-musllinux_1_2_x86_64.whl

Download URL spoiler-0.1.3-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
e164bc4a2eb2f5bea5aaf45c0400fd26fa6e3523e4e84a0ae3eb24b7bfdeb5ab
BLAKE2b-256 checksum
How to use checksums
36ea3d17205a83082ca2daed8b01df806a9af85b420fba53f82b4ea927957469
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.3-py3-none-musllinux_1_2_aarch64.whl

Download URL spoiler-0.1.3-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
5c13921d2bc87543d0102c2be8774f52ed475448ec6dfda6bcc9edd2f2e77c86
BLAKE2b-256 checksum
How to use checksums
8ac6a429ca49e8144410052f42deed579a1aeef4af039f61ab77401e0c3096bd
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.3-py3-none-manylinux_2_28_x86_64.whl

Download URL spoiler-0.1.3-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
20907a681bed012e89a1065c00fd1ee0afcab10e3947049bd34ee7f4f858371d
BLAKE2b-256 checksum
How to use checksums
20b534094b59d72787e66ebf488896b8bc51b618c34f4c0a3d230e45bd04904b
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.3-py3-none-manylinux_2_28_aarch64.whl

Download URL spoiler-0.1.3-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
9fea7cb868e42c72897eb19fd68a1615c8c3aedc2df6acb15058f4758d2e1507
BLAKE2b-256 checksum
How to use checksums
3646d3ac4babd97cfb67847678d914335cc7cfb7cbed947ea5bfc7d51576f5c7
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.3-py3-none-macosx_11_0_arm64.whl

Download URL spoiler-0.1.3-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
4d5ae99a9046558bb87510f5c9bb865405cead66c641bce762e922a7dbda6eae
BLAKE2b-256 checksum
How to use checksums
9a3b674f7609115253b309213d4897aace50be9b150902c561a0e4a772d1ee16
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.3-py3-none-macosx_10_12_x86_64.whl

Download URL spoiler-0.1.3-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
4191e95b061806a1dfcf297d31f27170d341b3f7f062ec3d46c52dbfbebb0cb3
BLAKE2b-256 checksum
How to use checksums
7da8dcd02a2d8ad3bbb74debdf7afb89c92f6333bdf1ce3f161c2dc46c852432
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

This release

0.1.3 This release

7 release files

0.1.2

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