@lumin-monitor/browser
v0.3.1
Published
Browser SDK for Lumin: page / track / identify with batched ingest.
Maintainers
Readme
@lumin-monitor/browser
Browser SDK for Lumin. Drop page / track /
identify into your frontend; per-session timelines join with your
server-side logs via session_id.
This is the one SDK in the Lumin instrumentation story. Everything
else — host metrics, container metrics, container logs, Prometheus
/metrics scraping — flows through the lumin-agent container next
to your services. The browser is the one place an SDK is unavoidable
because there is no agent inside the user's tab.
Install
pnpm add @lumin-monitor/browser
# or: npm install @lumin-monitor/browser / yarn add @lumin-monitor/browserZero runtime dependencies. React Router is an optional peer dep (only
needed if you import @lumin-monitor/browser/react-router).
Quick start
import { init } from "@lumin-monitor/browser";
const lumin = init({
apiKey: import.meta.env.VITE_LUMIN_BROWSER_API_KEY,
});
// Bound methods, safe to destructure:
export const { page, track, identify, flush, close } = lumin;apiKey is a lmn_pub_* key minted in Settings → API keys with kind
Browser SDK. The server enforces "pub keys can only post to /v1/events"
on its side — even if an attacker copies the key out of devtools they
cannot post logs or metrics, query any data, or pivot to a different
endpoint. Rotate from Settings → API keys if it leaks.
If you accidentally paste a lmn_priv_* (server / agent kind) key here
the server will return 403 with a hint pointing you back at the mint
flow — go pick "Browser SDK" instead.
That's it for production — endpoint defaults to
https://api.getlumin.dev. The Lumin API serves the
Access-Control-Allow-* headers needed for cross-origin requests, so
your app does not need to be on the same origin as Lumin.
API
init(options): LuminClient
| Option | Default | Notes |
| ----------------- | ----------------------------- | --------------------------------------------------------------------- |
| apiKey | — | Required. lmn_pub_… from Settings → API keys (kind: Browser SDK). |
| endpoint | https://api.getlumin.dev | Override only for local dev or same-origin proxy. See below. |
| batchSize | 50 | Max events buffered before a forced flush. |
| flushIntervalMs | 500 | Max ms between flushes. |
| onError | console.warn | Called as (err, droppedCount) when a batch fails. |
| fetch | global fetch | Override for tests or environments without a global fetch. |
| captureUnhandledErrors | true | Install window.error + unhandledrejection listeners. See Error capture below. |
When to override endpoint
You almost never need to. The two legitimate cases:
- Local development. Point at your dev server, e.g.
http://localhost:8765orhttp://api.localhost:8443. The SDK allowshttp://only forlocalhost,127.0.0.1,::1, and any*.localhosthost; everything else must behttps://. - Same-origin proxy. If you front Lumin through your own domain
(
https://acme.com/luminreverse-proxied toapi.getlumin.dev) for ad-blocker resistance or CSP simplicity, pass that base URL.
The endpoint must be a base URL with no path — pass
https://api.getlumin.dev, not https://api.getlumin.dev/v1/events.
The SDK validates this at construction and throws synchronously on a
bad shape so misconfigurations surface at boot, not at flush time.
Returns a LuminClient with bound page, track, identify, flush,
close methods. Calling init multiple times produces independent
clients; apps that want one shared instance should pin it at module
scope.
page(name?, properties?)
Fire on route change. For React Router or Next.js apps, hook this into your router's location-change event (or use the included React Router helper below).
page(); // current URL, no name
page("Pricing"); // named view
page("Pricing", { plan: "indie" }); // with propertiesIf your router gives you a stable route template (/projects/:id vs
the expanded path), pass it as the name — keeps cardinality bounded
the same way templated routes do server-side.
track(name, properties?)
Custom events. Names are free-form; the Sessions UI lets you filter
by name, so keep them stable (signup_completed good,
signup_completed_v2_2026_05 bad).
track("signup_completed", { plan: "indie", referrer: "hn" });
track("checkout_clicked");identify(userId, traits?)
Bind the current anonymous session to a known user — typically called
right after login. Every prior event in the session is retroactively
associated with userId when the session timeline is rendered.
identify("user_abc123");
identify("user_abc123", { plan: "indie", signedUpAt: "2026-05-01" });Re-call on every page load while the user is signed in. It is cheap and ensures a hard refresh still binds the session.
captureError(err, properties?)
Capture an error. Auto-installed handlers catch window.error and
unhandledrejection; call this directly from try/catch blocks where
you'd otherwise swallow the failure.
try {
await applyDiscount(code);
} catch (err) {
captureError(err, { code, step: "checkout" });
showToast("Discount didn't apply, try again");
}Accepts a real Error (preferred — preserves stack and constructor name
as error_type), or any value that will be stringified for the message.
null / undefined are silently ignored.
The same Error object captured twice (e.g. once via captureError and
once via the auto handler) is deduped, so wrapping a throw with
captureError(err); throw err; is safe.
Auto error capture
When captureUnhandledErrors is true (the default), the SDK installs
listeners for the two browser events that carry unhandled exceptions:
window.addEventListener("error", ...)— uncaught throwswindow.addEventListener("unhandledrejection", ...)— rejected promises that never had.catch()attached
Both go through the same captureError path. The SDK uses
addEventListener (not window.onerror = …), so it coexists with other
tools (Sentry, framework dev overlays) that may have installed their
own handler.
What is NOT captured:
- Source-mapped stacks. Minified bundles ship as minified stacks. Symbolication against source maps is a v2 concern.
- Cross-origin script errors. Browsers scrub these to
"Script error."with noErrorobject — the SDK still ships that minimum but stack and type are unavailable. Setcrossorigin="anonymous"on<script>tags and serve scripts withAccess-Control-Allow-Originif you need full visibility into errors from third-party-hosted bundles.
Opt out with captureUnhandledErrors: false if another tool already
owns these listeners; manual captureError(err) still works.
flush(): Promise<void>
Force a flush of any buffered events. The SDK already auto-flushes on
visibilitychange and pagehide; call this manually only when you
need to guarantee delivery before a navigation to a different origin
(payment redirect, OAuth handoff, etc.).
close(): Promise<void>
Flush, then tear the SDK down. Subsequent page/track/identify
calls become no-ops. Use only when you genuinely want to stop emitting
events for the rest of the page lifetime — uncommon.
React Router integration
import { useLuminPageviews } from "@lumin-monitor/browser/react-router";
const lumin = init({ apiKey: "...", endpoint: "..." });
export default function App() {
useLuminPageviews(lumin);
return <Outlet />;
}Fires page() once per pathname change. Deliberately ignores search
and hash — those are usually filter state, not real pageviews, and
double-counting them inflates page metrics.
Peer deps: react >= 18, react-router >= 7. Both are optional — the
subpath only loads them when imported.
Linking browser events to server logs
The whole point of the SDK is the join with your server-side logs.
To make "click a 500 log row → jump to the session" work, your
server has to know the browser's session_id.
The pattern: have the browser send session_id as a request header
(e.g. X-Lumin-Session) on every API call, and have your server-side
logger include that value as session_id on every log row it emits
during that request. Lumin joins on the field automatically.
This SDK exposes the session ID but does not inject the header for you — pick where in your network layer to set it. See the Lumin stack guides for server-side recipes (Gin, Spring Boot, Express, FastAPI).
Delivery semantics
- Batched. Up to
batchSizeevents orflushIntervalMs, whichever fires first. - Auto-flushes on
visibilitychange(hidden) andpagehide. These usenavigator.sendBeaconwhen available so flushes survive tab close. - Drops, never throws, on network failure. The
onErrorcallback is the only signal — wire it to your own observability if you care about drop rates. - No retries today. A failed batch is gone.
These trade-offs match the SDK's purpose: capture user behavior, not deliver every event under adverse network conditions. If you need guaranteed delivery, write it as a server-side log instead.
Security & CSP
A few things worth knowing for a security review.
The API key is write-only and kind-restricted. Browser SDK keys
(lmn_pub_*) can post to /v1/events for one specific project. They
cannot read events, cannot touch any other project, cannot post logs or
metrics, and cannot reach the app UI. The server returns 403 for any
attempt to use a pub key on a non-events endpoint, and returns 403 for
any server-kind (lmn_priv_*) key sent to /v1/events. Treat the
browser key like a public token — anyone with devtools can see it. The
real control is rotation, not concealment.
The SDK only reads what you pass it. It does not introspect the
DOM, scrape form fields, or capture network traffic. The data sent
to Lumin is exactly what you put in properties plus the auto-captured
url, referrer, session_id, anonymous_id, and (after identify)
user_id. Don't put secrets in properties.
Content Security Policy. If you ship a strict CSP, allowlist the
ingest origin in connect-src:
Content-Security-Policy:
connect-src 'self' https://api.getlumin.dev;(Substitute your same-origin proxy URL if you use one.) With CSP in place, even a compromised SDK build cannot exfiltrate events to a different origin — the browser blocks the request before it leaves. This is the strongest defense if you are worried about supply-chain attacks on the SDK.
Endpoint override. The endpoint option exists for local dev and
same-origin proxies. The SDK validates the shape (requires https://
for non-local hosts, rejects paths/queries/fragments, rejects
non-http(s) schemes) and throws synchronously on a bad value. This
catches typos and accidental misconfiguration, not a determined
attacker — code that controls the SDK config strictly has more
powerful primitives available (direct fetch, etc.). CSP is the
defense if you need to prevent exfiltration to an unexpected host.
TypeScript
Ships its own .d.ts files. The init, page, track, identify
signatures are typed end-to-end. InitOptions, LuminClient,
EventType, and WireEvent are exported for code that needs to
reference them directly.
License
Apache-2.0. See LICENSE.
