Palbase
Sign inGet started

Web SDK

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.warn per 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

RuntimeDelivery model
BrowserEvents 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:

RouteSent when
POST /v1/analytics/batcha browser buffer flushes
POST /v1/analytics/captureone event, from a server runtime
POST /v1/analytics/identifyidentify(), and the automatic one on sign-in
POST /v1/analytics/aliasalias()

A large buffer is delivered in slices of at most 100 events per request, sequentially, because the server refuses a larger batch.

Note: /v1/analytics is 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 eventWhat analytics does
Signed inFlush the buffer, then identify(user.id) — links the anonymous id to the user.
Signed out, user initiatedFlush, then reset() — clears the identified user and rotates the anonymous id.
Signed out, any other reasonFlush only — identity is preserved, since the same person is still there.
Token refreshedNothing.

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, call reset() 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-Id request header — that is request metadata with no events attached — and it does not block reset().

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

MethodSignatureBuffered?Notes
capture(event: string, properties?: Record<string, unknown>) => voidYes, in the browserInvalid names dropped, one warning per process.
screen(name: string, properties?: Record<string, unknown>) => voidYes, in the browserStored as $screen plus screen_name.
identify(userId: string, traits?: Record<string, unknown>) => voidNo — immediateAutomatic on sign-in; a different user auto-resets.
alias(from: string, to: string) => voidNo — immediateIdentity merge.
reset() => voidDrops the buffer, clears the user, rotates the anonymous id.
setOptOut(on: boolean) => voidPersisted; 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.

  • 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/analytics is exempt from
  • Analytics — the iOS counterpart of this page