# Browser

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 key

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](/browser-ingest). You do not need it to use the SDK, only if you are hand rolling a client.

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

## Step 1: mint endpoint on your backend

Express, one line:

```ts
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:

```ts
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 file

```ts
// 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:

```ts
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 automatically

Two things, both at level 50 (`error`):

| Source | Captured |
| --- | --- |
| `window` `error` event | `event.error`, falling back to `event.message` |
| `window` `unhandledrejection` event | `event.reason` |

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

```ts
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, breadcrumbs

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.

```ts
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 list

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:

```text
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 refresh

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 browser

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:

```ts
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 example

Backend (Express, ESM):

```ts
// 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:

```ts
// 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,
});
```

```ts
// 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 flow

Still supported, no longer recommended:

```ts
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.

## Troubleshooting

**`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.
