AbsoluteJS

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:

1
BrowserAn exception escapes
window.onerror or unhandledrejection fires. By this point the breadcrumb ring buffer already holds the trail that led here — console.error / warn calls, clicks, fetch requests, and SPA navigations.
2
BeaconBeacon captures and batches
The event picks up tags, user context, a per-session id, and the active replayId via getReplayId. Events buffer up to maxBatch (default 30) or flushIntervalMs (default 5s), with sampleRate sampling and a beforeSend redaction hook — and flush reliably on pagehide / tab-hidden via sendBeacon.
3
Errors ingestPOST to the ingest endpoint
The untrusted envelope is Schema-validated server-side. Every rejection is a typed IngestRejection that maps to exactly one HTTP status — 413 payload too large, 400 malformed, 401 unauthorized, 429 rate limited. Accepted events are fingerprinted, pushed into the coalescing buffer, and answered 202 immediately.
4
Errors ingestSymbolication and fingerprinting
The default fingerprint is a 16-hex-char SHA-1 prefix over the error name, the normalized message (digits and quoted literals stripped — so "user 'u_42' not found" and "user 'u_99' not found" group together), and the first user stack frame. The drainer's prepare hook wires /symbolicate to rewrite minified stacks against uploaded source maps, off the hot path.
5
Errors ingestIssue created or deduped
Every ~500ms the drainer flushes: N identical errors in a window become a single times_seen += N upsert plus a capped sample of raw events, so an incident herd touches the hot issue row once. onIssue fires only on the transitions worth paging for — a new issue, or a resolved issue seen again (a regression, which flips back to unresolved).
6
ReplayReplay segments stored
In parallel, the recorder chunks the DOM recording and uploads each chunk via its pluggable transport (wire @absolutejs/blob). A flush() on window error stores the tail around the exception, and the stored event carries the replayId — cross-linking the issue to the exact session.
7
Errors ingestTriage
The durable store is the Issues surface: first-seen, last-seen, occurrence count, state, assignee, and regression detection. listIssues drives a dashboard, setState resolves or ignores, listEvents shows the occurrence timeline — and traceId / spanId / replayId cross-link each event to its exact trace and DOM replay.
Bounded by design
The ingest buffer is in-process and bounded — 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.

Errorsv0.7.2server
@absolutejs/errors

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

Beaconv0.4.6browser
@absolutejs/beacon

Zero-dependency browser SDK, ~2 KB gzipped. Auto-captures uncaught errors and unhandled rejections, records breadcrumbs, batches, and POSTs the envelope via sendBeacon / fetch keepalive.

Replayv0.3.1browser
@absolutejs/replay

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

Errors Postgresv0.1.3adapter
@absolutejs/errors-postgres

Postgres-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:

TS
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:

TS
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' } });
}
Recording sessions is a liability surface
Replay is private by default — inputs are masked (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.