Overview
Effect-native, Sentry-equivalent exception capture for the AbsoluteJS substrate.
@absolutejs/errorsv0.7.2betaObservabilityEffect-native, Sentry-equivalent exception capture with grouped issues, typed per-sink failures and a browser ingest endpoint.
Sentry-equivalent exception capture you self-host inside your Bun app. createErrorTracker fingerprints errors into grouped issues, records them on the active OTel span, emits audit events and upserts into a durable issue store — and capture() can never itself fail, returning a CaptureOutcome with per-sink delivery state and typed failures instead of throwing. The /ingest subpath adds a Schema-validated Elysia endpoint (with a coalescing buffer and drainer) for browser envelopes from @absolutejs/beacon, and /symbolicate rewrites minified stacks through source maps.
bun add @absolutejs/errorsEffect-native, Sentry-equivalent exception capture for the AbsoluteJS substrate.
createErrorTracker is a thin decorator over @absolutejs/audit, @absolutejs/telemetry, and a durable issue store. capture(error, context?) is an Effect<CaptureOutcome, never> — capturing an error can never itself fail — that does six things in one shot:
Computes a stable fingerprint so the same error from different
call sites collapses into one "issue."
Records the exception on the active OTel span, when a tracer is
supplied.
Emits an audit event (kind: 'errors.captured').
Upserts a grouped issue + appends the event to a durable
store, and fires onIssue on a new/regressed issue.
Pushes onto an in-process recent-errors LRU buffer.
Bumps per-fingerprint metrics counters.
Nested Error.cause chains are appended to the stored stack and preserved as structured extra.errorCauses entries, including driver fields such as database error codes, severity, detail, and routine. The outer error continues to drive fingerprinting so causes add diagnosis without collapsing distinct operations.
Browser ingest recomputes its canonical fingerprint after the optional prepare() step. Source-mapped copies of the same failure therefore group together across releases even when their raw content-hashed chunk names differ. The ingest boundary also redacts credential-bearing fields, bearer/JWT values, breadcrumb text, and URL query/hash values after schema validation but before fingerprinting or buffering. This defense-in-depth pass is enabled by default; createIngestEndpoint({ redact: false }) is available only for hosts that replace it with an equivalent trusted-boundary policy, while a custom redact(event) function can extend the built-in policy.
ErrorContext carries the standard Sentry-style triage envelope: tenant, target, traceId, spanId, replayId, tags, extra, level.
@absolutejs/errors/elysia provides one server plugin with separate settings for the two error paths:
server captures thrown, handled, and unexplained returned 5xx responses. Set captureReturned5xx to a predicate when an intentional control-plane response such as readiness 503 should remain observable through health monitoring without becoming an exception issue. The predicate receives the request context, response type, and status; thrown and explicitly handled exceptions are unaffected. It is enabled by default and can be disabled with server: false. ingest mounts the browser-event endpoint and is opt-in; pass {} to use the tracker's store and defaults, or false/omit it to expose no route.
This subpath is server-only and contains the Effect-backed tracker/ingest runtime. Browser code should use @absolutejs/beacon (or @absolutejs/observability) and never import @absolutejs/errors/elysia. Forwarding-only servers can omit tracker and provide server.capture directly; this is still the same errorsPlugin.
The in-process buffer is for triage; for a persistent, queryable Issues product (first-seen / last-seen / occurrence count / state / assignee / regression detection) pass a store:
Every capture upserts a grouped issue (keyed by (project, fingerprint)) and appends the event. onIssue fires only on the transitions worth paging someone for — a new issue or a regression (a resolved issue seen again, which flips back to unresolved). A resolved issue's severity escalates but never de-escalates; ignored issues stay muted.
Store writes are best-effort: a store outage surfaces as a typed StoreFailure in outcome.failures (wrapping the adapter's IssueStoreError), increments captureErrors, and never breaks capture.
Adapters are Effect-native — every method returns an Effect with a typed IssueStoreError channel (IssueStoreSchemaError / IssueStoreQueryError / IssueStoreSerializationError):
createMemoryIssueStore() is the zero-failure reference implementation (use for dev / tests / single-process); @absolutejs/errors-postgres is the durable adapter — both honor identical semantics.
StoredEvent carries traceId / spanId (→ @absolutejs/telemetry) and replayId (→ @absolutejs/replay) so the dashboard can cross-link an issue to its exact trace and DOM replay. issueTitle() / issueCulprit() are exported so store adapters derive the issue row without reaching into internals.
handoffErrorContext(summary) links an Issue to an @absolutejs/handoff summary through bounded tags and a privacy-safe projection. It excludes the latest evidence payload, messages, references, and external ids.
The default fingerprint is a 16-hex-char prefix of SHA-1 over
normalized() strips digits and quoted string literals so user 'u_42' not found and user 'u_99' not found group together. Inject a custom fingerprint function for deterministic tests or domain-specific grouping rules.
maxRecent (default 100) caps the in-process recent buffer.
maxFingerprints (default 1000) caps the byFingerprint
counter map so an attacker who can synthesize unique errors can't blow process memory. Beyond the cap, older entries are evicted arbitrarily — the counters are approximate-by-design.
A stable fingerprint (name, normalized message, first user stack frame) collapses the same error from different call sites into one issue; digits and string literals are stripped so IDs do not split groups.
Every fan-out (audit, tracer, store, onIssue) is wrapped so its failure becomes a typed Data.TaggedError in the outcome, with per-sink delivery state you can switch on exhaustively.
Pass a store to get a persistent issues surface — first seen, last seen, occurrence count, state, assignee — with onIssue firing only on new issues and regressions.
The /ingest subpath exposes a Schema-validated Elysia endpoint that buffers and drains envelopes posted by @absolutejs/beacon from the browser.
The /symbolicate subpath rewrites minified production stack traces back to original source locations via source maps.
maxRecent and maxFingerprints cap the in-process buffers so synthesized unique errors cannot blow process memory.
Outcomes
Effect-native, Sentry-equivalent exception capture for the AbsoluteJS substrate.
ErrorContext carries the standard Sentry-style triage envelope: tenant, target, traceId, spanId, replayId, tags, extra, level.
@absolutejs/errors/elysia provides one server plugin with separate settings for the two error paths:
Hardening checklist
Follow in order
Every fan-out (audit / tracer / store / onIssue) is a trust boundary. Each is wrapped so its failure becomes a typed, tagged value (Data.TaggedError) collected into the outcome — never swallowed into an anonymous counter or an onError(unknown). The outcome reports a per-sink delivery state ('ok' | 'failed' | 'skipped'), so a caller can react specifically — retry the store, page on audit loss, ignore a flaky onIssue — via an exhaustive switch (failure._tag).
import { Effect } from "effect";
import { status } from "elysia";
import { createErrorTracker, createMemoryIssueStore } from "@absolutejs/errors";
import { tracerOrNoop } from "@absolutejs/telemetry";
const errors = createErrorTracker({
audit: broker, // @absolutejs/audit
tracer: tracerOrNoop(otelProvider, "app"),
store: createMemoryIssueStore(), // or @absolutejs/errors-postgres
project: "acme",
release: process.env.RELEASE,
environment: "production",
onIssue: (r) => alert(r.issue), // only on new / regression
});
// Effect API (primary):
const outcome = await Effect.runPromise(
errors.capture(e, {
tenant,
target: `order_${orderId}`,
tags: { component: "billing" },
}),
);
// Promise edge (for Promise-world consumers) — identical outcome:
const out = await errors.captureException(e);
if (out.failures.length > 0) {
for (const f of out.failures) {
switch (f._tag) {
case "StoreFailure":
retryLater(f.cause);
break; // f.cause: IssueStoreError
case "AuditSinkFailure":
page("audit lost", f.cause);
break;
case "TracerFailure":
case "OnIssueFailure":
case "FingerprintFailure":
/* tolerate */ break;
}
}
}
return status(
"Internal Server Error",
`Request failed. Reference: ${out.fingerprint}`,
);Working example for API.
createErrorTracker(options?: {
audit?: { append: (event) => Promise<void> | void };
tracer?: { startSpan?: (name) => Span };
store?: IssueStore; // durable "Issues" surface
project?: string; // default 'default'
onIssue?: (r: IssueUpsertResult) => void | Promise<void>; // new/regression only
release?: string;
environment?: string;
fingerprint?: (error: Error, context: ErrorContext) => string | Promise<string>;
maxRecent?: number; // default 100
maxFingerprints?: number; // default 1000
clock?: () => number;
}) => ErrorTrackerCreate a tracker and capture an exception. capture() returns Effect<CaptureOutcome, never> — capturing an error can never itself fail.
import { Effect } from 'effect';
import {
createErrorTracker,
createMemoryIssueStore
} from '@absolutejs/errors';
const errors = createErrorTracker({
environment: 'production',
onIssue: (r) => alert(r.issue), // fires only on new / regression
project: 'acme',
release: process.env.RELEASE,
store: createMemoryIssueStore() // or @absolutejs/errors-postgres
});
// Effect API (primary):
const outcome = await Effect.runPromise(
errors.capture(e, {
tags: { component: 'billing' },
target: `order_${orderId}`,
tenant
})
);
// Promise edge — identical outcome:
const out = await errors.captureException(e);Each sink failure is a typed, tagged value collected into the outcome — react per sink instead of catching unknown.
if (out.failures.length > 0) {
for (const f of out.failures) {
switch (f._tag) {
case 'StoreFailure':
retryLater(f.cause); // f.cause: IssueStoreError
break;
case 'AuditSinkFailure':
page('audit lost', f.cause);
break;
case 'TracerFailure':
case 'OnIssueFailure':
case 'FingerprintFailure':
break; // tolerate
}
}
}
return new Response(`error ${out.fingerprint}`, { status: 500 });Durable issue stores plug into createErrorTracker via the store option. The built-in createMemoryIssueStore covers dev, tests and single-process apps; adapters add persistence with identical semantics.
Search the declarations exported by the current package type files. Expand a symbol to inspect its source-backed signature.
Current package surface
Import surface · click to copy