An offline-first browser error tracking pipeline that runs inside the failure path, redacts before it stores anything, and slows one repeating failure instead of a whole page.
You are on a call with support. A customer says checkout failed twice this morning. Then it worked. You ask them for the browser console, they send a screenshot, and the console is empty, because the page has been reloaded twice since then. The network tab is gone with it. What you have is one sentence and a timestamp.
Somewhere in your dashboard, that customer has no error. Not a wrong error, not a truncated stack, just nothing. Which means the dashboard is telling you a story about happy users, and the users who are quietly giving up look exactly the same as them.
So here is the question that decides whether your browser error tracking is useful or decorative: what happens to the errors that never reach your tracker at all?
Between here and there you will build a capture pipeline that stores locally before it ever touches the network, redacts before it writes anything, slows one repeating failure without silencing the new ones, and ends in a single HTML file that a support engineer can open and read.
Keep one question in mind while you read, because I answer it near the end. Where does an error go when it is thrown before your tracker starts, and the tab closes a second later?
If debugging is the part of the job you actually like, follow me here on Medium. I publish one practical frontend piece a week and I reply to comments. If someone on your team owns the error dashboard, send them this.
Why browser error tracking fails at the worst possible moment
Quick version. Your reporter is a handful of lines that run after something already went wrong. One call at startup, one call per failure you handle yourself, and a way to get the results out. In the version I use below, that startup call captures uncaught errors, unhandled promise rejections and everything logged at warn or above into IndexedDB, and the results come out when someone presses Ctrl+Shift+Alt+D. It is the smallest piece of your error handling, which is exactly why it gets the least attention.
Real-world version. The failure modes are not exotic. They are the ordinary consequences of running code in a hostile moment.
A convenience reporter that touches error.message on a thrown value that is not an Error throws a second exception inside your catch block. A fetch call that is not keepalive gets cancelled when the tab closes, so the error you captured during pagehide never leaves the browser. A URL pasted into a payload carries whatever token a user's password reset link had in the query string. One broken polling endpoint produces the same error every five seconds until it owns your entire rate limit, and the genuinely new failure that happened at minute three is the one that gets dropped. Any script loaded from a third-party origin can be blocked by an extension or a strict Content Security Policy, and when that happens capture stops silently.
Under the hood. These are not separate bugs to fix one at a time. They come from one design decision: whether the reporting path is allowed to fail. The library I use in the examples below makes five promises to itself, and each one kills a class of the failures above.
It cannot throw, so a hostile value cannot turn reporting into a second crash. It cannot recurse, because a flag drops anything raised while the pipeline is already running. It cannot double count, because a WeakMap remembers the error objects it has already seen. It cannot flood, because a fixed-window limiter drops the excess. It cannot lose pre-init errors, because failures thrown before initTelemetry runs are normalised immediately and replayed once the application metadata exists.
Teaser. Which brings us to the smallest and most important function in the whole system, the one that people write in thirty seconds and regret for a year.
Before we get into the examples, a quick note: The code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular/React versions. Always refer to the official documentation for the most current API and syntax.
The reporter that throws while it reports
Quick version. Here is a helper that a lot of codebases have somewhere, and it looks fine.
// shared/report-error.ts
export function reportError(error: unknown): void {
const payload = JSON.stringify({
message: (error as Error).message,
stack: (error as Error).stack,
url: window.location.href,
});
void fetch('/telemetry/errors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
}
It compiles, it runs, and in development it even works. Then production hands it a value that is not an Error, and the same helper fails in four different ways.
(error as Error).message on a thrown string returns undefined and you log an empty record. On null it throws a TypeError, and because this ran inside your own catch block, the original failure is replaced by a new one. window.location.href includes the query string, so a reset link with a token in it is now in your telemetry. And fetch without keepalive is dropped by the browser when the page is closing, which is when the errors you most want are captured.
Real-world version. The fix is not to write a longer version of the same function. It is to route every capture through one ingestion point that owns normalisation, redaction and persistence.
// shared/telemetry.ts
import { captureError, initTelemetry, logger } from '@codewithrajat/rm-logvault';
initTelemetry({
appName: 'checkout',
appVersion: '2.4.1',
environment: 'production',
});
export function reportCheckoutFailure(error: unknown, orderId: string): void {
captureError(error, {
tags: { flow: 'checkout', route: '/checkout/:orderId' },
extra: { orderId },
severity: 'error',
});
logger.warn('[Checkout] payment attempt failed', { orderId });
}
Note what is missing. No window.location.href, because the record picks up page context itself. No .message access, because captureError accepts unknown and normalises whatever it gets. No await, because capturing is fire-and-forget and the write is queued locally before any network is involved.
The library is @codewithrajat/rm-logvault, published on npm with the source on GitHub.
The thrown value is the first argument, which makes the helper safe to call from any catch block you already have, including the one that rethrows.
// checkout-api.ts
import { reportCheckoutFailure } from './telemetry';
export async function payForOrder(orderId: string): Promise<void> {
try {
await postPayment(orderId);
} catch (error) {
reportCheckoutFailure(error, orderId);
throw error;
}
}
The record that lands in IndexedDB after payForOrder('A-1024') fails looks like this, trimmed to the fields that matter.
{
"schemaVersion": 1,
"source": "manual",
"severity": "error",
"handled": true,
"thrownType": "error",
"name": "Error",
"message": "Payment provider returned 502",
"fingerprint": "3f2c81a9c4b7d0e5",
"occurrenceCount": 1,
"tags": { "flow": "checkout", "route": "/checkout/:orderId" },
"extra": { "orderId": "A-1024" },
"page": { "url": "https://shop.example.com/checkout", "route": "/checkout", "pageLoadId": "pl_8f13" },
"uploadStatus": "pending"
}
Under the hood. Normalisation is where most of the work hides. A thrown string, a thrown number, a rejected Promise with a plain object, a Proxy whose getter raises, an AggregateError, a cause chain four levels deep: all of them have to become the same shape without ever throwing. The pipeline records thrownType so you can tell a real Error from an object someone rejected with, keeps up to three links of the cause chain, and reads at most five sub-errors from an AggregateError.
When the pipeline itself fails, it does not surface a new error to the user. It reports through an internal channel that fires once per stage, which is why a broken storage layer produces one warning instead of one warning per captured error.
If you have a value that must become a real Error before it reaches code that expects one, toError does that without stringifying anything hostile that came in with it.
Teaser. Capturing locally is cheap. Deciding when those records travel, and what happens when they cannot, is the part people get wrong next.
Where do browser errors go when the network is down?
Quick version. Nowhere, and that is the point. The default configuration stores everything in IndexedDB and makes no network request at all.
// main.ts
import { initTelemetry } from '@codewithrajat/rm-logvault';
initTelemetry({ appName: 'checkout', appVersion: '2.4.1' });
Two databases are created, rm-logvault-errors and rm-logvault-logs. Errors are capped at 500 rows kept for 7 days, logs at 2000 rows kept for 3 days. Nothing is sent anywhere, which is why this works with no backend, in an air-gapped build, and in a page with a locked-down CSP.
Real-world version. The turning point here is a design decision rather than a feature: nothing travels straight from the failure to the network. A queue that only lives in memory is not a queue, and the interesting moments are the ones where the page is going away.
The library flushes the write buffer on pagehide and on visibilitychange when the page becomes hidden, and it asks the uploader for a keepalive drain at the same time. That combination is what stops the last thirty seconds of a session from disappearing with the tab. It is also why a burst of logs can still be lost, since the buffer holds entries for up to a second before writing unless the batch size is reached first.
Storage is not guaranteed either. Private browsing modes, a full origin quota, and a corrupted database all happen, and the quota rules are set by the browser rather than by you, as covered in Storage for the web. When opening the database fails, storage moves to unavailable and the library keeps working with the console only, because losing diagnostics is much less bad than breaking the application.
// support-tools.ts
import { getTelemetryStatus } from '@codewithrajat/rm-logvault';
export function describeTelemetry(): string {
const status = getTelemetryStatus();
return `mode=${status.mode} storage=${status.storage} pending=${status.pending.errors}/${status.pending.logs} dropped=${status.droppedByRateLimit}`;
}
Under the hood. What sits on top of IndexedDB is an outbox. A write is a pending row. A sync run claims rows atomically, sends them, and marks them uploaded. Claiming matters for correctness, not performance: with two tabs open, two sync loops would otherwise upload the same record twice.
captureError(error, ctx)
|
v
normalize -> fingerprint -> already seen this Error object?
| |
| merge tags/extra onto the pending row
v
sanitize (redact + bound) -> IndexedDB row (pending, occurrenceCount)
|
v
outbox: claim -> send -> mark uploaded | retry with backoff | mark failed
|
v
HTML report (Ctrl+Shift+Alt+D) / json / jsonl / csv
Aggregation only ever touches pending rows. A row that is being uploaded is immutable, because rewriting a record after it has left the browser would make the server copy and the local copy disagree with each other and with their own fingerprint.
Teaser. None of that queue matters if the thing you are queueing is a customer's session token.
Redaction should happen before storage, not before upload
Quick version. Records are scrubbed on the way in. A key named token, email, password or authorization becomes [REDACTED] regardless of nesting, and the same happens inside free text.
// shared/telemetry.ts
import { captureError, initTelemetry } from '@codewithrajat/rm-logvault';
initTelemetry({
appName: 'checkout',
redaction: {
extraSensitiveKeys: ['orderReference'],
allowedQueryParams: ['page', 'locale'],
},
});
captureError(new Error('card declined for user@example.com'), {
extra: { accessToken: 'sk_live_51H8x', locale: 'en-GB' },
});
The stored record keeps the shape of your object and none of the secrets.
{
"message": "card declined for [REDACTED]",
"extra": { "accessToken": "[REDACTED]", "locale": "en-GB" }
}
Real-world version. Redacting on the server is the common mistake, and it is a mistake even when the server is yours. The data has already crossed the network, sat in a request log, and been handled by whatever sits in front of your API. Redacting in the browser before the write means the plaintext value never exists in any store you have to audit later.
The second common mistake is assuming the redactor only looks at keys. It also scans text, because phone=+14150000000 in a log message leaks the same value as { phone: '+14150000000' } would. The sensitive-key match is done against a lowercased key with separators stripped, so access_token, accessToken and ACCESS TOKEN all resolve to the same decision. Free text gets JWT-shaped strings, Bearer and Basic schemes, e-mail addresses, and token=... style pairs scrubbed, along with long opaque runs of 32 to 200 word characters, which is the shape of a leaked API key or a dashless UUID.
Query strings follow a different rule, because dropping every value would make a URL useless. Values survive only for parameter names on an allow-list, which by default is limit, offset, page, pageSize, size, sort, order, scope, lng, lang and type. A URL that is really an identifier gets replaced with a placeholder, and the fingerprint normalises those placeholders so /orders/8842 and /orders/9917 group as one bug.
Under the hood. The sanitizer is one instance reached through getDefaultSanitizer(), and it is wired before anything can capture, not after.
The stronger half of the privacy claim is what is never read at all, since there is no rule to get wrong when there is no read. Request and response bodies are never touched, including axios data and params. Cookies and document.cookie are never read. localStorage and sessionStorage are never read, because the vault is separate storage rather than a view over yours. Auth header values are never read, and only four named correlation headers are probed by name for a request id. Form values are never read. The only DOM element the library inspects is the shortcut's own event target, and only to decide whether to stop.
Bounding is as important as removing. Text is truncated before any pattern runs, so the regular expressions cannot be driven into a pathological backtrack. Values are walked to a depth of four, thirty keys, twenty array items, two thousand characters per string. Records are then reduced against a byte budget, 16 KB for errors and 4 KB for logs, so one enormous stack trace cannot block the write queue.
Which of your own query parameters would survive that allow-list? Open one real production URL from your application and look at what the key names are. If an identifier you did not expect is in the list, you just found a leak before an auditor did.
Teaser. A redacted record is still capable of drowning out the record that matters.
One failing poll should not bury every other error
Quick version. Identical failures aggregate into one row with an occurrenceCount, so a thousand repeats cost one row instead of a thousand.
// shared/telemetry.ts
import { captureError, flushTelemetry, initTelemetry } from '@codewithrajat/rm-logvault';
import { createMemoryRepository } from '@codewithrajat/rm-logvault/testing';
const repository = createMemoryRepository();
initTelemetry({ appName: 'checkout', repository, shortcut: false });
for (let attempt = 0; attempt < 5; attempt += 1) {
captureError(new Error('orders refresh failed'), { tags: { poll: 'true' } });
}
await flushTelemetry();
const rows = repository.errors.all();
// rows.length === 1, rows[0].occurrenceCount === 5
Real-world version. Aggregation alone does not save you, and this is the bug I see most often in hand-rolled trackers. A global limit answers "how many errors may this page produce per minute". It does not answer "may this one error be produced again". A poll that refetches every five seconds and fails every time spends a tenth of a 120-per-minute budget on a failure that is already sitting in the report. Ten endpoints failing on that cadence exhaust the budget completely, and the new failure at minute three is the one that gets dropped, which is the exact opposite of what you want.
The fix is a second limiter, per fingerprint, consulted before the global one. Twelve repeats per minute by default. A repeat that is refused does not consume the global budget, so the budget goes to failures nobody has seen yet.
// shared/telemetry.ts
import { initTelemetry } from '@codewithrajat/rm-logvault';
initTelemetry({
appName: 'checkout',
errors: {
maxEventsPerMinute: 120,
maxRepeatsPerMinute: 12,
},
});
A wrong version that still runs, and still looks reasonable, is deduplicating on the full stack including line numbers.
// shared/dedupe-error.ts
const seen = new Set<string>();
export function isDuplicate(error: Error): boolean {
const key = `${error.message}:${error.stack ?? ''}`;
if (seen.has(key)) return true;
seen.add(key);
return false;
}
Every deploy shifts line numbers, so every deploy creates a brand new group for a bug you already knew about. The set also grows without bound for the lifetime of the page. Dropping the stack and deduping on the message alone fails in the other direction, collapsing two unrelated failures that happen to share a message.
Have you ever lost an evening to a bug like one of these, where the tooling lied to you about what happened? Tell me what it was in the comments, because these are usually more interesting than the fix.
Under the hood. A fingerprint is built from the source, the normalised message, the top stack frames, and a path with identifiers replaced. It is hashed with cyrb53 rather than a cryptographic hash, because this value is a grouping key, not a security boundary, and the whole thing has to stay small and fast on the error path.
Two layers of dedupe run before any of that. Capturing the same Error object twice, once from a React boundary and once from window.onerror, does not create two records, because the object identity is remembered in a WeakMap. The second capture is not wasted either. It can merge severity, tags and extra onto the row that already exists, which is how a global handler's generic capture gets upgraded by the caller that actually knew where the failure came from.
message, stack, category, page and environment are deliberately not patchable. They describe the occurrence, and the first capture recorded them. Changing them afterwards would leave a stored record disagreeing with its own fingerprint.
The aggregate itself is mutable by design: a repeat widens lastSeen and increments occurrenceCount on the pending row. Repeat throttling is the same mechanism that keeps that update path from becoming a write storm.
Teaser. Everything so far assumes your code threw the error. A lot of real browser failures never reach a try block at all.
The errors a window.onerror handler cannot see
Quick version. Two of these are off by default, because capturing them changes behaviour for the host page. The third is on by default, and the snippet below sets all three so you can see where they live.
// shared/telemetry.ts
import { initTelemetry } from '@codewithrajat/rm-logvault';
initTelemetry({
appName: 'checkout',
errors: {
captureResources: true,
captureCsp: true,
captureChunkErrors: true,
},
});
Resource failures cover a <script> or <link> that failed to load. CSP violations arrive as securitypolicyviolation events. Chunk errors are dynamic import failures, and they are on by default because a stale chunk after a deploy is one of the most common production-only failures there is.
Real-world version. Each of these has a reason to be invisible by default.
A resource error does not bubble, so a listener on the document in the bubble phase never sees it, which is why the error event has to be caught during capture. A CSP violation is not an error at all, it is an event with a blockedURI and a directive. And a cross-origin script that throws produces the message Script error. with no stack and no details, which is a browser security decision rather than a bug in your tracker. The same rule applies to rejected promises from cross-origin scripts, which do not fire unhandledrejection at all, as documented on MDN.
Dynamic imports need one more thing, because the failure happens before any component or route handler is running. withChunkCapture wraps the loader and attaches the route pattern.
// orders.routes.ts
import { withChunkCapture } from '@codewithrajat/rm-logvault';
export const loadOrderDetail = withChunkCapture(
'/orders/:id',
() => import('./pages/order-detail'),
{ tags: { flow: 'order-detail' } },
);
export async function openOrder(orderId: string): Promise<void> {
try {
await loadOrderDetail();
} catch {
offerReload();
}
}
The record from that failure carries source: 'chunk' and fatal severity, which is what makes "the deploy broke lazy loading" a query rather than a hunch.
Under the hood. Angular and React both funnel framework failures into the same ingestion point, which is the only reason the grouping works across boundaries. Angular's ErrorHandler is an injectable class, so the adapter subclasses it, reports, and then delegates to console.error so the framework's own diagnostics are unchanged. React 19 added root level error callbacks to createRoot, and the adapter exposes them as a spreadable bundle.
Workers are their own case, because a worker has its own global scope. The worker adapter listens for error and messageerror inside the worker and forwards the failure, which is the only way an uncaught exception in a worker stops being silent.
If your application knows something the library cannot, a context builder is the hook for it. Registration is by name, a second registration under the same name replaces the first, and a throwing builder falls through to the next one instead of swallowing the capture.
// shared/telemetry.ts
import { registerErrorContextBuilder } from '@codewithrajat/rm-logvault';
const unregister = registerErrorContextBuilder({
name: 'feature-flag-failures',
canHandle: (error) => error instanceof Error && error.message.includes('flag'),
build: (error) => ({
tags: { subsystem: 'feature-flags' },
extra: { flagMessage: error instanceof Error ? error.message : 'unknown' },
}),
});
// Later, on teardown. Safe to call twice.
unregister();
Teaser. Two of those boundaries are in frameworks you are already using, and wiring them the wrong way is how people end up with a capture from a component that React never actually failed on.
Wiring React and Angular error boundaries without replacing them
Quick version. In React, an error boundary that reports is a class component, because that is still how boundaries work.
// app-root.tsx
import { TelemetryErrorBoundary } from '@codewithrajat/rm-logvault/react';
import { App } from './app';
export function AppRoot() {
return (
<TelemetryErrorBoundary
fallback={(error, reset) => (
<div>
<p role="alert">Something went wrong: {error.message}</p>
<button type="button" onClick={reset}>
Try again
</button>
</div>
)}
>
<App />
</TelemetryErrorBoundary>
);
}
Real-world version. A boundary catches render failures in the tree below it. It does not catch a failure in an event handler, and React 19 added root level callbacks for the ones that never reach a boundary at all. Use both, and do not fight the framework: spread the root handlers into createRoot rather than rewriting the boundary.
// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { reactRootErrorHandlers } from '@codewithrajat/rm-logvault/react';
import { AppRoot } from './app-root';
const container = document.getElementById('root');
if (container === null) throw new Error('missing #root element');
createRoot(container, reactRootErrorHandlers({ tags: { shell: 'checkout' } })).render(
<StrictMode>
<AppRoot />
</StrictMode>,
);
For a failure you handle yourself, a hook keeps the context consistent and gives you a function that is safe to call from a handler or an effect.
// checkout-panel.tsx
import { useState } from 'react';
import { useErrorCapture } from '@codewithrajat/rm-logvault/react';
import { payForOrder } from './pay-for-order';
export function CheckoutPanel({ orderId }: { readonly orderId: string }) {
const capture = useErrorCapture({ tags: { flow: 'checkout' } });
const [status, setStatus] = useState<'idle' | 'paying' | 'failed'>('idle');
async function pay(): Promise<void> {
setStatus('paying');
try {
await payForOrder(orderId);
setStatus('idle');
} catch (error) {
setStatus('failed');
capture(error, { extra: { orderId } });
}
}
return (
<section>
{status === 'failed' ? <p role="alert">Payment failed. Try again.</p> : null}
<button type="button" onClick={() => void pay()}>
Pay
</button>
</section>
);
}
In Angular the integration is a provider, and the default handler stays in place underneath it.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideTelemetryErrorHandler } from '@codewithrajat/rm-logvault/angular';
import { captureError, initTelemetry } from '@codewithrajat/rm-logvault';
import { AppComponent } from './app.component';
initTelemetry({ appName: 'checkout', appVersion: '2.4.1' });
bootstrapApplication(AppComponent, {
providers: [provideTelemetryErrorHandler({ tags: { shell: 'checkout' } })],
}).catch((error: unknown) => {
// Bootstrap failures happen before any handler exists, so they are captured explicitly.
captureError(error, { source: 'manual', severity: 'fatal' });
});
The provider uses a factory rather than a class provider on purpose, so it does not require emitDecoratorMetadata in your tsconfig. A component that handles a failure itself looks like this.
// order-detail.component.ts
import { Component, inject, input, signal } from '@angular/core';
import { captureError } from '@codewithrajat/rm-logvault';
import { OrdersApi } from './orders-api';
import type { OrderLine } from './order-line';
@Component({
selector: 'app-order-detail',
template: `
<h2>Order {{ orderId() }}</h2>
@if (status() === 'error') {
<p role="alert">We could not load this order.</p>
}
@for (line of lines(); track line.sku) {
<p>{{ line.sku }} x {{ line.quantity }}</p>
}
<button type="button" (click)="load()">Retry</button>
`,
})
export class OrderDetailComponent {
readonly orderId = input.required<string>();
readonly lines = signal<readonly OrderLine[]>([]);
readonly status = signal<'idle' | 'loading' | 'error'>('idle');
private readonly api = inject(OrdersApi);
async load(): Promise<void> {
this.status.set('loading');
try {
this.lines.set(await this.api.load(this.orderId()));
this.status.set('idle');
} catch (error) {
this.status.set('error');
captureError(error, {
tags: { route: '/orders/:id', flow: 'order-detail' },
extra: { orderId: this.orderId() },
});
}
}
}
Under the hood. The reason both frameworks funnel into one place is deduplication. A failure that reaches a boundary and also reaches the root is one failure, and object identity is what makes the second capture merge into the first rather than double up. That is also why both adapters swallow their own exceptions: a reporting call that throws inside componentDidCatch turns a handled render failure into an unhandled one.
The Angular adapter only reports and delegates. It never replaces the default console output, so an Angular developer's usual error messages stay exactly where they were, and the adapter works whether the application uses bootstrapApplication or an older bootstrap path.
Teaser. At this point the vault has everything. Somebody still has to get it out and read it.
From a support ticket to one self-contained HTML report
Quick version. Press Ctrl+Shift+Alt+D in the page, and a single HTML file downloads. The default shortcut is registered at startup, so there is nothing to wire.
// support-tools.ts
import { exportDiagnosticsReport, logger } from '@codewithrajat/rm-logvault';
export async function downloadReport(): Promise<void> {
const result = await exportDiagnosticsReport({ format: 'html' });
logger.info(`report ok=${String(result.ok)} bytes=${String(result.bytes)}`);
}
Real-world version. The shortcut gets you a file. It does not get you the right file, and it does not get you the record the support agent needs to read.
Four formats exist for four different readers. html is the one a support engineer opens, and it embeds the raw payload so the file is complete on its own. json is one document for a script. jsonl is one object per line, which is the format that survives grep and jq. csv is a flat table you can open in a spreadsheet when someone asks for a summary by route.
Two details are worth knowing before you hand the shortcut to everyone. The default gate allows it for anyone in the page, so gate it yourself with allow, and use redactAgain when the records were written before your redaction configuration changed.
// shared/telemetry.ts
import { initTelemetry, logger } from '@codewithrajat/rm-logvault';
initTelemetry({
appName: 'checkout',
shortcut: {
key: 'd',
filenamePrefix: 'checkout-diagnostics',
allow: () => isInternalUser(),
onExported: (ok) => {
logger.info(`[Diagnostics] export finished ok=${String(ok)}`);
},
},
});
The library exposes the same shortcut as obscurity, not access control. Anyone who knows the key combination can press it, which is why the report is scrubbed twice rather than trusting the records to be safe.
Under the hood. A report is an HTML file that a user e-mails to somebody else, so it has to be unable to execute anything. Three rules make that true: record data is escaped rather than interpolated into markup, the viewer never touches innerHTML, and the file fetches nothing. The payload is embedded as escaped JSON so the page can render a table without a network request.
Export is also bounded. Records are marshalled in slices of 500 with a yield to the event loop between slices, and an export already in flight rejects a second one, because a double key press is a double click and not a request for two files.
Teaser. Local-only is a complete product on its own. The moment you want a dashboard, you add an endpoint, and that is a decision with its own failure modes.
Sending records to your backend, on purpose
Quick version. One URL is enough if your collector accepts both kinds.
// shared/telemetry.ts
import { initTelemetry } from '@codewithrajat/rm-logvault';
initTelemetry({
appName: 'checkout',
env: 'VITE_',
url: '/telemetry',
headers: () => ({ Authorization: `Bearer ${getAccessToken()}` }),
level: 'info',
});
Supplying a URL turns uploads on and moves the mode to remote. Records are still written to IndexedDB first, so an endpoint that is down costs latency in your dashboard and nothing at all in your data.
Real-world version. Uploads introduce three questions that local-only never asked.
What happens when the request never lands. Network failures, timeouts, 408, 429 and every 5xx are retried with backoff, and a Retry-After header is honoured when the server sends one. What happens when the request lands and is refused. 400, 401, 403, 404, 405, 410, 413, 415 and 422 are terminal, and the records are marked failed so they stop consuming retry budget forever. What happens after the user re-authenticates and you want those records anyway. retryFailedTelemetry() moves failed records back to pending, which is the difference between losing everything captured during a token expiry and getting it back.
// session-recovery.ts
import { retryFailedTelemetry } from '@codewithrajat/rm-logvault';
export async function recoverSession(): Promise<number> {
await refreshSession();
return retryFailedTelemetry();
}
Credentials default to same-origin, never include, so a configured endpoint cannot quietly start carrying cookies to a third party. For a same-origin collector that is exactly right. For a cross-origin one, pass a header provider and let your own auth code decide what goes out.
Under the hood. The wire format is one envelope per batch, batched at 50 records and flushed every 30 seconds.
{
"schemaVersion": 1,
"kind": "errors",
"sentAt": 1767225600000,
"app": { "appName": "checkout", "appVersion": "2.4.1", "buildId": "9c1a2f4", "environment": "production" },
"records": [{ "id": "err_01j8", "fingerprint": "3f2c81a9c4b7d0e5", "occurrenceCount": 3, "message": "Payment provider returned 502" }]
}
Your server has one obligation: answer 2xx for accepted records, and use a status your client can interpret correctly when you do not accept them. A 429 with Retry-After is respected rather than fought. A 401 is terminal on purpose, because retrying an unauthenticated request is a way to hammer your own gateway while the token is broken. The full envelope, the status table and the server obligations are in the REST contract.
If the shipped fetch transport does not fit, rest.transport accepts your own implementation with a send method, and the testing subpath ships a scriptable one so tests can exercise every branch of the outbox without a server.
Teaser. Everything above is code you can reason about, which means all of it is code you can break with a bad test setup.
Testing a browser error tracker without a browser
Quick version. The library ships test doubles on a separate subpath, and that subpath installs nothing, starts nothing and opens no database.
// telemetry-upload.test.ts
import { afterEach, expect, it } from 'vitest';
import {
destroyTelemetry,
initTelemetry,
logger,
syncTelemetry,
} from '@codewithrajat/rm-logvault';
import { createFakeTransport, createMemoryRepository } from '@codewithrajat/rm-logvault/testing';
afterEach(() => {
destroyTelemetry();
});
it('retries a 503 instead of dropping the batch', async () => {
const repository = createMemoryRepository();
const transport = createFakeTransport({ status: 503, retryAfterMs: 30_000 });
initTelemetry({
appName: 'orders-test',
repository,
shortcut: false,
rest: {
errorsUrl: '/telemetry/errors',
logsUrl: '/telemetry/logs',
transport,
},
});
logger.error('[Orders] load failed');
const result = await syncTelemetry();
expect(transport.sendCount).toBe(1);
expect(transport.lastBody()?.records).toHaveLength(1);
expect(result.logs.uploaded).toBe(0);
expect(result.logs.retried).toBe(1);
});
Run it with npx vitest run. The in-memory repository also fails on demand through failWith and quotaAt, which is how you test the degradation path instead of hoping it works.
Real-world version. The test people actually need is the one that asserts a component failure produced a record with the right context. In React, Testing Library plus Vitest does it with a real boundary.
// telemetry-error-boundary.test.tsx
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { destroyTelemetry, flushTelemetry, initTelemetry } from '@codewithrajat/rm-logvault';
import { TelemetryErrorBoundary } from '@codewithrajat/rm-logvault/react';
import { createMemoryRepository } from '@codewithrajat/rm-logvault/testing';
function BrokenWidget(): never {
throw new Error('orders widget exploded');
}
describe('TelemetryErrorBoundary', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
destroyTelemetry();
vi.restoreAllMocks();
});
it('renders the fallback and stores exactly one record', async () => {
const repository = createMemoryRepository();
initTelemetry({ appName: 'orders-test', repository, shortcut: false });
render(
<TelemetryErrorBoundary fallback={<p>Widget unavailable</p>}>
<BrokenWidget />
</TelemetryErrorBoundary>,
);
expect(screen.getByText('Widget unavailable')).toBeTruthy();
await flushTelemetry();
const [record] = repository.errors.all();
expect(record?.source).toBe('react');
expect(record?.message).toContain('orders widget exploded');
expect(record?.componentStack).toBeTruthy();
});
});
In Angular, the handler is an injectable, so the test is a TestBed provider test and it runs with ng test.
// checkout-error-handler.spec.ts
import { TestBed } from '@angular/core/testing';
import { ErrorHandler } from '@angular/core';
import { provideTelemetryErrorHandler } from '@codewithrajat/rm-logvault/angular';
import { destroyTelemetry, flushTelemetry, initTelemetry } from '@codewithrajat/rm-logvault';
import { createMemoryRepository } from '@codewithrajat/rm-logvault/testing';
describe('TelemetryErrorHandler', () => {
it('stores an error Angular reported, with its context', async () => {
const repository = createMemoryRepository();
initTelemetry({ appName: 'checkout-test', repository, shortcut: false });
TestBed.configureTestingModule({
providers: [provideTelemetryErrorHandler({ tags: { shell: 'checkout' } })],
});
TestBed.inject(ErrorHandler).handleError(new Error('payment failed'));
await flushTelemetry();
const [record] = repository.errors.all();
expect(record?.source).toBe('angular');
expect(record?.message).toBe('payment failed');
expect(record?.tags?.shell).toBe('checkout');
destroyTelemetry();
});
});
Under the hood. Both tests assert the same three things, and they are the three that break in production: the record exists, the message survived normalisation, and the context that identifies where it came from is attached.
The last double is for code that only calls capture, not for the pipeline. A recording captureError double replaces the real one in a unit test of a service, so a service test does not need the library initialised at all.
// checkout-service.test.ts
import { expect, it, vi } from 'vitest';
import { createCaptureErrorMock } from '@codewithrajat/rm-logvault/testing';
const telemetry = createCaptureErrorMock();
vi.mock('./telemetry', () => ({ captureError: telemetry.captureError }));
it('reports a failed load with the route', async () => {
const { loadOrders } = await import('./orders-service');
await loadOrders('42');
expect(telemetry.messages()).toEqual(['orders 42 not found']);
expect(telemetry.last()?.ctx?.source).toBe('router');
telemetry.reset();
});
The reason to reach for a recording double instead of a bare vi.fn() is the assertion itself. telemetry.last()?.ctx?.source reads like the thing you are checking, while mock.calls[0][1].source reads like an implementation detail of the mock.
Teaser. Here is the whole thing in one file, plus the answer to the question I asked at the start.
The full integration, and the answer to the question from the top
This is the complete version of the running example, in one place. Every option here is optional, and the ordering comments matter more than the values.
// shared/telemetry.ts
import {
captureError,
destroyTelemetry,
getTelemetryStatus,
initTelemetry,
logger,
} from '@codewithrajat/rm-logvault';
import type { TelemetryHandle } from '@codewithrajat/rm-logvault';
let handle: TelemetryHandle | undefined;
export function startTelemetry(): TelemetryHandle {
if (handle !== undefined) return handle;
handle = initTelemetry({
// Identity. Everything on a record that says which build produced it.
appName: 'checkout',
appVersion: '2.4.1',
buildId: '9c1a2f4',
environment: 'production',
// Local by default. Adding a URL is the only thing that turns uploads on.
url: '/telemetry',
headers: () => ({ Authorization: `Bearer ${getAccessToken()}` }),
errors: {
maxRecords: 1000,
retentionDays: 14,
maxEventsPerMinute: 120,
maxRepeatsPerMinute: 12,
captureResources: true,
captureCsp: true,
captureChunkErrors: true,
beforeCapture: (record) => (record.message.includes('ResizeObserver loop') ? null : record),
},
logs: {
level: 'info',
consoleLevel: 'warn',
maxRecords: 5000,
retentionDays: 7,
},
redaction: {
allowedQueryParams: ['page', 'locale'],
},
rest: {
intervalMs: 30_000,
batchSize: 50,
onTerminalFailure: (status) => {
logger.warn(`[Telemetry] batch rejected with status ${String(status)}`);
},
},
shortcut: {
filenamePrefix: 'checkout-diagnostics',
allow: () => isInternalUser(),
},
// Consent is evaluated before every capture, persist and upload.
consent: () => hasAnalyticsConsent(),
onInternalError: (stage, error) => {
logger.warn(`[Telemetry] internal failure in ${stage}`, error);
},
});
return handle;
}
export function reportFailure(error: unknown, context: Record<string, string>): void {
captureError(error, {
tags: context,
severity: 'error',
});
}
export function stopTelemetry(): void {
destroyTelemetry();
handle = undefined;
}
export function telemetrySummary(): string {
const status = getTelemetryStatus();
return `${status.mode} ${status.storage} pending=${status.pending.errors}/${status.pending.logs}`;
}
Now the question from the top: where does an error go when it is thrown before your tracker starts, and the tab closes a second later?
Two mechanisms cover it, and they are independent. Before initTelemetry runs, a failure is normalised immediately and held in a bounded buffer of 50 entries. It is stored in its safe form rather than as a live reference to a caller's object graph, so nothing about the application is retained by accident. When initialisation completes, that buffer is replayed into the real tracker, which is why an exception thrown during module evaluation still appears in the report with the application metadata attached.
The tab closing is the other half, and it is the outbox doing the work. Records are written to IndexedDB as pending rows, pagehide and the hidden visibilitychange state both trigger a flush, and the uploader gets a keepalive drain at that moment. If the network is gone, the rows are still there when the user comes back, and the next sync pass picks them up.
The result of all of this is a support conversation that goes differently. Someone opens the report, sees a single row with a large occurrence count and a route tag, and the ninety seconds of guessing are gone.
The one thing most teams get wrong. They audit the reporter for correctness and never audit it for failure. Every code path in this article exists because the pipeline runs while the application is already broken: storage is allowed to be missing, the network is allowed to be gone, and the value being reported is allowed to be hostile. A reporter that only works when everything else works is not monitoring, it is a happy-path logger with a dashboard attached.
When this is the wrong tool. If you need session replay, stack traces symbolicated from uploaded source maps, alerting with on-call routing, or a hosted dashboard that somebody else maintains, a hosted SDK will get you there faster and this library will not pretend otherwise. It captures and stores browser-side failures and hands you a report. Turning those records into a page at 3am is a pipeline you own. That list of non-goals is written down in the README rather than left for you to discover.
Bonus tips. One per level.
Beginner: pass extra and tags explicitly instead of dumping an object into a payload. The sanitizer will scrub the values, but it cannot invent the route or the order id you never told it about.
Intermediate: set logs.consoleLevel at build time, 'off' in production and 'debug' in development. The console level and the persist level are separate, so silencing the console does not silence the vault, and initialising telemetry does not change your console output on its own.
Advanced: use errors.beforeCapture for noise you have genuinely decided to ignore, and use a registered context builder for classification your application already knows. Neither should be used to hide an error category you have not investigated yet.
Recap.
- The reporter runs inside the failure path, so the first requirement is that it cannot throw, and hostile values are the normal case rather than the edge case.
- Local-first storage with an outbox on top is what makes a flush at
pagehideand a retry after a network outage the same mechanism. - Redaction belongs before the write, not before the upload, and the strongest version of that claim is reading less in the first place.
- Aggregation stops the row count exploding, but a per-fingerprint limiter is what stops one repeating failure from eating the budget that new failures need.
- A report that a support engineer can open, read and forward is the actual deliverable. Everything else is plumbing that makes it possible.
What did you think?
Did this approach match how you are solving it, or do you have a different take? Drop a comment. I genuinely read every single one.
Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too. It really does make a difference.
Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.
Let us connect. Find me on LinkedIn or GitHub and let us keep the conversation going.
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit — Developer blog posts & tech discussions
Top comments (0)