- Guides
- Automatic reports
Automatic reports
Bugs that report themselves: the six triggers, the storm guardrails, the server-side config the SDK obeys, and how to verify the HMAC signature.
The best bug report is the one nobody had to write. With automatic reports on, the SDK uploads its ring buffer the moment your app breaks — while the user is still on the page, and whether or not they ever tell you.
It is off by default on every project, and turning it on is a decision made
in the console, on the server. That is the whole design: your pk_live_... key
is public by design (it sits in your HTML, like a Sentry DSN), so if a script
attribute could enable uploads, anyone who copied the key could spend your quota.
your app the SDK Espejo API ┌────────────────┐ ┌──────────────────────────┐ ┌──────────────────────────┐ │ page loads ───▶│──▶│ GET /v1/ingest/config ──▶ │──▶│ project says on/off, │ │ │ │ (fails ⇒ stays OFF) │◀──│ triggers, caps │ │ throws a 500 ─▶│──▶│ trigger matches? │ │ │ │ │ │ storm guardrails pass? │ │ │ │ │ │ POST /v1/ingest/sessions ─│──▶│ session stored, │ │ │ │ report_source=auto │ │ report_source=auto │ └────────────────┘ └──────────────────────────┘ │ │ │ │ ▼ │ your receiver ◀──── signed POST ───────────────│ webhook (if enabled) │ ┌────────────────┐ X-Espejo-Signature └──────────────────────────┘ │ verify HMAC │ │ hand to agent │ └────────────────┘Turning it on
Section titled “Turning it on”- Open the project settings in the console and switch Auto-report on.
- Pick the triggers. Five are on by default;
auto:network_4xxis not (see below). - Optionally set up the webhook — a URL and a signing secret — so something
other than a human finds out. Without it, automatic reports simply show up in
your recordings list, tagged
Auto.
Nothing changes in your page. The <script> tag stays exactly as it was: the SDK
asks the server what is on every time a page loads.
The six triggers
Section titled “The six triggers”| Trigger | Fires when | Default |
|---|---|---|
auto:uncaught | window.onerror — an exception nobody caught | on |
auto:unhandled_rejection | a rejected promise with no handler | on |
auto:console_error | console.error(...) was called | on |
auto:network_5xx | a response came back with status ≥ 500 | on |
auto:network_failed | the request never completed: offline, CORS, aborted | on |
auto:network_4xx | a response came back with status 400–499 | off |
auto:network_4xx ships off because a 401 while revalidating a session and a 404
for a missing avatar are normal noise in most apps: with it on, one mistyped
login screen spends the whole per-page budget. It exists for teams whose 4xx
means something.
console.warn never triggers anything. It is still captured into the recording —
it just is not a bug.
Why a render loop can’t bankrupt you
Section titled “Why a render loop can’t bankrupt you”A component looping at 60fps throws 3,600 errors a minute. Three guardrails run in the browser, because that is where a request can be not made:
- A cap per page load. Three by default, configurable up to 20. This is the one that stops the pure loop.
- A cooldown. 60 seconds between two automatic reports by default. This stops the drip: one error every two seconds never reaches the cap but would still upload thirty recordings.
- De-duplication by error signature. The same message and stack never uploads twice within the page session. Without it, one noisy bug eats all three slots and the second, different bug never gets through.
A rejected attempt does not spend a slot — otherwise a drip would exhaust the cap without having uploaded anything.
On top of that, automatic reports draw on the project’s normal daily budget
(daily_session_budget and daily_byte_budget). There is no privileged channel,
so the worst case is bounded by a number you already chose, and the same
automatic pause protects it.
The kill switch
Section titled “The kill switch”Two ways to turn it off from the page. Neither can turn it on:
<script src="https://app.espejo.dev/sdk/espejo.js" data-key="pk_live_..." data-auto-report="off"></script>// For SPAs: silence a screen you know is noisy, then bring it back.espejo.setAutoReport(false);espejo.setAutoReport(true); // only lifts the local switch; the project still decidesespejo.autoReportActive tells you whether an error right now would upload
anything — useful for checking your configuration without provoking an error.
What lands in the recording
Section titled “What lands in the recording”An automatic report is a normal recording. In the API and over MCP it carries two extra fields:
report_source—manualorauto. Who asked for it.trigger—manual,always, or one of theauto:*values above. What happened.
In the console the row is tagged Auto and titled after its trigger (there is no
description, because nobody typed one).
The webhook
Section titled “The webhook”When a session with report_source=auto finishes ingesting, and the webhook is
enabled, Espejo sends a POST with this body:
{ "project_id": "b3f1c0a2-1d4e-4f8a-9c2b-7e5d0a1f3b6c", "project_slug": "acme-app", "session_id": "9f2c4a7b1e8d035face6b2470d18c9a5", "replay_url": "https://app.espejo.dev/s/9f2c4a7b1e8d035face6b2470d18c9a5", "trigger": "auto:network_5xx", "error_excerpt": "TypeError: cannot read properties of undefined (reading 'id')", "counts": { "errors_4xx": 0, "errors_5xx": 2, "console_errors": 1 }, "occurred_at": "2026-08-17T13:41:02.514Z"}What is not in there: request and response bodies, the full console, the DOM.
This JSON travels to a URL you typed and will end up in a Slack channel or an
agent’s log; the detail stays behind authentication, where replay_url and the
MCP tools can reach it.
Delivery rules:
httpsonly, and the host must not resolve to a private, loopback or link-local address. Checked when you save it and again before every send — a domain that was public yesterday can point at10.0.0.5today.- 5 second timeout. One attempt plus three retries with exponential backoff
(1s, 2s, 4s). A
5xx, a timeout or a network error is retried; a4xxfrom your endpoint is not, because it will say the same thing three more times. - Redirects are not followed. A
302toward an internal address would walk straight past the check above. - Nothing is ever sent unsigned. If the secret is missing, the delivery is skipped and the reason is recorded.
- The result of the last real delivery (test deliveries included) is shown on the project page. A webhook that fails silently is worse than none.
Verifying the signature
Section titled “Verifying the signature”The header is X-Espejo-Signature, formatted sha256=<hex>, and it is the
HMAC-SHA256 of the raw request body with your secret.
“Raw” is half the contract: compute the HMAC before parsing the JSON. If you parse and re-serialize, the smallest difference in key order or whitespace gives a different digest and every delivery looks forged.
Node / Express:
import express from "express";import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();const SECRET = process.env.ESPEJO_WEBHOOK_SECRET;
// `express.raw` and not `express.json`: we need the exact bytes that were signed.app.post("/espejo", express.raw({ type: "application/json" }), (req, res) => { const expected = "sha256=" + createHmac("sha256", SECRET).update(req.body).digest("hex"); const given = req.get("X-Espejo-Signature") ?? "";
// Compare in constant time, and check the length first — timingSafeEqual // throws on buffers of different sizes, and the length is not a secret. const a = Buffer.from(expected); const b = Buffer.from(given); if (a.length !== b.length || !timingSafeEqual(a, b)) { return res.status(401).send("bad signature"); }
const event = JSON.parse(req.body.toString("utf-8")); console.log(event.trigger, event.replay_url); res.sendStatus(200);});Python / Flask:
import hmac, hashlib, osfrom flask import Flask, request
app = Flask(__name__)SECRET = os.environ["ESPEJO_WEBHOOK_SECRET"].encode()
@app.post("/espejo")def espejo(): # request.get_data() is the raw body — do not use request.json here. expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest() given = request.headers.get("X-Espejo-Signature", "") if not hmac.compare_digest(expected, given): return "bad signature", 401
event = request.get_json() print(event["trigger"], event["replay_url"]) return "", 200Answer 2xx to acknowledge. Anything else is treated as a failure and retried
(except 4xx, which is taken as a definitive no).
Testing it
Section titled “Testing it”The Send test button on the project page sends a sample payload through the same path as a real delivery: same signature, same timeout, same retries, same URL validation, and it records the result. A test that used a separate path would only prove that the separate path works.
Pointing an agent at it
Section titled “Pointing an agent at it”A webhook that hands over a replay link, plus an MCP server that can read that recording — network with bodies, console, clicks, DOM — is the raw material for a self-healing pipeline. The pipeline is yours to build: Espejo records and reports, it does not repair.
Reading the config yourself
Section titled “Reading the config yourself”The endpoint the SDK calls is public and returns only what a browser needs:
GET /v1/ingest/config?key=pk_live_...{ "success": true, "data": { "enabled": true, "triggers": ["auto:uncaught", "auto:network_5xx"], "max_per_page": 3, "cooldown_ms": 60000 }}The key goes in the query string, not a header, so this stays a simple GET: a
custom header would add a CORS preflight to the start of every page load. The
response is cacheable for five minutes.
The webhook URL and its secret are never in this response, and cannot be:
this is readable by anyone holding the public key. When auto-report is off — or
the key is unknown or revoked, or the project is paused, or the account is
deactivated — the answer is the same enabled: false, so the endpoint cannot be
used to probe whether a key is valid.