Analytics
pb.analytics records product events from your web app — fire-and-forget. Events are buffered in the browser and shipped in batches, identity is bound to the auth lifecycle automatically, and nothing in this API ever throws or rejects into your code: analytics must never break the app. flush() is the only awaited call on the surface, and it never rejects either.
Quick example
import { pb } from '@palbase/web';
pb.analytics.capture('todo_created', { priority: 'high', list: 'work' });
pb.analytics.screen('TodoList');
// usually unnecessary — signing in identifies automatically:
pb.analytics.identify('usr_0193e2a1', { plan: 'pro' });
That is the whole happy path. The sections below cover the full surface and what happens underneath.
Capturing events
capture(event: string, properties?: Record<string, unknown>): void records one event with an optional JSON property bag:
pb.analytics.capture('todo_completed', {
todoId: 'todo_123',
durationDays: 2,
});
Event names must match this pattern, which mirrors the server-side validator exactly:
/^[a-zA-Z$][a-zA-Z0-9_.:$-]{0,63}$/
That is: start with a letter or $, then letters, digits, _, ., :, $ or -, 64 characters maximum.
Warning: An event with an invalid name is silently dropped. The SDK logs one
console.warnper process the first time it happens and then drops quietly. If an event never shows up, check the name first —todo created, with a space, is dropped.
Screen views
screen(name: string, properties?: Record<string, unknown>): void records a screen or page view. It is stored as the canonical $screen event with the name in properties.screen_name, so screens captured from the web, from iOS and from server code all collapse to one shape:
pb.analytics.screen('TodoDetail', { todoId: 'todo_123' });
// stored as: event '$screen', properties { screen_name: 'TodoDetail', todoId: 'todo_123' }
The event-name regex does not apply to screen names — only non-empty is required, and an empty name is dropped with a warning.
Buffering and delivery
| Runtime | Delivery model |
|---|---|
| Browser | Events are buffered in memory and flushed as a batch when the buffer reaches 20 events, or 10 seconds after the first buffered event — whichever comes first. |
| Server (SSR, Route Handlers, Node) | No buffer and no timers: every call posts immediately. Nothing leaks into a long-lived server process. |
Four routes on your Environment carry everything on this page:
| Route | Sent when |
|---|---|
POST /v1/analytics/batch | a browser buffer flushes |
POST /v1/analytics/capture | one event, from a server runtime |
POST /v1/analytics/identify | identify(), and the automatic one on sign-in |
POST /v1/analytics/alias | alias() |
A large buffer is delivered in slices of at most 100 events per request, sequentially, because the server refuses a larger batch.
Note:
/v1/analyticsis exempt from the per-Environment request ceiling that applies to the rest of your API (60, 600 or 3000 requests per minute by tier). High-volume capture cannot rate-limit the API it is measuring.
flush()
flush(): Promise<void> drains the buffer immediately — useful before navigating away from a page you fully control:
await pb.analytics.flush();
Note:
flush()never rejects. A failed delivery is warned to the console and the events are dropped. Do not build app logic on top of delivery success.
Each event's timestamp is stamped when you call capture, but sent_at is stamped at send time, not at enqueue, because the server corrects for clock skew with timestamp + (received_at - sent_at). An event that sat in the buffer for nine seconds still lands at the moment it happened.
Identity
Every visitor gets a stable anonymous id — a random UUID persisted in localStorage. Every event carries a distinct_id: the anonymous id before sign-in, the user id after.
The two identity keys are suffixed with the identity the SDK parses out of your publishable key, which on the Palbase cloud is the compile-time constant project for every Environment:
palbe.analytics.id.project the anonymous id
palbe.analytics.optout.project the opt-out choice, stored as the literal string 'true'
Warning: These are not per project. Two Palbase projects on the same browser origin share one anonymous id and one opt-out choice. This page used to describe the suffix as per-project; it is not, because the ref is not in the key. See the two API keys.
Automatic identification
You normally never call identify() yourself — the SDK binds to the auth lifecycle:
| Auth event | What analytics does |
|---|---|
| Signed in | Flush the buffer, then identify(user.id) — links the anonymous id to the user. |
| Signed out, user initiated | Flush, then reset() — clears the identified user and rotates the anonymous id. |
| Signed out, any other reason | Flush only — identity is preserved, since the same person is still there. |
| Token refreshed | Nothing. |
Pre-login stitching
The SDK stamps an X-Distinct-Id header on every request it makes — backend calls included, even if you never touch pb.analytics, because the identity state is constructed eagerly with the runtime. Before sign-in the header carries the anonymous id; on sign-in, identify links that anonymous id to the user id. Activity from before the user signed in is stitched to their identity afterwards with no work on your side. A header you set yourself, in any casing, wins.
Each buffered event also records its distinct_id at capture time, so events captured while anonymous keep the anonymous id on the wire even when the batch flushes after sign-in — the identify link is what ties them together.
identify(userId, traits?)
identify(userId: string, traits?: Record<string, unknown>): void links the current anonymous id to a user id and adopts it as the distinct id. It posts immediately rather than buffering. Call it yourself only when your user identity lives outside Palbase auth, or to attach traits:
pb.analytics.identify('usr_0193e2a1', { plan: 'pro', team: 'acme' });
Warning: Re-identifying as a different user automatically calls
reset()first, with a warning, so one anonymous id is never linked to two users. If you intend to switch users, callreset()yourself for clarity.
alias(from, to)
alias(from: string, to: string): void merges one distinct id into another — for stitching identities across systems, such as a pre-existing CRM id. Posts immediately.
pb.analytics.alias('usr_0193e2a1', 'crm_84421');
reset()
reset(): void drops any buffered events, clears the identified user and rotates the anonymous id. The automatic sign-out binding flushes first and then resets; if you call reset() manually and want pending events delivered, flush first:
await pb.analytics.flush();
pb.analytics.reset();
Opt-out (GDPR)
setOptOut(on: boolean): void is the consent switch. While opted out, capture, screen, identify and alias are all no-ops, anything already buffered is dropped immediately, and the flush timer is cancelled — so nothing leaks after the user said stop:
// user declined analytics consent:
pb.analytics.setOptOut(true);
// user granted consent later:
pb.analytics.setOptOut(false);
The choice is persisted, so it survives reloads — set it once when the user makes it.
Note: Opt-out gates event ingestion only. It does not disable the
X-Distinct-Idrequest header — that is request metadata with no events attached — and it does not blockreset().
Test traffic
pb.setTestDevice(on: boolean) marks this client's traffic as test traffic. It is a member of pb itself, not of pb.analytics:
pb.setTestDevice(true);
Every subsequent performance flush then carries the header X-Palbase-Test-Device: 1, which the server records as test traffic. It is the same header the iOS SDK sends. Note the exact scope: it flags perf ingestion, not the capture / screen / identify events on this page — there is no equivalent switch for those, so use a separate Environment if you need event data kept clean.
Performance traces share this transport
pb.perf is the sibling surface. It is always on: the runtime records one network item per request (with the URL redacted), a cold-start app_start trace, and the web vitals LCP, CLS, INP, FCP and TTFB. You can add your own:
const trace = pb.perf.startTrace('checkout');
trace.putAttribute('plan', 'pro');
trace.incrementMetric('items', 3);
trace.stop();
Items go to POST /v1/analytics/perf with a durable offline queue that persists on failure and drains when the browser comes back online, and the sampling rate is fetched once at start-up. Requests to /v1/analytics/* are excluded from tracing, so a flush never measures itself.
API reference
| Method | Signature | Buffered? | Notes |
|---|---|---|---|
capture | (event: string, properties?: Record<string, unknown>) => void | Yes, in the browser | Invalid names dropped, one warning per process. |
screen | (name: string, properties?: Record<string, unknown>) => void | Yes, in the browser | Stored as $screen plus screen_name. |
identify | (userId: string, traits?: Record<string, unknown>) => void | No — immediate | Automatic on sign-in; a different user auto-resets. |
alias | (from: string, to: string) => void | No — immediate | Identity merge. |
reset | () => void | — | Drops the buffer, clears the user, rotates the anonymous id. |
setOptOut | (on: boolean) => void | — | Persisted; gates all ingestion. |
flush | () => Promise<void> | — | Drains the buffer; never rejects. |
All of them are safe to call before, during and after sign-in, in any runtime. None of them throw.
Related
- Auth — the sign-in and sign-out lifecycle that drives automatic identification
- Calling Your Backend — the request pipeline that carries
X-Distinct-Id - the two API keys — why the storage keys are not per Environment
- Limits — the request ceiling that
/v1/analyticsis exempt from - Analytics — the iOS counterpart of this page