Browser

BugWatch in the browser (any framework)link

Use this page for vanilla JS/TS, Svelte, Angular, Solid, Lit, or any browser app without a dedicated BugWatch adapter. React, Vue, and Next.js have their own adapters, but everything below still applies to them.

Why the browser never holds your secret keylink

Your projectKey looks like <keyId>:<secret> and grants ingest for your whole project. Anything you ship to the browser is public, so the key must stay on your server.

The browser instead uses a session token: your backend calls BugWatch with the secret key and gets back a short lived, ingest only token scoped to one project and environment. The browser SDK fetches that token from an endpoint on your domain and sends events to https://api.newinstance.cloud/api/v1/bugwatch/ingest/browser with an x-bugwatch-session header. The secret never leaves your server.

The wire format for that ingest call is documented in Browser Ingest. You do not need it to use the SDK, only if you are hand rolling a client.

browser  --GET /bugwatch/session-->  your backend  --x-api-key-->  BugWatch (mint)
browser  --x-bugwatch-session-->  BugWatch (/ingest/browser)

Step 1: mint endpoint on your backendlink

Express, one line:

import { bugWatchBrowserSessionHandler } from "@newinstance/bugwatch/express";

// Must answer GET: the browser SDK fetches sessionUrl with method GET.
app.get(
	"/bugwatch/session",
	bugWatchBrowserSessionHandler({ projectKey: process.env.BUGWATCH_KEY! }),
);

Any other backend (Fastify, Hono, Hapi, Nest, Django proxy, a Next.js route handler) uses the core helper:

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

// Inside your own GET /bugwatch/session route:
const session = await mintBrowserSession({
	projectKey: process.env.BUGWATCH_KEY!,
});
// session = { token: "...", expiresAt: 1771000000000 }  <- epoch milliseconds
return Response.json(session);

The response body must be { token, expiresAt }. expiresAt should be epoch milliseconds; if it is not a number the SDK assumes the token lives one hour. The Express helper answers 502 { error: "failed to mint BugWatch session" } when minting fails, so a broken key never leaks details to the page.

Step 2: one call in your entry filelink

// src/main.ts (or app.ts, index.ts, whatever loads first)
import { initBrowser } from "@newinstance/bugwatch";

export const client = initBrowser({
	sessionUrl: "/bugwatch/session",
	environment: "production",
	release: "web@1.4.0",
});

That is the whole browser setup. initBrowser guarantees:

  • Never throws. Bad config, unreachable session URL, anything: it warns once (via console.warn, or console.error for the paths-alias stub diagnosis) and returns a working no-op client. A crash reporter cannot crash your app.
  • SSR safe. When window is undefined it returns the existing client or a no-op, so the same module is safe to import in a server rendered bundle and safe at module scope.
  • Idempotent. The client lives on a global symbol; a second call returns the same instance and does not install handlers twice.
  • Handlers installed automatically. window error and unhandledrejection listeners are attached for you.
  • Relative sessionUrl allowed. "/bugwatch/session" is resolved against the current page origin at init time.

Reach the singleton from anywhere else without threading it through your app:

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

getBrowserClient()?.captureMessage("checkout opened", 30);

getBrowserClient() returns undefined before initBrowser has run, so keep the optional chaining.

What is captured automaticallylink

Two things, both at level 50 (error):

SourceCaptured
window error eventevent.error, falling back to event.message
window unhandledrejection eventevent.reason

Everything else is manual. Errors you catch yourself are invisible to BugWatch until you report them:

try {
	await placeOrder(cart);
} catch (err) {
	client.captureException(err, { tags: { area: "checkout" }, level: 50 });
	showRetryToast();
}

client.captureMessage("payment retried", 40, { tags: { attempt: "2" } });

captureException(error, hint?) and captureMessage(message, level?, hint?) both return the event id as a string. The captureException hint accepts level, tags, user, traceId, and spanId; on captureMessage the level is the positional second argument and the hint carries the rest.

User, tags, contexts, breadcrumbslink

All of these attach to every subsequent event from that client. There is one scope per browser tab, so set them right after login and clear them on logout.

client.setUser({ id: "u_912", email: "ada@example.com", username: "ada" });
client.setTag("plan", "pro");
client.setContext("viewport", { w: window.innerWidth, h: window.innerHeight });
client.setRelease("web@1.4.0");

client.addBreadcrumb({
	category: "ui",
	message: "clicked Checkout",
	level: 30,
	data: { cartValue: 4200 },
});

client.setUser(null); // on logout

Breadcrumbs are kept in a bounded ring (last 50) and ship with the next event. client.withScope(cb) gives direct access to the scope when you need several changes at once.

Origin allow listlink

Each project has an Allowed Origins list under BugWatch, your project, Settings. Leave it empty and browser ingest is unrestricted. Once you add entries, the ingest call from any other origin gets:

