JavaScript and React error tracking
A bug in your backend shows up in your logs. A bug in the browser happens on someone else's machine, and unless the page reports it, you never hear about it. The user sees a blank cart, reloads, and leaves.
This post shows how to report front-end errors to ZipLogger with the @ziplogger/browser package: uncaught errors, unhandled promise rejections, errors you catch yourself, and React render errors. The payloads below are real output from the SDK, captured against a local test server.
Install and create one client
npm install @ziplogger/browser
Create the client once, in its own module, and import it everywhere:
// ziplogger.js
import { ZipLoggerBrowser } from '@ziplogger/browser'
export const ziplogger = new ZipLoggerBrowser({
endpoint: 'https://app.ziplogger.ai',
apiKey: 'YOUR_BROWSER_API_KEY',
source: 'storefront',
release: import.meta.env.VITE_APP_VERSION,
commitSha: import.meta.env.VITE_COMMIT_SHA,
})
ziplogger.captureGlobalErrors()
Anything shipped to a browser can be read by anyone who opens devtools, so use a separate key for it. In ZipLogger, go to Settings → API keys, give the key a name, and leave Can read data unticked. That makes it an ingest key: it can send telemetry but cannot read anything back. You can revoke it without touching your backend keys.
source defaults to the page's hostname. environment defaults to production. The release and commitSha values come from your build; the two VITE_ names above are just an example.
This is a field on each line. Which ZipLogger environment the line is stored in is decided by the API key, not by this value. See Separate staging and production logs.
Uncaught errors and promise rejections
captureGlobalErrors() listens for the window error and unhandledrejection events. Call it as early as you can so startup errors are not missed. It returns a function that removes the listeners.
Here is what arrives when code reads .items off a null cart:
{
"severity": "error",
"message": "Uncaught TypeError: Cannot read properties of null (reading 'items')",
"source": "storefront",
"release": "2026.9.29",
"commitSha": "a1b2c3d",
"stackTrace": "TypeError: Cannot read properties of null (reading 'items')\n at renderCart (...)",
"fields": {
"environment": "production",
"file": "https://shop.example.com/assets/index-4f2a91.js",
"line": 1,
"column": 48213,
"handler": "window.onerror",
"url": "https://shop.example.com/checkout",
"userAgent": "Mozilla/5.0 (...)",
"exceptionType": "TypeError",
"exceptionMessage": "Cannot read properties of null (reading 'items')"
}
}
The url and userAgent fields are added to every line. Turn that off with includePageContext: false. When there is an Error object, its stack becomes stackTrace and its name and message become exceptionType and exceptionMessage.
A rejected promise nobody handled arrives as Unhandled rejection: Failed to fetch, with handler: "unhandledrejection" and the error's stack.
Errors you catch yourself
When you handle an error but still want to know about it, use captureError and pass whatever context helps:
try {
restoreCart(JSON.parse(saved))
} catch (err) {
ziplogger.captureError(err, { step: 'restore-cart', cartId: cart.id })
showEmptyCart()
}
The fields go into fields next to the page context, so step and cartId become searchable. For anything that is not an error, ziplogger.log({ severity: 'warn', message: '...', fields: {...} }) sends a plain log line.
React error boundaries
A render error in React unmounts the whole tree unless an error boundary catches it. The SDK has a boundary in @ziplogger/browser/react. It takes your copy of React as an argument, so the package itself has no React dependency:
import React from 'react'
import { createErrorBoundary, createUseZipLogger } from '@ziplogger/browser/react'
import { ziplogger } from './ziplogger'
const ErrorBoundary = createErrorBoundary(React, ziplogger)
export const useZipLogger = createUseZipLogger(React, ziplogger)
export function CheckoutPage() {
return (
<ErrorBoundary name="checkout" fallback={<p>Something went wrong with checkout.</p>}>
<Checkout />
</ErrorBoundary>
)
}
The boundary accepts fallback, name and an optional onError(error, info) callback. When it catches something, it logs this (trimmed to the interesting fields):
{
"severity": "error",
"message": "React error boundary: Cannot read properties of undefined (reading 'price')",
"stackTrace": "TypeError: Cannot read properties of undefined (reading 'price')\n at ...",
"fields": {
"componentStack": "at PriceTag (https://shop.example.com/assets/index-4f2a91.js:1:9921)\n at CartRow\n at Checkout",
"boundary": "checkout",
"exceptionType": "TypeError",
"exceptionMessage": "Cannot read properties of undefined (reading 'price')"
}
}
The componentStack tells you where in the tree it broke, which the JavaScript stack alone often does not. Put boundaries around separate parts of the page, not only at the root. Then a broken price tag takes down the cart, not the whole page, and boundary tells you which part failed.
Error boundaries only catch errors thrown while rendering. Errors in event handlers, timers and async code never reach them. That is how React works, not a ZipLogger limit. Those errors still reach captureGlobalErrors() if nothing catches them. Inside components, useZipLogger() gives you captureError, log, track and identify.
Using Vue, Svelte or Angular? The core client has no framework dependency. Call ziplogger.captureError(err) from the framework's own error hook.
Finding them in ZipLogger
Errors with an exception, such as the ones captureGlobalErrors() and captureError() send, are grouped on the Issues page. Repeats of one error are one row, with a count, the releases it appeared in, and actions to assign and resolve it. To count affected users, pass the user id in the fields: ziplogger.captureError(err, { userId: user.id }). captureGlobalErrors() takes no fields, so the Issues it creates show users as unknown. The error tracking docs explain how errors are grouped.
Browser errors are also log lines, so they land on the Search page with everything else. Pick error in the severity filter, then open Fields and narrow it to your source. Expand a row to see its JSON fields (each has buttons to keep only that value or exclude it), then the Log fields and the Stack trace. Investigate on the expanded row shows what is connected to that line: its trace, related events, the release and any incident around it.
A single bug can produce thousands of lines, one per visitor. Switch Search to Patterns to group lines by message shape, so one broken component is one row with a count. The pattern clustering post explains how the grouping works. If you also record sessions, a replay shows what the user did before the error. See the session replay post.
What it does not do
These limits matter if you are comparing it with a dedicated error tracker such as Sentry. If your app already uses the Sentry browser SDK, you can also keep it and change only its DSN; see migrating from Sentry for what carries over.
- Source maps are applied when you view an error, not when it is stored. ZipLogger keeps the stack trace exactly as the browser sent it, with frames like
index-4f2a91.js:1:48213. Upload your build's source maps from CI under the samereleaseyou pass to the SDK, and the Issue page shows the original file, line and code (how). The Issue list and the find the commit that broke production feature still use the minified frames, so blame cannot work on minified code, even withcommitShaset. - "Script error." with no detail. When a script loaded from another origin throws, the browser hides the message and stack from the page. The SDK receives
Script error., with an emptyfileandline: 0, and sends exactly that. To get the real error, serve the script withAccess-Control-Allow-Originand load it withcrossorigin="anonymous". - Rejections with no Error.
Promise.reject('timeout')arrives asUnhandled promise rejectionwith no stack, and the rejected value is not included. With no stack it does not form an Issue either. Reject withnew Error(...). - Failed resource loads are not captured. A broken image or script tag fires an
errorevent that does not reach the window listener. - No deduplication in the browser. An error thrown on every frame is sent every time. On the server the repeats are grouped into one Issue, but each one is still a log line. The SDK buffers up to 1,000 records (
queueCapacity) and drops the rest, counted inziplogger.dropped. Errors count toward your plan's log volume like any other line; see pricing. - No automatic breadcrumbs. The SDK does not record clicks or console output. Add context yourself with
fields, or record replays.
Failed network calls are a separate feature: instrumentFetch() logs any request that returns HTTP 400 or above, or fails outright, with a trace id linking it to the backend. That is covered in tracing a click from the browser to the backend.
Delivery
The SDK queues records and sends them in batches of 20, or after 3 seconds, as NDJSON to /ingest/v1/logs. It never throws into your code. When the tab is hidden or closed, it flushes with fetch(..., { keepalive: true }) so the last errors still get out. A send that fails with a network error, 408, 429 or 5xx is retried twice, honouring Retry-After. The final flush on page close is not retried.
If your site sends a Content Security Policy, allow the endpoint, for example connect-src 'self' https://app.ziplogger.ai. Without that, the browser blocks every send.
The full option list is in the browser docs. To try it on your own app, create a free workspace, make an ingest key, and add a deliberate throw to a test page.