@collabland/analytics
v0.1.0
Published
Zero-dependency analytics SDK for browsers and Node — event tracking with automatic visitor/session identity, SPA pageview tracking, and Express/Next.js request middleware.
Readme
@collabland/analytics
A zero-dependency analytics SDK for browsers and Node. It never throws into
your app — delivery failures, storage failures, and even a throwing
onError callback are all swallowed internally. Full TypeScript definitions
are included.
Install
npm install @collabland/analytics
# or
pnpm add @collabland/analytics
# or
yarn add @collabland/analyticsRequires Node >= 20 when used server-side. In the browser it works with no bundler configuration beyond standard ESM/CJS resolution.
Quickstart (browser)
import { createAnalytics } from '@collabland/analytics';
const analytics = createAnalytics({
apiKey: 'pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
host: 'https://your-ingest-host.example.com', // see Configuration below — defaults to localhost
});
analytics.track('signup_completed', { plan: 'pro' });
analytics.page(); // no path given — auto-fills from location.pathname
analytics.identify('user_123', { email: '[email protected]' });In browsers, identity is automatic and needs no setup:
- anonymousId is minted once and persisted in
localStorageunderaa_anonymous_id. - sessionId is minted once per tab and persisted in
sessionStorageunderaa_session_id. - Both are stamped on every event automatically.
- After you call
identify(userId), thatuserIdsticks — it's stamped on every subsequent event from this instance, not just the one you calledidentifyon. - A bare
analytics.page()(nopathgiven) auto-fillspathfromlocation.pathname.
SPA route tracking
trackPageviews wires up automatic pageview tracking for client-side routers
by patching the History API (pushState/replaceState/popstate) — no
router-specific integration needed. Example for a Next.js App Router layout:
'use client';
import { useEffect } from 'react';
import { analytics } from './analytics';
import { trackPageviews } from '@collabland/analytics';
export function AnalyticsListener() {
useEffect(() => trackPageviews(analytics), []);
return null;
}Two things worth knowing:
- The History patch installs once per page and stays installed for the page's lifetime — calling the returned unsubscribe function stops that subscriber's emissions, but it does not remove the underlying patch.
- If you unsubscribe and re-subscribe while still on the same path (e.g. React strict-mode's effect → cleanup → effect cycle), the initial page is not re-fired — consecutive same-path navigations are deduped.
Node / server usage
There's no window in Node, so nothing is automatic: pass userId,
anonymousId, and sessionId explicitly on each call.
import { createAnalytics } from '@collabland/analytics';
const analytics = createAnalytics({
apiKey: process.env.ANALYTICS_API_KEY!,
host: 'https://your-ingest-host.example.com',
});
// Identity forwarded from the browser (see ids() below) — pass it per call:
export function recordCheckout(
order: { total: number },
ids: { userId?: string; anonymousId?: string; sessionId?: string },
) {
analytics.track('checkout_completed', { total: order.total }, { ...ids, revenue: order.total });
}
identify()does not stick server-side. A server-sideanalyticsinstance is typically a single shared module-level object handling requests from many concurrent users — ifidentify()made theuserIdsticky the way it does in browsers, one user's id would leak onto every other user's events. In Node, callidentify()only when you also want to emit a one-offkind:'identify'event, and passuserIdexplicitly viaopts.userId(orRequestEventInput.userId) on every other call.
To keep server-emitted events joined to the same visitor/session as your
frontend events, forward the browser's identity on your API calls using
ids():
// frontend
const { anonymousId, sessionId } = analytics.ids();
await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ anonymousId, sessionId /* ...your payload */ }),
});Express middleware
import express from 'express';
import { createAnalytics } from '@collabland/analytics';
import { analyticsMiddleware } from '@collabland/analytics/express';
const analytics = createAnalytics({
apiKey: process.env.ANALYTICS_API_KEY!,
host: 'https://your-ingest-host.example.com',
});
const app = express();
app.use(analyticsMiddleware(analytics, {
ignore: (pathname) => pathname === '/healthz',
}));Emits exactly one kind:'request' (http_request) event per completed
response, with the matched route template as path (e.g. /users/:id, not
/users/42) when Express has resolved a route, falling back to the concrete
pathname otherwise (e.g. 404s). ignore receives that concrete pathname.
The middleware types are hand-rolled structural subsets of Express's own
types — no @types/express dependency required.
Next.js route handlers
// app/api/ask/route.ts
import { createAnalytics } from '@collabland/analytics';
import { withAnalytics } from '@collabland/analytics/next';
const analytics = createAnalytics({
apiKey: process.env.ANALYTICS_API_KEY!,
host: 'https://your-ingest-host.example.com',
});
async function handler(req: Request) {
return Response.json({ ok: true });
}
export const POST = withAnalytics(analytics, handler, { route: '/api/ask' });Emits one kind:'request' event per invocation, using opts.route as the
path (Next exposes no runtime route-template API, so pass it explicitly) or
the concrete request pathname if you omit it. withAnalytics never throws
anything of its own — if your handler throws, that error is recorded as
status 500 and then re-thrown unchanged, so your existing error handling is
untouched. Works on both the Node and Edge runtimes.
Consent / privacy
identity: 'off'disables all storage access — the SDK never reads or writeslocalStorage/sessionStorage, and stamps no automaticanonymousId/sessionId. A stickyuserIdfromidentify()still works in-memory for the life of the instance (browsers only).disabled: trueimpliesidentity: 'off'and goes further: the instance becomes a total no-op — no events are ever enqueued, and nothing is ever written to storage.reset()clears the stickyuserId, and in the default'auto'mode also mints and persists fresh anonymous/session ids — call it on logout.
// before consent is granted
const analytics = createAnalytics({ apiKey: 'pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', identity: 'off' });
// on logout
analytics.reset();Configuration reference
| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| apiKey | string | (required) | Your ingest API key. |
| host | string | 'http://localhost:3000' | Set this to your ingestion endpoint in production. If left at the default, events silently go to localhost — the only signal is a delivery error passed to onError, if you provided one. |
| flushAt | number | 20 | Queue size that triggers an automatic flush. |
| flushIntervalMs | number | 5000 | Interval (ms) between timer-driven flushes. |
| sampleRate | number | 1 | Fraction of events kept, 0–1. Dropped events never enqueue. |
| disabled | boolean | false | Total no-op when true — see Consent / privacy above. |
| identity | 'auto' \| 'off' | 'auto' | 'auto': in browsers, persist and auto-stamp anonymousId/sessionId. 'off': never touch storage. Node stamps neither, regardless of mode. |
| onError | (err: Error) => void | undefined | Called for delivery failures, per-event ingest rejections (IngestRejectionError), and dropped oversized/unserializable events. A throwing onError is itself caught — it can never crash your app. |
API reference
| Member | Signature | Description |
| --- | --- | --- |
| track | track(name: string, props?: Record<string, unknown>, opts?: { userId?: string; timestamp?: Date; eventId?: string; revenue?: number; anonymousId?: string; sessionId?: string }): void | Enqueue a kind:'track' event. |
| page | page(props?: { path?: string; title?: string; referrer?: string }): void | Enqueue a kind:'page' event; omitting path in a browser auto-fills location.pathname. |
| identify | identify(userId: string, traits?: Record<string, unknown>): void | Enqueue a kind:'identify' event. Sticky in browsers only — see Node / server usage above. |
| request | request(info: RequestEventInput): void | Enqueue a kind:'request' event — what the Express/Next middlewares call under the hood; you can also call it directly. |
| reset | reset(): void | Logout: clears the sticky userId and mints fresh anonymous/session ids (browser only; no-op in Node). |
| ids | ids(): { anonymousId?: string; sessionId?: string; userId?: string } | Current identity snapshot ({} in Node) — forward to your backend so server-emitted events share the same identity. |
| flush | flush(): Promise<void> | Force an immediate flush of the queue. |
| shutdown | shutdown(): Promise<void> | Stop the flush timer and perform a final flush (via sendBeacon in browsers, when available) — call on process exit / page teardown. |
| trackPageviews | trackPageviews(analytics: Analytics): () => void | Framework-free SPA route tracking — see SPA route tracking above. Returns an unsubscribe function. |
| createHttpTransport | createHttpTransport(opts: { host: string; apiKey: string; fetchImpl?: typeof fetch; sleep?: (ms: number) => Promise<void>; now?: () => number }): Transport | Builds the default HTTP transport. Override fetchImpl/sleep/now for tests, or pass your own Transport implementation to createAnalytics({ transport }). |
| uuidv7 | uuidv7(now: number = Date.now()): string | RFC 9562 UUIDv7 generator — used internally to mint eventId. |
| HTTP_REQUEST_EVENT_NAME | 'http_request' | The event name the Express/Next middlewares emit. |
| IngestRejectionError | class IngestRejectionError extends Error { readonly rejected: { index: number; reason: string; event?: WireEvent }[] } | Passed to onError when the ingest server accepted a batch overall but rejected individual events within it. |
Delivery semantics
- Events queue in memory (cap: 1000 — the oldest is dropped first if you
exceed it) and flush automatically once the queue reaches
flushAt(default 20) or everyflushIntervalMs(default 5000ms), whichever comes first. - Batches are packed greedily and byte-aware: at most 500 events per HTTP
request, individual events capped at 32 KiB (oversized events are dropped
client-side and reported via
onErrorrather than sent), and each request body capped at 256 KiB. - Failed sends retry up to 3 times with jittered exponential backoff,
honoring a
Retry-Afterresponse header when the server sends one. Non-retryable failures (4xx other than 429) are not retried. - On
pagehide(browsers) and onshutdown(), the final flush prefersnavigator.sendBeacon, falling back to a normalfetchif the beacon call is rejected or unavailable. - Every event carries a client-generated UUIDv7
eventId(override it viaopts.eventIdontrack()for your own idempotency control). The ingest server dedupes oneventId, so a retried batch never double-counts.
License
MIT © Abridged, Inc.
