FastStats Web Analytics Core
[!IMPORTANT] This repository is in early development and not intended for production use.
The base SDK handles analytics, identity, sessions, navigation, outbound link clicks, and consent. Error tracking, Web Vitals, session replay, and feature flags are independent extensions, so applications only download the products they use.
import { init, track } from "@faststats/web";
import { errorTracking } from "@faststats/web/error";
import { sessionReplay } from "@faststats/web/replay";
import { webVitals } from "@faststats/web/web-vitals";
init({
siteKey: "site_123",
extensions: [
errorTracking(),
webVitals(),
sessionReplay({ recordConsole: true }),
],
});
track("checkout_completed", { total: 42 });
init() starts one shared client and is idempotent, making it safe to call from
application bootstrap code. Calls made before initialization are ignored rather
than buffered with stale context. Use shutdown() before initializing with new
options.
For multiple sites, tests, or framework integrations, create an independent client with explicit lifecycle control:
import { createClient } from "@faststats/web";
const analytics = createClient({ siteKey: "site_123" });
analytics.start();
analytics.track("checkout_completed", { total: 42 });
analytics.destroy();
Consent remains client-local: use "anonymous" for cookieless collection,
"granted" for normal collection, or "denied" to keep the client dormant.
Identity and traits
identify() links the visitor to your user ID and sets profile fields and
traits. traits override existing values; traitsOnce only fill in traits the
person does not have yet.
import { identify, setTraits, unsetTraits } from "@faststats/web";
await identify("user_123", "[email protected]", {
traits: { plan: "free" },
traitsOnce: { signupSource: "reddit" },
});
await setTraits({ plan: "pro" });
await setTraits({ firstFeature: "export" }, { mode: "setOnce" });
await unsetTraits(["legacyPlan"]);
After a successful identify(), the user ID is stored next to the anonymous
visitor ID and sent as externalId with every event, error, and replay chunk,
so the collector knows who the visitor is. Use an opaque ID rather than an email
address. getExternalId() returns it, or "" before identify(), in
cookieless mode, and while consent is denied. logout() and denying consent
always forget it. Cookieless clients never store or send it.
An identify() identical to the last one sent in this browser session, for the
same anonymous visitor and with no queued trait changes, resolves true without
a request, so calling it on every page load is cheap. Pass force: true to always
send.
setTraits() and unsetTraits() update the identified visitor's person without
a full identify. They apply to the user of the latest identify() call, even
while its request is in flight. Before identify(), changes are kept in memory
for the current page and sent, in order, right after the next successful
identify(). Like identify(), they resolve false and send nothing in
cookieless mode.
Visit duration
properties.time_on_page on $page_ping and $page_leave is the accumulated
time in milliseconds that the page was visible and its document had focus. It pauses when the tab is
hidden, the window loses focus, or the page enters the back-forward cache, and
resets on navigation, logout, or session rotation. A monotonic clock keeps
system-clock changes from affecting this duration. It also pauses after 60
seconds without pointer, keyboard, or scroll input, unless a <video> or
<audio> element on the page is playing, and resumes on the next input. Time
the device spent asleep or the tab frozen is not counted.
Pageviews wait until the page becomes visible. Pages replaced or closed before being shown do not emit pageviews or page-leave events.
The SDK sends a cumulative $page_ping when activity pauses and periodically
while active: after 15 and 45 seconds, then every 60 seconds. A ping that adds no
time is suppressed. A visit ends with
exactly one $page_leave, on navigation, a terminal pagehide, logout, a consent
change, or session rotation. A page the browser discards without notice never
sends one, so its last ping holds its duration. A pageview, its pings, and its
leave share properties.visit_id; consumers should use the maximum
time_on_page per visit across both events, never sum them.
Before 0.13 the pings were sent as page_leave every 15 seconds. The collector
and dashboards read both shapes; deploy them before releasing this SDK.
Built-in event names
The SDK sends its own events as $pageview, $page_ping, $page_leave,
$outbound_link, and $error. Names starting with $ are reserved for
built-in events, so track() ignores them and warns once per name in the
console. Every other name, including pageview or error, is a custom event.
Each payload carries protocol: 2. Older SDKs sent the built-in events without
the prefix; the collector renames those events, so older versions keep working.
Default context
Every event carries timezone (IANA name), language, and screen
(1920x1080, CSS pixels) beside the page context, separate from its properties.
Custom events and outbound links also carry the visit_id of the page view they
happened during.
Each visit retains its original identity, session, and page metadata. A session or identity change starts a new visit, even on the same route. Both persistent and cookieless sessions expire after 30 minutes of inactivity or 24 hours of age.
session_duration is elapsed wall-clock time from the session start to the last
counted activity. It includes time away between two active periods of the same
session, so it is separate from active time on the page. An idle visitor stops
extending the session, which then expires after 30 minutes like any other.
Delivery is best effort: a sudden process kill or network failure can lose time since the last delivered ping. The collector must accept successive events for the same visitor and URL; deploy the removal of its five-second debounce before releasing this SDK.
Feature flags
featureFlags() loads the project's flags for the visitor the client tracks and
resolves them synchronously from memory:
import { init } from "@faststats/web";
import { featureFlags } from "@faststats/web/flags";
const flags = featureFlags();
init({ siteKey: "site_123", extensions: [flags] });
await flags.ready();
if (flags.isEnabled("new-checkout")) showNewCheckout();
const maxUploads = flags.get("max-uploads", 10);
A flag counts as exposed the first time the app reads a served value with
get(), isEnabled() or details(), not when it loads. Reads are sent in
small batches and on page hide, so the dashboard's exposure numbers show the
flags people actually saw. Reading a default, while loading or for a missing
flag, is not an exposure.
Flags are evaluated for the anonymous visitor ID, and the session ID links each
evaluation to analytics. identify() re-evaluates them for that user, so
rollouts follow the user across devices, and logout() goes back to a new
anonymous visitor. When the user is known at startup, pass userId to skip the
anonymous load. A cookieless visitor has no lasting ID, so rollouts hold for the
session only. Flags are not loaded while consent is "denied", and revoking
consent drops exposures not yet sent.
get() returns the default passed at the call site while flags are loading, and
when the flag does not exist, is archived, has a different type, or the service
cannot be reached within timeoutMs. details(key) adds the variant and reason
(rule_match, target_match, fallthrough, disabled,
prerequisite_failed).
flags.subscribe(() => render(flags.status)); // "idle" | "loading" | "ready" | "error"
await flags.setContext({ attributes: { plan: "pro" } });
await flags.reload();
| Option | |
|---|---|
flags | Loads only these keys, for projects with many flags. |
userId | Your ID for a signed-in user, when known at startup. |
attributes | Anything targeting rules should match on. |
endpoint | Base URL of the flags service, when self-run. |
timeoutMs | Aborts a slower load. Defaults to 3000. |
fetch | A fetch implementation to use instead of the global. |
Typed flags
Generate types from the project's flags with the FastStats CLI:
faststats flags typegen my-project
This writes faststats-flags.d.ts (into src/ when there is one), declaring
every active flag and the values it can serve. Keys then autocomplete, a typo or
an archived flag fails to compile, and values are typed by their variants:
flags.get("theme", "system"); // "light" | "dark" | "system"
flags.isEnabled("theme"); // error: not a boolean flag
The default joins the result type because it is what loading serves. Run the
command again after changing flags, or add --check in CI to fail when the
file is out of date. Without the file, any key is accepted and the result is
typed by the default.
Network recording in session replay
Network recording is disabled by default. Enable it with
sessionReplay({ recordNetwork: true }). This records resource URLs and timing
data using rrweb's network plugin. Headers and bodies are not recorded by default.
FastStats collection endpoints (including a custom baseUrl) are always excluded
to prevent recording replay uploads recursively.
Use networkOptions to configure rrweb's plugin, including filtering or redaction:
sessionReplay({
recordNetwork: true,
networkOptions: {
initiatorTypes: ["fetch", "xmlhttprequest"],
transformRequestFn: (request) => {
const url = new URL(request.name);
if (url.pathname.startsWith("/private/")) return;
url.search = "";
request.name = url.toString();
return request;
},
},
});
recordHeaders, recordBody, and recordInitialRequests can also be set in
networkOptions. Redact sensitive data before enabling headers or bodies.
Network options alone do not enable recording. The same replay options are
available through the React and Nuxt integrations.
Custom extensions
An extension registers only the lifecycle hooks it needs. Hook failures are
isolated from analytics collection, asynchronous setup is safe during teardown,
and cleanup receives discard: true when consent is revoked.
import type { AnalyticsExtension } from "@faststats/web";
export function diagnostics(): AnalyticsExtension {
return {
name: "diagnostics",
setup(context) {
const off = context.on("pageChange", ({ url }) => {
console.debug("page changed", url);
});
return () => off();
},
};
}