Skip to content

Envelope format

This is a public contract, not an implementation detail. Additive fields are a minor version; renaming or removing one is a major version. Build against it with confidence.

Every type here is exported from the package:

import type { Envelope, CrxEvent, LifecycleEvent } from "crxtrace";
POST <dsn>
content-type: application/json
x-crxtrace-sdk: crxtrace-js/<version>

Sent with credentials: "omit" and cache: "no-store". Bodies under 60 KB use keepalive, which lets the request outlive the worker that started it.

interface Envelope {
sdk: { name: string; version: string };
sentAt: number; // epoch ms
installId: string; // random, per-install
release?: string;
debugId?: string; // build id, for source map lookup (0.2.0+)
environment: string; // "production" | "development" | custom
extension: ExtensionInfo;
runtime: RuntimeInfo;
events: CrxEvent[];
sessions: SessionUpdate[];
lifecycle: LifecycleEvent[];
}
interface ExtensionInfo {
id?: string;
name?: string;
version?: string;
manifestVersion?: number;
}
interface RuntimeInfo {
browser: string;
browserVersion?: string;
platform?: string;
language?: string;
}

All three arrays may be empty, and an envelope with only lifecycle entries is normal — that’s a worker reporting a clean boot.

debugId is present only when the extension passed one to init(). It identifies the exact build, and is how a server selects the source maps to resolve this envelope’s stack frames — see Source maps. Absent means “this build has no uploaded maps”, which is different from an empty string.

interface CrxEvent {
eventId: string;
timestamp: number;
level: "fatal" | "error" | "warning" | "info";
surface: Surface;
mechanism: Mechanism;
handled: boolean;
type: string; // e.g. "TypeError"
message: string;
culprit?: string; // e.g. "app:///background.js:12"
stack?: StackFrame[];
rawStack?: string;
mv3Category: Mv3Category;
host?: string; // content scripts only
url?: string;
release?: string;
environment: string;
breadcrumbs: Breadcrumb[];
tags: Record<string, string>;
contexts: {
serviceWorker?: ServiceWorkerContext;
extra?: Record<string, unknown>;
};
user?: { id?: string; email?: string; username?: string };
fingerprint?: string[];
}
interface StackFrame {
function?: string;
file?: string;
line?: number;
column?: number;
inApp: boolean; // frame belongs to the extension, not a page script
}
interface Breadcrumb {
t: number;
category: string;
message: string;
level?: Level;
data?: Record<string, unknown>;
}
interface ServiceWorkerContext {
bootId: string;
uptimeMs: number;
coldStart: boolean;
pendingTasks?: string[]; // tasks in flight when this fired
previousUptimeMs?: number;
wakeReason?: string;
}

Surface is one of service_worker, content_script, popup, options, sidepanel, devtools, offscreen, extension_page, unknown.

Mechanism — how the error reached the SDK — is one of onerror, unhandledrejection, capture, wrap, last_error, console, sw_termination, relay.

Mv3Category is documented in MV3 error categories.

interface SessionUpdate {
bootId: string;
surface: Surface;
release?: string;
environment: string;
status: "ok" | "errored";
startedAt: number;
}

Crash-free rate per release is ok / (ok + errored), grouped by release.

interface LifecycleEvent {
kind: "boot" | "install" | "update" | "browser_update"
| "startup" | "suspend" | "terminated";
at: number;
bootId: string;
release?: string;
data?: Record<string, unknown>;
}

For kind: "terminated", data carries:

Field Meaning
hadPendingWork The one that matters. false is routine idle recycling; true is a real fault.
uptimeMs Lifetime of the worker that died
pendingTasks Names passed to track() that were still running
Status SDK behaviour
2xx Batch cleared from the durable queue
429 Retried with backoff, honouring Retry-After
5xx Retried with backoff
Other 4xx Dropped. A bad key or malformed body fails identically next time — retrying forever just burns the user’s battery.
Network failure Retried

Retry-After is parsed as either seconds or an HTTP date.

Answer 2xx only once you’ve durably stored the batch. The SDK deletes its copy on acknowledgement.

import { createServer } from "node:http";
createServer(async (req, res) => {
res.setHeader("access-control-allow-origin", "*");
res.setHeader("access-control-allow-headers", "content-type, x-crxtrace-sdk");
if (req.method === "OPTIONS") return res.writeHead(204).end();
const chunks = [];
for await (const c of req) chunks.push(c);
const envelope = JSON.parse(Buffer.concat(chunks).toString());
for (const event of envelope.events) {
console.log(event.type, event.message, event.mv3Category);
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
}).listen(8787);

A fuller version, including lifecycle handling, is examples/ingest-server.mjs.

See Self-hosting for storing and grouping.