Error Pipeline
A Sentry-equivalent loop built from three packages that share deliberate seams. Beacon captures uncaught errors, unhandled rejections, and breadcrumbs in the browser — ~2 KB gzipped, zero dependencies. Errors ingests the envelope server-side, fingerprints it, and upserts a durable, grouped issue; server-side exceptions enter the same store through capture(), an Effect that can never itself fail — errors-as-values. Replay records the DOM session in chunks, uploaded via a pluggable blob transport, and exposes the replayId that stamps every error — cross-linking each issue to the exact session around it.
#The End-to-End Path
One browser exception travels through every seam in the stack. The client side is a dumb producer of telemetry — it has no trust boundary — so the Effect/Schema rigor lives server-side in the ingest endpoint, which validates the untrusted POST body:
maxGroups (default 10,000) × maxSamplesPerGroup (default 10) — with no Redis; new groups beyond the cap are shed and counted in droppedGroups. On the tracker side, maxRecent (default 100) caps the recent buffer and maxFingerprints (default 1000) caps the counter map, so an attacker who can synthesize unique errors can't blow process memory. A Redis or durable-log buffer can implement the EventBuffer seam later — without touching the endpoint — when a zero-loss SLA demands it.#The Packages
The split follows the byte budget. A browser SDK loads on every page for every user, so bytes are the dominant cost: an Effect-native client measures ~108 KB gzipped, beacon is ~2 KB, and replay adds ~1 KB of glue with rrweb lazy-imported only when recording starts. You lose nothing on type safety — the envelope is contract-locked to the ingest endpoint's accepted shape by a compile-time assertion, so changing the shape on either side breaks the build.
@absolutejs/errorsEffect-native, Sentry-equivalent capture. capture() returns an Effect that can never itself fail — every sink failure is a typed, tagged value — plus durable fingerprinted issues and the /ingest and /symbolicate subpaths.
@absolutejs/beaconZero-dependency browser SDK, ~2 KB gzipped. Auto-captures uncaught errors and unhandled rejections, records breadcrumbs, batches, and POSTs the envelope via sendBeacon / fetch keepalive.
@absolutejs/replaySession replay in ~1 KB of glue — rrweb is an optional, lazy-loaded peer. Chunks DOM recordings, uploads via a pluggable blob transport, and masks inputs by default.
@absolutejs/errors-postgresPostgres-backed, Effect-native IssueStore. Grouped issues plus an event timeline, with new-vs-regression detection in one atomic CTE upsert — works with porsager/postgres or Neon serverless.
#Wiring It Together
Server-side, errorsPlugin mounts the POST /ingest route and starts the drainer on one Elysia plugin. Swap createMemoryIssueStore() (dev, tests, single-process) for the Postgres adapter and issues become durable — both honor identical semantics:
import { Elysia } from 'elysia';
import { errorsPlugin } from '@absolutejs/errors/elysia';
import { createPostgresIssueStore } from '@absolutejs/errors-postgres';
import postgres from 'postgres';
const sql = postgres(process.env.DATABASE_URL ?? '');
const store = createPostgresIssueStore({ sql }); // lazy, auto-created schema
// POST /ingest: Schema-validate the untrusted body, push into the
// coalescing buffer, answer 202 immediately. A drainer flushes every
// ~500ms — ONE recordCoalesced upsert per (project, fingerprint) group.
const errors = errorsPlugin({
server: false,
ingest: {
store,
onIssue: (result) => {
// Fires ONLY on a new issue or regression — the page-someone hook.
if (result.isNew || result.isRegression) notify(result.issue);
},
},
});
new Elysia().use(errors).listen(3000);Browser-side, start the recorder first, then hand its replayId to the beacon so every captured event is stamped with the active session:
import { captureException, initBeacon } from '@absolutejs/beacon';
import { createRecorder } from '@absolutejs/replay';
// 1. Record the session. Chunks upload via a pluggable transport
// (wire @absolutejs/blob). Inputs are masked by default.
const recorder = createRecorder({
project: 'web',
release: import.meta.env.VITE_RELEASE,
upload: (chunk) =>
uploadToBlob(
`replays/${chunk.replayId}/${chunk.seq}.json`,
JSON.stringify(chunk),
),
});
// 2. Point the beacon at the ingest endpoint and stamp every event
// with the active replayId — the cross-link the dashboard uses.
initBeacon({
project: 'web',
endpoint: 'https://api.example.com/ingest',
release: import.meta.env.VITE_RELEASE,
environment: 'production',
getReplayId: () => recorder.replayId,
});
// 3. On error, flush the replay tail so the DOM around it is stored.
window.addEventListener('error', () => void recorder.flush());
// Uncaught errors + unhandled rejections are captured automatically;
// manual capture works anywhere:
try {
await checkout();
} catch (error) {
captureException(error, { tags: { component: 'billing' } });
}maskAllInputs: true); keep masking on. Add class="rr-block" to skip recording a node entirely, class="rr-mask" to mask its text, and set maskAllText: true for high-sensitivity apps.#Going Deeper
This page is the map; each package has its own page for the territory: Errors, Beacon, Replay. Errors covers the CaptureOutcome shape, the IssueStore contract, and memory bounds; Beacon covers the full browser API, batching, and sampling; Replay covers recording options, privacy classes, and the framework-agnostic player.