# Browser SDK (@lumin-monitor/browser)

> page / track / identify for browser apps. One of the two Lumin SDKs; the other is React Native.

Canonical: https://docs.getlumin.dev/stacks/browser/

`@lumin-monitor/browser` is the web half of the Lumin instrumentation story; [`@lumin-monitor/react-native`](https://docs.getlumin.dev/stacks/react-native/) is the mobile half. Everything else — logs, metrics, container observation — flows through the agent. A tab is one of the two places an SDK is unavoidable, because there is no agent inside it.

The SDK does four things: `page` for view tracking, `track` for custom events, `identify` for binding the current anonymous session to a known user, and automatic capture of unhandled errors. They all end up in the same event stream and share a `session_id` with your server-side logs (if you propagate it).

## Install

```sh
pnpm add @lumin-monitor/browser
# or: npm install @lumin-monitor/browser / yarn add @lumin-monitor/browser
```

## Initialise once at app boot

```ts
import { init } from "@lumin-monitor/browser";

const lumin = init({
  apiKey: import.meta.env.VITE_LUMIN_BROWSER_API_KEY,
  endpoint: "https://api.getlumin.dev",
});

// Bound methods, safe to destructure:
export const { page, track, identify, flush, close } = lumin;
```

The `apiKey` is a `lmn_pub_*` key minted in **Settings → API keys** with **Kind = Browser SDK**. It will be visible to anyone who opens devtools on your site — that's expected; the server enforces "pub keys can only post `/v1/events`", so a leaked browser key cannot read data, post logs/metrics, or reach any other project. If it leaks, rotate it from the same screen.

A `lmn_priv_*` key (the server / agent kind) sent here returns 403 — go back to the mint screen and pick "Browser SDK" instead.

Set `endpoint` to your Lumin ingest host. For SaaS that's `https://api.getlumin.dev`.

### Options

| Option | Default | Notes |
|---|---|---|
| `apiKey` | — | Required. `lmn_pub_…` from Settings → API keys (kind: Browser SDK). |
| `endpoint` | — | Required. Lumin ingest base URL. |
| `batchSize` | `50` | Max events buffered before a forced flush. |
| `flushIntervalMs` | `500` | Max ms between flushes. |
| `captureUnhandledErrors` | `true` | Auto-capture unhandled errors. See below. |
| `visitorPing` | `false` | Fire one anonymous visitor heartbeat at init. |
| `onError` | `console.warn` | Called with the failed batch + dropped count. |
| `fetch` | global `fetch` | Override for tests or for environments without a global fetch. |

## Page views

Fire `page()` on route change. For React Router or Next.js apps, hook this into your router's location-change event.

```ts
page();                       // current URL, no name
page("Pricing");              // named view
page("Pricing", { plan: "solo" });   // with properties
```

If your router gives you a stable route template (`/projects/:id` vs the expanded path), pass it as the name. That keeps cardinality bounded the same way templated routes do server-side.

## Custom events

```ts
track("signup_completed", { plan: "solo", referrer: "hn" });
track("checkout_clicked");
```

Event names are free-form. The Sessions page lets you filter by name, so keep them stable: `signup_completed` is good, `signup_completed_v2_2026_05` is not.

## Identify a user

Call `identify()` once you know who the user is — typically after login. The SDK already has an anonymous session ID; `identify` binds that session to the `userId` you pass, and every prior event in the session is retroactively associated with that user when the session timeline is rendered.

```ts
identify("user_abc123");
identify("user_abc123", { plan: "solo", signedUpAt: "2026-05-01" });
```

Re-call `identify` with the same `userId` on every page load while the user is signed in — it's cheap and makes sure refreshes still bind the session.

## React Router integration

`@lumin-monitor/browser` ships a React Router helper that hooks page tracking onto navigation. Import from the `/react-router` subpath:

```ts
import { useLuminPageviews } from "@lumin-monitor/browser/react-router";
```

Mount the hook once in your root component; it fires `page()` on every location change.

## Unhandled errors

Auto-capture is **on by default**. The SDK subscribes to `error` and `unhandledrejection` with `addEventListener` rather than assigning `window.onerror`, so it coexists with any other handler your app installs.

```ts
init({ apiKey, endpoint, captureUnhandledErrors: false }); // e.g. Sentry owns this
```

You can also report a caught error yourself:

```ts
captureError(err, { component: "Checkout" });
```

Pass a real `Error` where you can — the SDK keeps the stack and uses the constructor name as `error_type`. Any other value is stringified into the message.

Errors land in the session timeline in order with the pages before them, so you see what the user did on the way to the failure. There is no issue list, no grouping, and no dedupe; this is not an error tracker.

## Flushing on unload

The SDK auto-flushes on `visibilitychange` and on `pagehide`. If you need to force a flush manually (e.g. before navigating to a different origin):

```ts
await flush();
```

`close()` does the same and also tears down the SDK; only use it if you genuinely want to stop emitting events for the rest of the page lifetime (uncommon).

## Linking browser events to server logs

You can tag your server-side log rows with the browser's `session_id` and then filter the Logs page by it. Three steps, and none of them is automatic.

### 1. Send the id from the browser

The SDK does **not** inject headers for you, and it does not expose the session id on the client object either. Read it from `sessionStorage`, where the SDK keeps it under `lumin.session_id`:

```ts
export function luminSessionId(): string | null {
  try {
    return sessionStorage.getItem("lumin.session_id");
  } catch {
    return null; // Safari private mode, sandboxed iframe
  }
}

// Attach it to your own API calls:
const sid = luminSessionId();
await fetch("/api/projects", {
  headers: sid ? { "X-Lumin-Session": sid } : {},
});
```

The key is written when you call `init()`. Read it after that, and expect `null` on the very first paint if you read it earlier. `X-Lumin-Session` is a name this guide picked, not a protocol — use any header you like, as long as your server reads the same one.

### 2. Put it on your log rows

Bind the header value into your request logger so every line emitted during that request carries it. Each server guide has the snippet: [Gin](https://docs.getlumin.dev/stacks/gin/), [Spring Boot](https://docs.getlumin.dev/stacks/spring-boot/), [Express](https://docs.getlumin.dev/stacks/express/), [FastAPI](https://docs.getlumin.dev/stacks/fastapi/).

### 3. Filter the Logs page by it

The field arrives inside the row's `attributes`, so query it with the `attr.` prefix:

```
attr.session_id:0f8c2a41-...
```

That works on historical queries and on live tail. You can also expand any log row that carries the field and click `session_id` in the detail panel — the UI composes the same term for you.

### What this does not do

**There is no clickable pivot from a browser session to its logs, and none from a log row back to a session.** The Sessions page and the Logs page are separate views joined by a value you copy between them; the session id is the last segment of the session detail URL. There is also no `session_id` column — the field lives in `attributes` like any other, which is why the filter needs the `attr.` prefix.

Session replay does not exist either. The session detail page shows the event history for a session, not a recording of the page.
