React

BugWatch for React (SPA)link

This page covers wiring @newinstance/bugwatch into a client-rendered React app (Vite, CRA, React Router, TanStack Router). If you are on Next.js, use @newinstance/bugwatch/next/client instead.

Fetching data with TanStack Query? Add @newinstance/bugwatch-tanstack on top of this and every failed query and mutation is reported automatically, with a breadcrumb trail and a span per request. See TanStack Query.

npm install @newinstance/bugwatch

Read this first: the browser security modellink

Everything you ship to the browser is public. Your projectKey (<keyId>:<secret>) is a secret and must never appear in React code, in a VITE_ env var, or in any bundle. Anyone who reads it can ingest arbitrary events under your account.

The browser SDK authenticates with a short-lived session token instead:

  1. Your backend holds the projectKey and exposes a mint endpoint, for example GET /bugwatch/session, returning { token, expiresAt }.
  2. The browser SDK is configured with sessionUrl pointing at that endpoint.
  3. On first send the SDK fetches a token, caches it, refreshes it shortly before expiry and again on a 401, and posts events to https://api.newinstance.cloud/api/v1/bugwatch/ingest/browser with the x-bugwatch-session header.

Your secret never leaves your server. Full details are in the SDK README under Browser Ingest page.

// WRONG. Never do this in a React app.
const client = createClient({ projectKey: "sk_live_abc123:my-secret" });

If a key has already shipped to a browser, rotate it in the dashboard under Project settings, API keys.

Initialize once at the app entrylink

Call initBrowser in src/main.tsx, at module scope, before you render. It creates the client, installs the window error and unhandledrejection handlers, and is safe to import anywhere.

// src/main.tsx
import { initBrowser } from "@newinstance/bugwatch";

export const bugwatch = initBrowser({
	sessionUrl: "/bugwatch/session",
	release: import.meta.env.VITE_RELEASE,
	environment: import.meta.env.MODE,
});

What initBrowser guarantees:

  • Never throws. Bad config or an unreachable session endpoint degrades to a working no-op client plus one console.warn. A crash reporter must not crash your app.
  • Idempotent. The client lives on a global symbol. A second call (hot reload, a stray import, two bundles on one page) returns the first client and ignores the new options.
  • SSR safe. With no window it returns the existing client or a no-op, so prerendering or a test runner without a DOM will not blow up.
  • Relative sessionUrl is resolved against window.location.href before the client is created, so /bugwatch/session works from any route depth.

Anywhere else in the app, read the singleton rather than re-initializing:

import { getBrowserClient } from "@newinstance/bugwatch";

const client = getBrowserClient(); // BugWatchClient | undefined before init

Catch render errors with BugWatchErrorBoundarylink

BugWatchErrorBoundary is a class error boundary that catches errors thrown while rendering the subtree below it. Import it from the /react subpath and give it the client.

import { BugWatchErrorBoundary } from "@newinstance/bugwatch/react";

Props: client (required), fallback (optional ReactNode), children.

On a caught error it calls captureException(error, { level: 50, tags: { componentStack } }) and flushes immediately, so the event survives the user closing the tab. The React component stack is stored as the componentStack tag and truncated to the first 1000 characters, which is enough for the top frames of the tree. If the capture itself fails it is swallowed: the boundary never rethrows.

Rendering behaviour: once an error is caught, the boundary renders fallback. Without a fallback prop it renders null, so the crashed subtree simply disappears with no error surfaced to the user. Always pass a fallback for anything a user looks at.

The error state is sticky for the life of that boundary instance. To offer a retry, remount the boundary by changing its key (for example keying it on the route path), which is also why per-route boundaries are usually better than a single one at the root.

// One boundary per route subtree, remounted on navigation.
<BugWatchErrorBoundary
	key={location.pathname}
	client={bugwatch}
	fallback={<RouteCrashed />}
>
	<Outlet />
</BugWatchErrorBoundary>

Capture handled errors and messageslink

Anything you catch yourself will not reach a boundary, so capture it explicitly.

import { getBrowserClient } from "@newinstance/bugwatch";

export function useCheckout() {
	return async function checkout(order: Order) {
		const client = getBrowserClient();
		try {
			await payFor(order);
			client?.captureMessage("checkout completed", 30, {
				tags: { orderId: order.id },
			});
		} catch (err) {
			client?.captureException(err, {
				level: 50, // 30 info, 40 warn, 50 error, 60 fatal
				tags: { orderId: order.id, step: "pay" },
			});
			throw err;
		}
	};
}

captureException(err, hint?) accepts level, tags, user, traceId, spanId, all optional, and returns the event ID string. captureMessage(message, level?, hint?) defaults to level 30.

