Skip to content
Console

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 │
└────────────────┘
  1. Open the project settings in the console and switch Auto-report on.
  2. Pick the triggers. Five are on by default; auto:network_4xx is not (see below).
  3. 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.

TriggerFires whenDefault
auto:uncaughtwindow.onerror — an exception nobody caughton
auto:unhandled_rejectiona rejected promise with no handleron
auto:console_errorconsole.error(...) was calledon
auto:network_5xxa response came back with status ≥ 500on
auto:network_failedthe request never completed: offline, CORS, abortedon
auto:network_4xxa response came back with status 400–499off

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.

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.

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 decides

espejo.autoReportActive tells you whether an error right now would upload anything — useful for checking your configuration without provoking an error.

An automatic report is a normal recording. In the API and over MCP it carries two extra fields:

  • report_source — manual or auto. Who asked for it.
  • trigger — manual, always, or one of the auto:* 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).

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:

  • https only, 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 at 10.0.0.5 today.
  • 5 second timeout. One attempt plus three retries with exponential backoff (1s, 2s, 4s). A 5xx, a timeout or a network error is retried; a 4xx from your endpoint is not, because it will say the same thing three more times.
  • Redirects are not followed. A 302 toward 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.

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, os
from 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 "", 200

Answer 2xx to acknowledge. Anything else is treated as a failure and retried (except 4xx, which is taken as a definitive no).

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.

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.

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.