Documentation menu

@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.

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.