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 → vocabulary_check 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.

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

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.1
File Size Uploaded
spoiler-0.1.1.tar.gz 186.6 kB Details

Built distributions (wheels)

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

Total release size: 21.2 MB

Release files / spoiler-0.1.1.tar.gz

Download URL spoiler-0.1.1.tar.gz
Size 186.6 kB
Tags Source
SHA-256 checksum
How to use checksums
64a108038b0b75ea5aa9e3df5ac3669b4b449d6821681f348e815f4e5fe0c6d0
BLAKE2b-256 checksum
How to use checksums
3a41da44555ca9ebf99a21b4f2e33d03e37c8e8c64309be095f1cdd08f080110
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.1-py3-none-musllinux_1_2_x86_64.whl

Download URL spoiler-0.1.1-py3-none-musllinux_1_2_x86_64.whl
Size 3.7 MB
Tags Linux musl 1.2+ x86-64 Python 3
SHA-256 checksum
How to use checksums
c065421d6f9eb5f3f4736efe1c333db3fc6ac8c7291ee1f9eef53b589cc50aa8
BLAKE2b-256 checksum
How to use checksums
70c8b56d6f6e663be15bbd8be7014bc171603faf7b4251f45394d6d1338fec0a
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.1-py3-none-musllinux_1_2_aarch64.whl

Download URL spoiler-0.1.1-py3-none-musllinux_1_2_aarch64.whl
Size 3.4 MB
Tags Linux musl 1.2+ ARM64 Python 3
SHA-256 checksum
How to use checksums
3d1fea14f0f76f4cbfbbad465793b788b4f863f0a1e6e83aed7238cf568a38e5
BLAKE2b-256 checksum
How to use checksums
d6197ffcaba37848155c0a27ebffd80322cb907cddd4d3bb9299bc1f4095f9fa
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.1-py3-none-manylinux_2_28_x86_64.whl

Download URL spoiler-0.1.1-py3-none-manylinux_2_28_x86_64.whl
Size 3.6 MB
Tags Linux glibc 2.28+ x86-64 Python 3
SHA-256 checksum
How to use checksums
8ee9945f855bfc0d0d804b4ade294f2b50c198a55f73eea74d134d476ebdc3ab
BLAKE2b-256 checksum
How to use checksums
d4bf30ddedbf30e4c0416c9c9c466449eb611d6d14337402ecbfa03442a7624d
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.1-py3-none-manylinux_2_28_aarch64.whl

Download URL spoiler-0.1.1-py3-none-manylinux_2_28_aarch64.whl
Size 3.4 MB
Tags Linux glibc 2.28+ ARM64 Python 3
SHA-256 checksum
How to use checksums
3f7997f34ce4e2f877a4d5611954d4d576a3bdc92e895ffc8b63a38acc6abb61
BLAKE2b-256 checksum
How to use checksums
2d2c20c386e50868322b365903e6efb0463aedb67781e8221283a9835a93e41a
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.1-py3-none-macosx_11_0_arm64.whl

Download URL spoiler-0.1.1-py3-none-macosx_11_0_arm64.whl
Size 3.3 MB
Tags Python 3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
2469669d6fc8fbf44641397e0fece69231722e3549a25c590883c788fc4df691
BLAKE2b-256 checksum
How to use checksums
c739192df6b696bda86048f3c8a603002cd5ab5fe38cfc3b13588b63c60284d8
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.1-py3-none-macosx_10_12_x86_64.whl

Download URL spoiler-0.1.1-py3-none-macosx_10_12_x86_64.whl
Size 3.5 MB
Tags Python 3 macOS 10.12+ x86-64
SHA-256 checksum
How to use checksums
da227a43c738e7463acda8327af54f4a481390f19ee183d5a83ac7ebd4d87669
BLAKE2b-256 checksum
How to use checksums
933275f3366fe7aa87251f3893cf5be33e53c14b7b6e52d6420009d7d4afce71
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

0.1.2

7 release files

This release

0.1.1 This release

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