Identify the user after loginlink

Call setUser on the browser client once authentication resolves. It applies to every later event. Pass null on logout so the next visitor is not attributed to the previous one.

useEffect(() => {
	const client = getBrowserClient();
	if (!client) return;
	client.setUser(
		user ? { id: user.id, email: user.email, username: user.name } : null,
	);
}, [user]);

The global setter is correct here: a browser tab has exactly one user. (The concurrency warning in the README applies to servers, not to SPAs.)

Breadcrumbs are a bounded ring of the last 50 entries attached to every captured event, rendered as a timeline on the occurrence view. Record the handful of actions that explain a crash, not every render.

getBrowserClient()?.addBreadcrumb({
	category: "ui",
	message: "clicked Place order",
	level: 30,
	data: { items: cart.length },
});

Fields: category, type, level (numeric), message, data (keep it small), timestamp (ms, defaults to now).

What browser mode does not dolink

sessionUrl mode captures errors and logs. It does not export spans: the span exporter drops spans when there is no projectKey, which by design there never is in a browser. So no waterfall is produced from the page itself.

Distributed tracing still works in one direction. wrapFetch creates a span object locally, and even though that span is never exported it produces a valid traceparent header that is injected into your outbound request. Your traced backend picks it up and joins the same trace, so server-side errors and logs from that call are grouped under the trace the page started. An existing traceparent on the request is never overwritten.

import { wrapFetch, getBrowserClient } from "@newinstance/bugwatch";

const client = getBrowserClient();
export const tracedFetch = client ? wrapFetch(client) : fetch;

Complete examplelink

// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { initBrowser } from "@newinstance/bugwatch";
import { BugWatchErrorBoundary } from "@newinstance/bugwatch/react";
import { App } from "./App";

export const bugwatch = initBrowser({
	sessionUrl: "/bugwatch/session", // YOUR backend, no projectKey
	release: import.meta.env.VITE_RELEASE,
	environment: import.meta.env.MODE,
});

createRoot(document.getElementById("root")!).render(
	<StrictMode>
		<BugWatchErrorBoundary
			client={bugwatch}
			fallback={
				<main>
					<h1>Something went wrong.</h1>
					<button onClick={() => window.location.reload()}>Reload</button>
				</main>
			}
		>
			<App />
		</BugWatchErrorBoundary>
	</StrictMode>,
);

The matching backend route, which the SPA proxies or shares an origin with:

// server: mints the short-lived browser token. Express one-liner.
import { bugWatchBrowserSessionHandler } from "@newinstance/bugwatch/express";

app.get(
	"/bugwatch/session",
	bugWatchBrowserSessionHandler({ projectKey: process.env.BUGWATCH_KEY! }),
);

On any other stack, call mintBrowserSession({ projectKey }) from your own handler and return the resulting { token, expiresAt } as JSON. The SDK fetches this URL with GET, so mount it on GET. In development, proxy /bugwatch/session to your API server (Vite server.proxy) so the relative URL resolves to a real endpoint.

Troubleshootinglink

BugWatchResolutionError in the console, or SDK exports are undefined. Your tsconfig.json has compilerOptions.paths entries aliasing @newinstance/bugwatch/* to .d.ts files, and the bundler rewrote the runtime import to a type-declaration stub. Remove those paths entries. Since v0.2.0 subpath types resolve without aliases even on "moduleResolution": "node", and initBrowser from the package root needs no subpath import at all. The SDK detects this state and prints the fix once.

No events, no errors in the console. Ad blockers and tracking-protection lists block requests to third-party ingest hosts, so api.newinstance.cloud may be dropped before it leaves the browser. Check the Network tab for a blocked or cancelled request. Serving the mint endpoint from your own origin does not help here since ingest goes direct; test with blocking disabled to confirm, and treat browser event volume as best effort.

The session endpoint is missing or returns 401. Nothing breaks: token refresh fails, the SDK logs session fetch failed under debug: true, and the pending batch is dropped. There is no retry storm and no thrown error. On a 401 from ingest the cached token is cleared and refetched on the next bounded retry, then the batch is abandoned. Confirm the endpoint is deployed, reachable from the page origin, mounted on GET, and that the server-side projectKey is valid.

Events arrive without a user or tags. setUser, setTag, and addBreadcrumb only affect captures made after them. Set the user in an effect that runs on auth state change, not after the capture you care about.

Verify quickly. Add debug: true to initBrowser and throw from a button handler. Failures print [bugwatch] diagnostics in the console; on success the event appears in the dashboard.