@saaspro/browser
Pageviews, custom events, identities, errors, Core Web Vitals, metrics and feature flags in one dependency-free package. The script-tag build is 11.13 KiB gzipped.
npm install @saaspro/browser
Using React? Reach for @saaspro/react instead — it wraps this
package. No build step? Use the script tag.
Install
import { spm } from "@saaspro/browser";
spm.init({ key: "spm_pub_prod_xxxxxxxx" });
Put that in whichever module runs first in the browser (src/main.ts,
app/providers.tsx, …). The import is SSR-safe: nothing touches window until
init() runs. Calls made before init() are buffered and replayed, so a
component that tracks on mount never loses an event.
For a page that tracks into more than one app, createClient() returns an
isolated tracker.
Options
spm.init({
key: "spm_pub_prod_xxxxxxxx",
host: "https://saaspro.dev",
autoPageviews: true, // $pageview on load and on SPA route changes;
// turn off when <SPMPageview /> owns them
autoErrors: true, // window error + unhandledrejection
vitals: true, // LCP, CLS, INP, FCP, TTFB
outboundLinks: true, // $outbound on cross-host link clicks
dataAttributes: true, // [data-spm-event] clicks, [data-spm-form] submits
clickIds: true, // gclid, gbraid, wbraid, fbclid, ttclid, li_fat_id,
// msclkid + the _fbp / _fbc / _ttp cookies
privacyMode: "persistent", // "ephemeral" keeps tracking identity in memory
cookieless: false, // localStorage only, no cookies at all
respectDoNotTrack: false, // disable when the browser sends DNT
sessionTimeoutMinutes: 30,
batchSize: 20,
flushIntervalMs: 2000,
release: "web@1.4.0",
env: "production",
appVersion: "1.4.0",
debug: false,
consent: "granted", // "pending" buffers in memory until you grant
before: (event) => event, // redact or drop; return null to drop
});
Tracking
spm.page(); // $pageview for the current URL
spm.page("Checkout", { step: 2 }); // named pageview
spm.track("signup completed", { plan: "pro" });
spm.track("purchase", { items: 3 }, { value: 49.9, currency: "EUR" });
spm.identify("user_123", {
email: "ada@example.com",
name: "Ada Lovelace",
plan: "pro",
createdAt: "2026-01-01T00:00:00.000Z",
});
spm.alias("user_123"); // link the current anonymous id to a known user
spm.reset(); // on sign-out: new anonymous id, forget the identity
email, name, firstName, lastName, phone, avatar, plan, company
and createdAt are reserved traits — they get their own columns on the person
profile. Everything else lands in traits.
Redacting with before
spm.init({
key: "spm_pub_prod_xxxxxxxx",
before(event) {
if (event.type === "page" && event.context?.path?.startsWith("/admin")) return null;
if (event.context?.url) event.context.url = event.context.url.split("?")[0];
return event;
},
});
Automatic capture
Pageviews. history.pushState / replaceState are patched and popstate is
observed, so every SPA route change is a pageview. A route change first sends
$pageleave with durationMs for the page you are leaving. pagehide and
visibilitychange → hidden also send $pageleave and flush with sendBeacon.
$pageleave follows the pageview, not the setting: with autoPageviews: false
it is still sent for any page you announced yourself with spm.page() (or
<SPMPageview />), and never for a page that had no pageview.
Duplicate pageviews are dropped. A $pageview for a path + search that
was already announced less than 1000 ms ago is discarded — the first one is
kept, the second never reaches the queue, and debug: true logs
duplicate $pageview dropped. The rule applies to both sources, the history
patch and your own spm.page(), because both are correct on their own: an app
that leaves autoPageviews on and renders <SPMPageview /> would otherwise
count every navigation twice.
Neither a different name nor different properties reopens the window. A
second pageview for the same URL within a second is a duplicate whatever it
carries, and a genuinely different view is a different URL. If you need two
named views on one URL, put a second of daylight between them or give them
distinct paths. spm.reset() clears the window, so a new identity may announce
the page it is on again.
The dedupe is a safety net, not the configuration. When your framework owns
pageviews — <SPMPageview /> in a Next.js app — set autoPageviews: false and
let it be the only source.
Declarative clicks and forms.
<button
data-spm-event="cta clicked"
data-spm-prop-position="hero"
data-spm-prop-plan="pro"
data-spm-prop-trial="true"
>
Start free
</button>
<form data-spm-form="signup" action="/api/signup" data-spm-prop-variant="b">…</form>
The button sends cta clicked with { position: "hero", plan: "pro", trial: true }
("true" / "false" become booleans). The form sends $form_submit with
{ form: "signup", formId, action, variant: "b" }. The nearest tagged ancestor
wins, so wrapping a card works.
Outbound links. A click on <a href> pointing at another host sends
$outbound with { href, host, text }. www. is ignored when comparing hosts.
Errors.
spm.captureException(error, {
tags: { area: "checkout" },
extra: { orderId: 42 },
level: "error",
});
spm.addBreadcrumb({ category: "custom", message: "coupon applied", data: { code: "SPRING" } });
spm.setTags({ tier: "enterprise" });
window error and unhandledrejection are captured automatically as unhandled
errors with parsed stack frames. Breadcrumbs are recorded for navigation, clicks,
console.error, and every fetch / XMLHttpRequest with its status — the last
30 are attached to each error.
Web vitals. LCP, CLS, INP, FCP and TTFB are measured with
PerformanceObserver (no web-vitals dependency) and reported once each,
finalized when the page is hidden. Each carries the Core Web Vitals rating
(good / needs-improvement / poor).
Metrics
spm.metric("cart.items", 3); // gauge
spm.metric("checkout.errors", 1, { kind: "counter" });
spm.metric("image.bytes", 240_000, { kind: "histogram", unit: "bytes" });
const stop = spm.timer("checkout.duration", { step: "payment" });
// …later
stop(); // timer metric in ms
Feature flags
await spm.flags.load({ properties: { plan: "pro" } });
if (spm.flags.isEnabled("new-onboarding")) showNewFlow();
const variant = spm.flags.get("checkout-copy", "control");
const unsubscribe = spm.flags.onChange((flags) => render(flags));
Values are cached in memory and in localStorage (spm_flags), so a repeat
visit renders the last known values immediately. load() never rejects; if the
request fails the cache is kept.
Identity, sessions and attribution
The following table describes default persistent mode. For memory-only identities that reset within 30 minutes, see Browser privacy modes. To continue anonymous visits across explicitly configured domains, see Cross-domain journeys, including automatic links and await spm.createLink(url).
| Key | Where | Lifetime |
|---|---|---|
spm_aid |
localStorage + first-party cookie |
400 days |
spm_sid |
localStorage |
30 minutes idle |
spm_did |
localStorage |
until reset() |
spm_attr |
localStorage |
first touch forever, last touch 30 days |
spm_optout |
localStorage + cookie |
until optIn() |
The anonymous-id cookie is host-only: path=/; max-age=400 days; SameSite=Lax,
plus Secure on https, and no domain attribute — it never leaks to a
sibling subdomain. cookieless: true skips cookies entirely.
After 30 minutes of inactivity the SDK emits a session event with action: "end", durationMs and pageviews, then issues a new session id.
Attribution is captured from utm_* parameters, ad click ids and the referrer:
spm.getAttribution();
// { first: { source: "google", medium: "organic", landing: "/", at: "…" },
// last: { source: "newsletter", medium: "email", at: "…" } }
First touch is written once and never overwritten. Last touch is replaced by every attributable visit and expires after 30 days of direct visits.
Consent and opt-out
spm.init({ key: "spm_pub_prod_xxxxxxxx", consent: "pending" });
// nothing is sent and nothing is stored yet
acceptButton.onclick = () => spm.consent("granted"); // persists ids, flushes the buffer
rejectButton.onclick = () => spm.consent("revoked"); // drops the buffer and stops
spm.optOut(); // persistent: writes spm_optout, clears ids, detaches listeners
spm.optIn();
Transport
Events batch at batchSize or every flushIntervalMs, whichever comes first,
and split at 100 events per request. The body is text/plain with the key inside
the envelope, which keeps every request a CORS simple request — no preflight
round trip. Envelopes also split at 60,000 UTF-8 bytes. Normal fetches do not
use keepalive; on pagehide/hidden, sendBeacon is best-effort (queue acceptance
does not prove ingestion). Each request has a 10-second requestTimeoutMs.
A failed batch is retried once on the next flush. At most 1,000 queued/in-flight
events are retained; oversized, circular and overflow events are dropped.
Flags require granted consent; call flags.load() after granting consent or
changing identities. Reset/identify/alias invalidate the cache, and revocation
discards stale work. Already delivered events cannot be recalled by aborting.
data-spm-ignore excludes automatic clicks/forms in a DOM subtree;
captureText: false omits automatic visible-text capture but not URLs or explicit
properties. Redact these with before. Both captureText and
requestTimeoutMs also have script-tag data attributes.
await spm.flush() sends everything queued and resolves when the request
settles — useful right before a full page navigation.
Full API
init page track identify alias reset captureException
addBreadcrumb setTags setContext metric timer flags.load
flags.isEnabled flags.get flags.all flags.onChange consent optOut
optIn flush getAnonymousId getSessionId getDistinctId getAttribution
isEnabled, plus createClient() and the SDK_VERSION, DEFAULT_HOST,
INGEST_PATH, FLAGS_PATH constants.
Build artifacts and custom frames
JavaScript errors automatically attach registered build debug IDs. Use the build-artifact integration to inject IDs before deployment and privately upload source maps. captureException(error, { frames }) also accepts explicit frames from a custom parser or native bridge, including debugId, architecture, hexadecimal instructionAddress/imageAddress, and optional isReturnAddress. Native collection and unwinding are provided by your host bridge.
OpenTelemetry error correlation
Set traceContext: () => trace.getActiveSpan()?.spanContext() using your
existing @opentelemetry/api instance. Captured errors keep the trace/span IDs
that were active when capture was called, including deferred consent or native
queues. An explicit captureException(error, { trace: span.spanContext() })
overrides the callback; { trace: null } suppresses correlation for that error.
Invalid IDs and throwing callbacks are ignored independently of error capture.
This attaches context only. Configure your OpenTelemetry exporter separately using the trace integration guide. Browser and native clients keep their public ingest key; the trace collector's secret key stays on a server. Open Metrics → Traces for the request timeline or follow View request trace from a captured error.
Optional website heatmaps
@saaspro/browser/heatmaps adds separately consented click/scroll/friction
observations for reviewed static page layouts. It leaves the default tracker
bundle unchanged. The console shows geometry-only wireframes, compatible viewport
and layout filters, numeric counts and heuristic coverage. See the
heatmap integration guide for consent, markup,
identity/layout resets, delivery bounds and deletion behavior.