Blog Finding the cause

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 same release you 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 with commitSha set.
  • "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 empty file and line: 0, and sends exactly that. To get the real error, serve the script with Access-Control-Allow-Origin and load it with crossorigin="anonymous".
  • Rejections with no Error. Promise.reject('timeout') arrives as Unhandled promise rejection with no stack, and the rejected value is not included. With no stack it does not form an Issue either. Reject with new Error(...).
  • Failed resource loads are not captured. A broken image or script tag fires an error event 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 in ziplogger.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.

Release health: crash-free sessions per release

See crash-free sessions, crash-free users and adoption for each release, from Sentry SDKs or ZipLogger's own SDKs, and get alerted when a release crashes more.

Use the Sentry SDK with ZipLogger

Keep your Sentry SDK and send its errors, traces and logs to ZipLogger by changing the DSN. Node, browser and Python setups, and what is dropped.

Trace a click from the browser to the backend

Send a W3C traceparent header from the browser so your backend's OpenTelemetry spans join the same trace. CORS setup and real output included.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.