HTTP 403 {"error":"origin_not_allowed"}

That is the intended protection: a token scraped from your page cannot be replayed from an attacker's site. If your own events vanish after you configure the list, check that every origin you serve from is listed (apex plus www, staging, preview deploy domains, and http://localhost:PORT for local dev).

Token refreshlink

The SDK fetches sessionUrl on first send and caches the token. It refreshes when the token is within 30 seconds of expiresAt, and it discards the cached token immediately on a 401 from ingest so the next attempt mints a fresh one. Concurrent sends share a single in flight refresh. If no token can be obtained the batch is dropped and, with debug: true, the reason is printed.

No spans in the browserlink

Span creation requires a projectKey, which only exists server side, so a sessionUrl client records errors and logs but exports no spans. startSpan and withSpan still run: the span objects are real (timed, with real trace and span ids, and withSpan still links captures inside it to the span's trace) but they are never exported, so no browser waterfall is produced. wrapFetch(client) still injects a traceparent header into outgoing requests, so your backend joins the trace the browser started:

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

const tracedFetch = wrapFetch(client);
await tracedFetch("/api/orders", { method: "POST", body });

An existing traceparent on the request is never overwritten.

Complete runnable examplelink

Backend (Express, ESM):

// server.ts
import express from "express";
import { bugWatchBrowserSessionHandler } from "@newinstance/bugwatch/express";

const app = express();
app.get(
	"/bugwatch/session",
	bugWatchBrowserSessionHandler({ projectKey: process.env.BUGWATCH_KEY! }),
);
app.use(express.static("dist"));
app.listen(3000);

Browser entry, framework agnostic:

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

export const bugwatch = initBrowser({
	sessionUrl: "/bugwatch/session",
	environment: import.meta.env.MODE,
	release: import.meta.env.VITE_RELEASE,
	debug: import.meta.env.DEV,
});
// src/main.ts
import { bugwatch } from "./bugwatch.js";

bugwatch.setUser({ id: "u_912", email: "ada@example.com" });
bugwatch.setTag("plan", "pro");

document.querySelector("#checkout")?.addEventListener("click", async () => {
	bugwatch.addBreadcrumb({ category: "ui", message: "clicked Checkout" });
	try {
		const res = await fetch("/api/orders", { method: "POST" });
		if (!res.ok) throw new Error(`order failed: ${res.status}`);
	} catch (err) {
		bugwatch.captureException(err, { tags: { area: "checkout" } });
	}
});

// Verify the pipeline once, then delete:
bugwatch.captureMessage("bugwatch browser smoke test", 50);
void bugwatch.flush();

// Unhandled, captured with no extra code:
setTimeout(() => {
	throw new Error("boom from a timer");
}, 0);

Load the page, confirm a POST .../ingest/browser returning 202 in DevTools, then check the dashboard.

Legacy pre-0.2.0 flowlink

Still supported, no longer recommended:

import { createClient } from "@newinstance/bugwatch";
import { installBrowserErrorHandlers } from "@newinstance/bugwatch/browser";

const client = createClient({ sessionUrl: "/bugwatch/session" });
installBrowserErrorHandlers(client); // idempotent, returns an uninstall fn

createClient throws on invalid config during module evaluation, which can stop your app from hydrating. initBrowser exists precisely to remove that failure mode. Migration is a one line swap.

Troubleshootinglink

BugWatchResolutionError: "@newinstance/bugwatch/browser" resolved to a type-declaration stub Your tsconfig.json maps @newinstance/bugwatch/* in compilerOptions.paths to .d.ts files, and a bundler that honours paths (Next.js JsConfigPathsPlugin) rewrote the runtime import to the declaration stub, making every export undefined. Remove those paths entries. Since 0.2.0 the package ships typesVersions, so subpath types resolve even on "moduleResolution": "node", and initBrowser from the package root needs no subpath import at all.

Repeated 401s and no events The mint endpoint is broken or returning the wrong shape. Fetch it directly in the browser and confirm it answers GET with JSON { token, expiresAt } and a 2xx status. Common causes: mounted as POST only, sitting behind auth middleware that redirects to a login page, returning HTML from a SPA catch all route, or BUGWATCH_KEY unset or revoked on the server. Set debug: true to see session fetch failed: <status> in the console.

403 origin_not_allowed The page origin is not in the project's Allowed Origins list. See the section above.

Nothing at all in the Network tab Ad blockers and privacy extensions block requests to third party error reporting hosts. Test in a clean profile or with the extension disabled. If a share of your users blocks it, proxy /ingest/browser through your own domain rather than trying to defeat the blocker.

Only one console warning, then silence That is by design. initBrowser warns once per distinct message and then runs as a no-op client, so a misconfigured SDK never floods the console or breaks the app. Fix the underlying cause and reload.

Events missing user or tags setUser, setTag, and setContext must run before the capture. Call them as soon as the session is known, not inside the error handler.