The Pomodoro Timer That Survives You Closing the Tab
DeepWork is a free focus timer, task manager and calendar for Windows, macOS, Linux and the browser, and it keeps every record on your own disk
It is 2:47 in the afternoon and you have fourteen tabs open. One of them is a focus timer. You started a session in it an hour ago, then closed it along with the rest, because that is what you do with tabs.
Ninety seconds later you cannot remember whether you were twenty minutes in or forty. The session is gone. So is the intention behind it.
That is not a productivity problem. That is a software problem, and it is the one this article is about.
Why every focus timer I have tried ends up closed
The pattern is always the same shape, and I have been through it enough times to recognise it early.
You find a timer. It is beautiful. It asks you to create an account, or it asks you to pick a plan, or it explains that your sessions are syncing to the cloud so you can pick up on your phone. You enter your first task. You start a block. Somewhere in the second block a notification arrives telling you to hydrate, or that a teammate has commented on something.
By Thursday you have muted it. By Friday you have stopped opening it.
The timers that survive past a week tend to share one property: they behave like software on your computer rather than like a website you are visiting. They keep running when the window is small. They remember what you were doing. They stay quiet until the timer actually ends.
That is what I wanted, so I built it. It is called DeepWork, it is free and open source under the MIT licence, and the rest of this article is what it does and why you might want it.
If you already know you want to look at it, open the web app in the next thirty seconds and come back. Nothing to install, no account, no email field. The rest will still be here. And if you want to know when the next release lands, a follow is the cheapest way to find out.
The one decision that shaped everything
DeepWork has no account system. There is no sign in, no profile, no server of its own. Everything it knows lives in a SQLite database on the machine you are sitting at, in the folder your operating system keeps for applications.
I want to be precise about what that buys you, because “local first” gets used loosely.
It means your focus history for the last six months cannot disappear because a company changed its pricing. It means the app opens on a plane, with the Wi-Fi off, on the version you installed. It means nobody is A/B testing a redesign of your timer. And it means the only person who can read your journal entries is you, because there is nowhere for them to go.
Three things leave the machine, and I would rather list them than let you wonder. There is a once-per-startup check against GitHub to see whether a newer version exists. There is an installer download, but only if you press Update. And there are the links you click, which open in your real browser. That is the whole list. The browser build adds a visitor counter so the page can answer “is anyone actually using this”, and there is a switch in Settings that turns it off completely.
The trade for all of this is in a later section, and it is a real one. First, though, let me show you what a day with it looks like.
A day with DeepWork, from the first block to the last
I will not walk you through every screen. Ten pages is too many to describe in a row, and reading a feature list is not how anyone decides to use something. Instead, here is the day the app is shaped around.
Morning: work out what actually matters
You open DeepWork and land on the Dashboard. Twenty five minutes on the clock, a ring waiting to fill, a daily goal of eight sessions, and the tasks you have booked for today underneath it.
Behind that is the page I end up on most mornings. The Matrix is the Eisenhower grid, four quadrants and drag and drop between them. Do First, Schedule, Delegate, Eliminate. Dragging a card from Schedule to Do First is a fifteen second decision that saves you an hour of pretending everything is equally urgent.
The other thing that happens in the morning is the Today page. A task lands on today for one of three reasons: it is due today, it is overdue and still open, or you starred it. Starring is the override, and it is how you say “this one, today, whatever the date says”.
The block itself: a timer that rings until you answer it
You press Start, or you press Space if you are not typing in a field. The ring fills. Short break after the first three blocks, a longer one after the fourth, and you can change all of those lengths in Settings if twenty five and five are not your numbers.
When the block ends, the app does not send one polite chime and hope you noticed. It rings, shows a card, and posts a system notification, and then it repeats every interval you chose until you answer. Start, skip and stop all count as answers.
Two details here are the kind of thing you only notice after a week. Every repeat moves to the next colour of a twelve entry palette, so the alert you have been ignoring for six minutes is visibly a different colour than the one that started. And every alert carries its own line of text, drawn one per session from a list of twenty four for a finished focus block and twenty four more for the end of a break. Step away. Come back. It never shows you the same sentence twice in a row.
Ticking a task off is deliberately quieter. A card appears with a line from a third list, and no sound at all. You tick off tasks dozens of times a day; an app that rang for each one would train you to mute it, and then the session alert would be muted too.
The middle of the day: shrink it and forget it
This is the feature I would keep above all the others. DeepWork does not disappear into your other windows. Press minimise on any page and it does not vanish into the taskbar. It shrinks into a small always on top widget, 136 by 76 pixels, with the countdown ring, the time, and play, skip, stop and expand.
It sits in the corner over your editor while you actually do the work. You can start and pause without bringing the full window back. Press Escape and it grows back to exactly where it was.
And when you press the close button, the app does not quit. It goes to the system tray and keeps running, so a session can finish and ring while the window does not exist. Tray then Exit is what actually quits, and if you prefer the close button to quit, that is a setting.
The first launch asks you about three of these things before changing anything: start with the system, stay always on top, keep running in the tray. Nothing is enabled unless you say so. No installer writes a startup entry behind your back.
Afternoon: the calendar that schedules in pomodoros
The Calendar page is the one people underestimate. It shows you the day as events, and then does the scheduling arithmetic on top of it. It takes your quadrant order and lays your day out as focus blocks, at the block length and long break cadence your timer uses, so the day is planned in the same unit it is counted in.
A task that needs three blocks gets three. The breaks are reserved and never filled with work, which is the whole point of a break. If you drop something into a specific slot by hand, that slot is fixed and everything else flows around it.
Somewhere in the afternoon the water reminder asks you a question. It is on by default, it only fires inside the working hours you set, and it waits for an answer instead of announcing a fact. One glass counts as 30 millilitres by default, because the smallest thing anyone would call a drink is the honest unit for a day of sipping. If you minimise the window it logs the glass and leaves you alone rather than pulling the full app over your work.
The Habits page sits next to all this with current streaks, personal bests and a check-in strip per habit. Nothing clever. Just the thing that makes you not want to break the chain.
Evening: the part that tells you what to change
Most productivity apps hand you charts and let you draw the conclusion. Analytics in DeepWork pairs every number with a plain language reading of it.
Focus over the last seven days, with a hint telling you what the seven days before that looked like. Tasks closed, with a total underneath. Focus streak, with the number of active days behind it. Completion rate over thirty days, next to how many tasks are still open and how many are overdue. Habit consistency. Journal words over twelve weeks.
Those readings are arithmetic over your own records, computed on your machine, so the same week always produces the same reading. You can check any of them by opening the records they came from.
Then the Journal. Dated entries with word counts, a weekly words chart, and one figure I did not expect to find useful: the correlation between your writing and your focus. More words on the days you worked more blocks is either a good sign or a warning, and it is more useful to know which.
The details you find by poking around
Feature lists are boring. These are the things that made me keep a copy installed.
Excel import that shows you what it will do before it does it. Tasks, then Import, accepts .xlsx, .xlsm and .csv, recognises the headers people actually write (Task, Due Date, Prio, Labels), validates every row, and writes nothing until you confirm the preview. Duplicate titles are labelled, and you choose whether to skip them. There is a download template with real dropdowns and an instructions sheet, if you want your spreadsheet to be valid before you start.
Export that tells you where the file went. Twenty four columns per task, quick ranges like Today and Last 7 days, an optional totals block, and UTF-8 with a BOM so accents and non Latin scripts survive Excel. It writes into your Downloads folder, shows you the full path, and never overwrites: the second export of the day becomes export-2026-09-13 (2).csv.
More than twenty keyboard shortcuts. Ctrl+1 through Ctrl+0 jump between pages. Ctrl+Shift+F is focus mode, which is the clock and nothing else. Space starts and pauses. With a task card focused, 1, 2 and 3 move it between To Do, In Progress and Done, the arrow keys walk it along the board, and Enter opens it for editing.
Editing from wherever you are reading. Every card that shows a task carries the way into the same task form: a pencil on a board card, a pencil on a Matrix quadrant, one on a Calendar rail card, and Edit at the top of the Matrix right click menu. You never have to go and find the page that owns the task.
A backup you can actually restore. Settings writes a single dated JSON file with your sessions, tasks, habits, journal and water records. Importing it overwrites what you have, after a confirmation, because it is a restore and not a merge.
Diagnostics for when something breaks. Settings then Logs and Diagnostics shows you the app’s own logs, opens the folder, and has a Copy diagnostics button that puts the last three hundred lines plus your environment on the clipboard. If you ever report a bug, that is the thing to paste.
Themes, fonts and no network. System, light and dark themes, two bundled typefaces, and no font CDN, so it looks like itself offline or behind a company proxy.
What it is not, and the trade you are making
I would rather you decide with the whole picture.
It does not sync. Each machine has its own database. Moving your work between two computers means exporting the JSON on one and importing it on the other. There is no backend, and nothing arrives on your other device by itself.
It is one person’s app. One developer, a public changelog, and issues on GitHub. There is no support team and no roadmap meeting.
The installers are not code signed. This is the one that makes people nervous, so here is exactly what happens. On Windows you will see “Windows protected your PC” from SmartScreen, and the UAC dialog will say Unknown publisher. On macOS, Gatekeeper will refuse the first launch with a message about not being able to verify the app. Both are the absence of a signature worth several hundred dollars a year, not a sign of a broken download. If a release publishes a SHA-256 checksum, compare it before installing.
The web build is the least private option of the three. Anything you do in a browser tab lives in that origin’s localStorage, and clearing site data erases it. If you want the real database, install the desktop app.
It is not a team tool. No sharing, no assignment, no comments, no collaboration. One person, one database, by design.
If any of those are deal breakers, that is a fair answer, and the web app is still there for you to try before deciding.
How to get it in the next five minutes
The fastest route is the browser. Open deepwork.rajatmalik.dev, and if you like it, install it as an app from your browser’s menu. In Chrome or Edge that is the install icon in the address bar. In Safari 17 or newer it is File, then Add to Dock. Firefox cannot install web apps, so use the desktop build there.
The real thing is the desktop build, and it is on the releases page. The source, the changelog and every issue live in the same repository at github.com/malikrajat/deepwork.
- Windows 10 and newer, 64 bit or 32 bit: an NSIS .exe setup or an .msi.
- macOS: a .dmg, with separate builds for Intel and Apple Silicon.
- Linux: an .AppImage, a .deb or an .rpm.
There is also a page for it at deepwork.rajatmalik.dev.
Because nothing is signed, here is what gets you past it.
# Linux: make the AppImage runnable
chmod +x DeepWork_*.AppImage
# macOS 15 and later: clear the quarantine flag after the first refusal
xattr -dr com.apple.quarantine /Applications/DeepWork.app
On Windows, choose More info, then Run anyway, only if you trust the source. On macOS, the old Control click trick no longer works on recent versions; open System Settings, Privacy and Security, Security, and use Open Anyway after the first attempt.
Then let the first launch ask you its three questions, answer them honestly, and start a block.
A ten minute tour, if you want the short version
If you install it and then wonder where to begin, do these in order. It is the fastest way to find out whether it fits how you work.
1. Press Ctrl+2 and add three tasks, starring the one you would do first if the day went well.
2. Press Ctrl+3 and drag those three into quadrants honestly, not optimistically.
3. Press Ctrl+4 and look at what your day becomes when it is laid out as blocks.
4. Go back to the Dashboard, press Space, and then press minimise. Watch what happens.
5. Let the block finish and ignore the alert for two minutes. Notice the colour has changed.
6. Press Ctrl+6 and read the hint under each number.
7. Open Settings and change one thing you dislike.
Seven steps, ten minutes, and by the end you will know whether the shape of this app matches the shape of your day. If it does not, uninstall it; there is no account to delete and nothing to cancel.
If something is missing, say so
This is the part where a one person app genuinely needs you.
It is on its sixth tagged release, and the changelog in the repository is longer than this article. A good number of those fixes came from using it daily and writing down what annoyed me. The ones that did not came from people telling me.
So if you try it and something is wrong, the most useful things to send are concrete. What you did, what you expected, what happened instead, and which page you were on. If it is a crash or something looks broken, use Settings, Logs and Diagnostics, Copy diagnostics, and paste that in. It saves an hour of guessing.
Feature requests are welcome too, and the honest answer is sometimes no. If it is a no, you will get a why.
The two places to put it:
- An issue at github.com/malikrajat/deepwork/issues
- An email to support@rajatmalik.dev
If you would rather just say it worked, that is also useful. Knowing which features people actually use is how the next version gets decided.
Have you ever abandoned a productivity app because it asked for an account on day one? I would like to know what pushed you off, because that list is the one I am trying to avoid.
Back to that tab
It is 2:47 in the afternoon and you have fourteen tabs open. One of them is DeepWork, because you installed it, and closing the tab does not matter any more.
The window is small, in the corner, over your editor. The ring is two thirds full. Your three tasks for today are on the board, one of them is in progress, and the fourth block is scheduled for after the break. The app is not asking you for anything.
When the block ends, it will ring, and it will keep ringing, and it will not stop until you decide you are done with it. That is the whole promise. The timer is the only thing that has to stop.
If you try it
Tell me how it went. Did the ten minute tour match how you actually work, or did the app get in your way? Leave a comment, and if you found a bug, the issue link above is the fastest route. I read everything.
Found this useful? If it saved you from another timer that forgets you the moment you close the tab, hit the clap button so more people find it. It genuinely helps a one person project more than it does a big one.
Want the next one? I write about the tools I build and the decisions behind them, roughly once a week. Follow me here on Medium, where I am @codewithrajat, or subscribe to the newsletter so you do not miss the next release.
Let us connect. Find me on LinkedIn or GitHub and tell me what your day actually looks like. And if you know someone whose focus timer lives in a tab they keep closing, send them this article.
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
Browser Error Tracking: The Errors Your SDK Never Sends
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 pagehide and 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
Mentions
The Pomodoro Timer That Survives You Closing the Tab DeepWork is a free focus timer, task manager and calendar for Windows, macOS, Linux and the browser, and it keeps every record on your own disk It is 2:47 in the afternoon and you have fourteen tabs open. One of them is a focus timer. You started a session in it an hour ago, then closed it along with the rest, because that is what you do with tabs. Ninety seconds later you cannot remember whether you were twenty minutes in or forty. The session is gone. So is the intention behind it. That is not a productivity problem. That is a software problem, and it is the one this article is about. Why every focus timer I have tried ends up closed The pattern is always the same shape, and I have been through it enough times to recognise it early. You find a timer. It is beautiful. It asks you to create an account, or it asks you to pick a plan, or it explains that your sessions are syncing to the cloud so you can pick up on your phone. You enter your first task. You start a block. Somewhere in the second block a notification arrives telling you to hydrate, or that a teammate has commented on something. By Thursday you have muted it. By Friday you have stopped opening it. The timers that survive past a week tend to share one property: they behave like software on your computer rather than like a website you are visiting. They keep running when the window is small. They remember what you were doing. They stay quiet until the timer actually ends. That is what I wanted, so I built it. It is called DeepWork, it is free and open source under the MIT licence, and the rest of this article is what it does and why you might want it. If you already know you want to look at it, open the web app in the next thirty seconds and come back. Nothing to install, no account, no email field. The rest will still be here. And if you want to know when the next release lands, a follow is the cheapest way to find out. The one decision that shaped everything DeepWork has no account system. There is no sign in, no profile, no server of its own. Everything it knows lives in a SQLite database on the machine you are sitting at, in the folder your operating system keeps for applications. I want to be precise about what that buys you, because “local first” gets used loosely. It means your focus history for the last six months cannot disappear because a company changed its pricing. It means the app opens on a plane, with the Wi-Fi off, on the version you installed. It means nobody is A/B testing a redesign of your timer. And it means the only person who can read your journal entries is you, because there is nowhere for them to go. Three things leave the machine, and I would rather list them than let you wonder. There is a once-per-startup check against GitHub to see whether a newer version exists. There is an installer download, but only if you press Update. And there are the links you click, which open in your real browser. That is the whole list. The browser build adds a visitor counter so the page can answer “is anyone actually using this”, and there is a switch in Settings that turns it off completely. The trade for all of this is in a later section, and it is a real one. First, though, let me show you what a day with it looks like. A day with DeepWork, from the first block to the last I will not walk you through every screen. Ten pages is too many to describe in a row, and reading a feature list is not how anyone decides to use something. Instead, here is the day the app is shaped around. Morning: work out what actually matters You open DeepWork and land on the Dashboard. Twenty five minutes on the clock, a ring waiting to fill, a daily goal of eight sessions, and the tasks you have booked for today underneath it. Behind that is the page I end up on most mornings. The Matrix is the Eisenhower grid, four quadrants and drag and drop between them. Do First, Schedule, Delegate, Eliminate. Dragging a card from Schedule to Do First is a fifteen second decision that saves you an hour of pretending everything is equally urgent. The other thing that happens in the morning is the Today page. A task lands on today for one of three reasons: it is due today, it is overdue and still open, or you starred it. Starring is the override, and it is how you say “this one, today, whatever the date says”. The block itself: a timer that rings until you answer it You press Start, or you press Space if you are not typing in a field. The ring fills. Short break after the first three blocks, a longer one after the fourth, and you can change all of those lengths in Settings if twenty five and five are not your numbers. When the block ends, the app does not send one polite chime and hope you noticed. It rings, shows a card, and posts a system notification, and then it repeats every interval you chose until you answer. Start, skip and stop all count as answers. Two details here are the kind of thing you only notice after a week. Every repeat moves to the next colour of a twelve entry palette, so the alert you have been ignoring for six minutes is visibly a different colour than the one that started. And every alert carries its own line of text, drawn one per session from a list of twenty four for a finished focus block and twenty four more for the end of a break. Step away. Come back. It never shows you the same sentence twice in a row. Ticking a task off is deliberately quieter. A card appears with a line from a third list, and no sound at all. You tick off tasks dozens of times a day; an app that rang for each one would train you to mute it, and then the session alert would be muted too. The middle of the day: shrink it and forget it This is the feature I would keep above all the others. DeepWork does not disappear into your other windows. Press minimise on any page and it does not vanish into the taskbar. It shrinks into a small always on top widget, 136 by 76 pixels, with the countdown ring, the time, and play, skip, stop and expand. It sits in the corner over your editor while you actually do the work. You can start and pause without bringing the full window back. Press Escape and it grows back to exactly where it was. And when you press the close button, the app does not quit. It goes to the system tray and keeps running, so a session can finish and ring while the window does not exist. Tray then Exit is what actually quits, and if you prefer the close button to quit, that is a setting. The first launch asks you about three of these things before changing anything: start with the system, stay always on top, keep running in the tray. Nothing is enabled unless you say so. No installer writes a startup entry behind your back. Afternoon: the calendar that schedules in pomodoros The Calendar page is the one people underestimate. It shows you the day as events, and then does the scheduling arithmetic on top of it. It takes your quadrant order and lays your day out as focus blocks, at the block length and long break cadence your timer uses, so the day is planned in the same unit it is counted in. A task that needs three blocks gets three. The breaks are reserved and never filled with work, which is the whole point of a break. If you drop something into a specific slot by hand, that slot is fixed and everything else flows around it. Somewhere in the afternoon the water reminder asks you a question. It is on by default, it only fires inside the working hours you set, and it waits for an answer instead of announcing a fact. One glass counts as 30 millilitres by default, because the smallest thing anyone would call a drink is the honest unit for a day of sipping. If you minimise the window it logs the glass and leaves you alone rather than pulling the full app over your work. The Habits page sits next to all this with current streaks, personal bests and a check-in strip per habit. Nothing clever. Just the thing that makes you not want to break the chain. Evening: the part that tells you what to change Most productivity apps hand you charts and let you draw the conclusion. Analytics in DeepWork pairs every number with a plain language reading of it. Focus over the last seven days, with a hint telling you what the seven days before that looked like. Tasks closed, with a total underneath. Focus streak, with the number of active days behind it. Completion rate over thirty days, next to how many tasks are still open and how many are overdue. Habit consistency. Journal words over twelve weeks. Those readings are arithmetic over your own records, computed on your machine, so the same week always produces the same reading. You can check any of them by opening the records they came from. Then the Journal. Dated entries with word counts, a weekly words chart, and one figure I did not expect to find useful: the correlation between your writing and your focus. More words on the days you worked more blocks is either a good sign or a warning, and it is more useful to know which. The details you find by poking around Feature lists are boring. These are the things that made me keep a copy installed. Excel import that shows you what it will do before it does it. Tasks, then Import, accepts .xlsx, .xlsm and .csv, recognises the headers people actually write (Task, Due Date, Prio, Labels), validates every row, and writes nothing until you confirm the preview. Duplicate titles are labelled, and you choose whether to skip them. There is a download template with real dropdowns and an instructions sheet, if you want your spreadsheet to be valid before you start. Export that tells you where the file went. Twenty four columns per task, quick ranges like Today and Last 7 days, an optional totals block, and UTF-8 with a BOM so accents and non Latin scripts survive Excel. It writes into your Downloads folder, shows you the full path, and never overwrites: the second export of the day becomes export-2026-09-13 (2).csv. More than twenty keyboard shortcuts. Ctrl+1 through Ctrl+0 jump between pages. Ctrl+Shift+F is focus mode, which is the clock and nothing else. Space starts and pauses. With a task card focused, 1, 2 and 3 move it between To Do, In Progress and Done, the arrow keys walk it along the board, and Enter opens it for editing. Editing from wherever you are reading. Every card that shows a task carries the way into the same task form: a pencil on a board card, a pencil on a Matrix quadrant, one on a Calendar rail card, and Edit at the top of the Matrix right click menu. You never have to go and find the page that owns the task. A backup you can actually restore. Settings writes a single dated JSON file with your sessions, tasks, habits, journal and water records. Importing it overwrites what you have, after a confirmation, because it is a restore and not a merge. Diagnostics for when something breaks. Settings then Logs and Diagnostics shows you the app’s own logs, opens the folder, and has a Copy diagnostics button that puts the last three hundred lines plus your environment on the clipboard. If you ever report a bug, that is the thing to paste. Themes, fonts and no network. System, light and dark themes, two bundled typefaces, and no font CDN, so it looks like itself offline or behind a company proxy. What it is not, and the trade you are making I would rather you decide with the whole picture. It does not sync. Each machine has its own database. Moving your work between two computers means exporting the JSON on one and importing it on the other. There is no backend, and nothing arrives on your other device by itself. It is one person’s app. One developer, a public changelog, and issues on GitHub. There is no support team and no roadmap meeting. The installers are not code signed. This is the one that makes people nervous, so here is exactly what happens. On Windows you will see “Windows protected your PC” from SmartScreen, and the UAC dialog will say Unknown publisher. On macOS, Gatekeeper will refuse the first launch with a message about not being able to verify the app. Both are the absence of a signature worth several hundred dollars a year, not a sign of a broken download. If a release publishes a SHA-256 checksum, compare it before installing. The web build is the least private option of the three. Anything you do in a browser tab lives in that origin’s localStorage, and clearing site data erases it. If you want the real database, install the desktop app. It is not a team tool. No sharing, no assignment, no comments, no collaboration. One person, one database, by design. If any of those are deal breakers, that is a fair answer, and the web app is still there for you to try before deciding. How to get it in the next five minutes The fastest route is the browser. Open deepwork.rajatmalik.dev, and if you like it, install it as an app from your browser’s menu. In Chrome or Edge that is the install icon in the address bar. In Safari 17 or newer it is File, then Add to Dock. Firefox cannot install web apps, so use the desktop build there. The real thing is the desktop build, and it is on the releases page. The source, the changelog and every issue live in the same repository at github.com/malikrajat/deepwork. - Windows 10 and newer, 64 bit or 32 bit: an NSIS .exe setup or an .msi. - macOS: a .dmg, with separate builds for Intel and Apple Silicon. - Linux: an .AppImage, a .deb or an .rpm. There is also a page for it at deepwork.rajatmalik.dev. Because nothing is signed, here is what gets you past it. # Linux: make the AppImage runnable chmod +x DeepWork_*.AppImage # macOS 15 and later: clear the quarantine flag after the first refusal xattr -dr com.apple.quarantine /Applications/DeepWork.app On Windows, choose More info, then Run anyway, only if you trust the source. On macOS, the old Control click trick no longer works on recent versions; open System Settings, Privacy and Security, Security, and use Open Anyway after the first attempt. Then let the first launch ask you its three questions, answer them honestly, and start a block. A ten minute tour, if you want the short version If you install it and then wonder where to begin, do these in order. It is the fastest way to find out whether it fits how you work. 1. Press Ctrl+2 and add three tasks, starring the one you would do first if the day went well. 2. Press Ctrl+3 and drag those three into quadrants honestly, not optimistically. 3. Press Ctrl+4 and look at what your day becomes when it is laid out as blocks. 4. Go back to the Dashboard, press Space, and then press minimise. Watch what happens. 5. Let the block finish and ignore the alert for two minutes. Notice the colour has changed. 6. Press Ctrl+6 and read the hint under each number. 7. Open Settings and change one thing you dislike. Seven steps, ten minutes, and by the end you will know whether the shape of this app matches the shape of your day. If it does not, uninstall it; there is no account to delete and nothing to cancel. If something is missing, say so This is the part where a one person app genuinely needs you. It is on its sixth tagged release, and the changelog in the repository is longer than this article. A good number of those fixes came from using it daily and writing down what annoyed me. The ones that did not came from people telling me. So if you try it and something is wrong, the most useful things to send are concrete. What you did, what you expected, what happened instead, and which page you were on. If it is a crash or something looks broken, use Settings, Logs and Diagnostics, Copy diagnostics, and paste that in. It saves an hour of guessing. Feature requests are welcome too, and the honest answer is sometimes no. If it is a no, you will get a why. The two places to put it: - An issue at github.com/malikrajat/deepwork/issues - An email to support@rajatmalik.dev If you would rather just say it worked, that is also useful. Knowing which features people actually use is how the next version gets decided. Have you ever abandoned a productivity app because it asked for an account on day one? I would like to know what pushed you off, because that list is the one I am trying to avoid. Back to that tab It is 2:47 in the afternoon and you have fourteen tabs open. One of them is DeepWork, because you installed it, and closing the tab does not matter any more. The window is small, in the corner, over your editor. The ring is two thirds full. Your three tasks for today are on the board, one of them is in progress, and the fourth block is scheduled for after the break. The app is not asking you for anything. When the block ends, it will ring, and it will keep ringing, and it will not stop until you decide you are done with it. That is the whole promise. The timer is the only thing that has to stop. If you try it Tell me how it went. Did the ten minute tour match how you actually work, or did the app get in your way? Leave a comment, and if you found a bug, the issue link above is the fastest route. I read everything. Found this useful? If it saved you from another timer that forgets you the moment you close the tab, hit the clap button so more people find it. It genuinely helps a one person project more than it does a big one. Want the next one? I write about the tools I build and the decisions behind them, roughly once a week. Follow me here on Medium, where I am @codewithrajat, or subscribe to the newsletter so you do not miss the next release. Let us connect. Find me on LinkedIn or GitHub and tell me what your day actually looks like. And if you know someone whose focus timer lives in a tab they keep closing, send them this article. 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
🚨 Your users hit errors. You never hear about them. rm-logvault fixes that in 3 lines: 0 dependencies no server errors + logs → IndexedDB secrets redacted before write Ctrl+Shift+Alt+D → 1 HTML report npm i @codewithrajat/rm-logvault ⭐ malikrajat/rm-logvault
Browser Error Tracking: The Errors Your SDK Never Sends 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 pagehide and 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