@pug-sh/browser captures events from browser apps - React, Vue, Next.js, or
plain JavaScript. It authenticates with your public key (pub_...), which
is write-scoped (events + identify) and safe to ship in client code, and follows
the same model as every Pug SDK: initialize once, track events,
identify users, reset on sign-out.
Install
npm install @pug-sh/browser
# or: pnpm add @pug-sh/browser | yarn add @pug-sh/browser | bun add @pug-sh/browserThe Web SDK ships two ways: as compiled JavaScript plus type definitions for bundlers (Vite, webpack, esbuild, Next.js, …), or as a standalone CDN bundle you drop into a script tag with no build step. Both run in the browser and expose the same API - the bundler path is below; for the CDN, add it via a loader snippet or a one-tag install.
With a bundler, import named exports from the package root:
import { init, track, identify, reset } from '@pug-sh/browser'init() runs once wherever your app boots - there are no framework-specific
packages. For Vite, Vue, and other bundlers, just call it at your entry point.
Verify by opening your dashboard’s Live view - an auto-tracked page_view
appears within ~15 seconds.
Working with a coding agent? Paste this instead - it points the agent at the markdown copy of this page, so it works from the reference below rather than from memory. More on the approach in Install with AI.
Add Pug product analytics to this app. Read these first - they are the current source of truth, and more recent than your training data: - https://docs.pug.sh/sdks/web/index.md - SDK reference - https://docs.pug.sh/get-started/index.md - quickstart - https://docs.pug.sh/reference/well-known-events/index.md - the well-known event catalog Then read this codebase before you write anything, and work out what the product actually does: its routes or screens, its main models, and the handful of things a user comes here to complete. The events worth tracking are the moments the product exists to produce - an account created, an order placed, a document shared - not every button on the page. If you cannot state the core action in one sentence, keep reading. Then: 1. Install @pug-sh/browser. 2. Call init() exactly once, where the app boots, in the browser only - never during SSR. In the Next.js App Router that means a "use client" provider mounted from the root layout. 3. Read the project ID and public key from environment variables. The public key starts with pub_ and is safe to ship. A private key starts with prv_ and must never reach client code. 4. Leave auto-tracking on and do not hand-roll page views - init() already captures them. 5. Add track() calls for the three or four actions you identified - the ones someone would look at in a weekly review. Property values must be plain JSON scalars: strings, numbers, booleans, dates. 6. If this app has authentication, call identify() after sign-in and reset() on sign-out. Note that the SDK is cookieless until optInTracking() is called, so identify() is a no-op without granted consent - tell me whether I need a consent banner rather than opting users in silently. Name events from the catalog before you invent one. It holds 127 well-known events across 19 families - commerce, authentication, billing, media, forms, support, workspace and more - and a well-known name is type-checked as you write it, validated at ingestion, and fills in dashboards that a custom name leaves empty. Search it for every action you are about to track, and take property names and types from that page rather than from whatever reads idiomatically. Fall back to a custom snake_case name only when nothing in the catalog fits, and when you do, tell me which event you considered and why it did not. Do not use any option that is not in the SDK reference above. When you are done, tell me in one sentence what you concluded this product does, then list the files you changed, the event names you chose, and the environment variables I need to set.
Next.js App Router
In the App Router, init must run in the browser, not during SSR, so call it
from a client provider:
'use client'
import { init } from '@pug-sh/browser'
import { useEffect } from 'react'
export function PugProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
init(process.env.NEXT_PUBLIC_PUG_PROJECT_ID!, {
apiKey: process.env.NEXT_PUBLIC_PUG_PUBLIC_KEY!
})
}, [])
return <>{children}</>
}The App Router also doesn’t do a full History push on client-side navigation, so
auto-tracking fires page_view only on the initial load - track later
navigations explicitly with track('page_view') in a usePathname() effect
(see Auto-tracking).
Loader snippet
Via CDN (no bundler). Both options below load one self-contained file from
Pug’s first-party CDN (cdn.pugs.dev) that installs the full API on
window.pug - every function on this page becomes a method (pug.track(...),
pug.identify(...), …). The version is pinned in the URL path (/v0.1.3/);
the file is immutable and edge-cached, so bump it when you upgrade - and roll
every page over together, since v0.1.0 changed the stored
identity format and pages on two versions can’t read each other’s. Use one
of these, not both.
Paste this into <head>. It defines window.pug right away and loads the
bundle asynchronously; calls made before the bundle arrives (init, track,
consent changes) are queued and replayed in order, so nothing is lost on a
slow connection. Always call pug.init() first - the SDK drops calls queued
ahead of it.
<script>
!(function (w, d) {
if (w.pug) { if (!w.pug._q) console.warn('[Pug SDK] window.pug already defined by another script; not loaded.'); return; }
var q = [];
var pug = (w.pug = { _q: q, _v: 1 });
var methods = ('init track identify reset destroy setAutoCapture setTrackingConsent optInTracking ' +
'optOutTracking isTrackingEnabled getTrackingConsent isConsentPending rotate ready').split(' ');
methods.forEach(function (m) {
pug[m] = function () { if (q.length < 1000) q.push([m, [].slice.call(arguments)]); };
});
var s = d.createElement('script');
s.async = true;
s.src = 'https://cdn.pugs.dev/v0.1.3/pug.min.js';
s.onerror = function () { console.warn('[Pug SDK] Failed to load ' + s.src); };
d.head.appendChild(s);
})(window, document);
// Always init first - calls queued before init() are dropped by the SDK.
pug.init('YOUR_PROJECT_ID', { apiKey: 'pub_YOUR_PUBLIC_KEY' });
</script>Calls queued before load return undefined rather than their real value, so
gate any read or await on the CDN-only pug.ready(cb) - it fires at its
queued position during replay, or synchronously once the SDK is already live:
<script>
pug.track('signup', { plan: 'pro' }) // queued, replayed in order after load
pug.ready(function () {
// Runs once the SDK is live - safe to read state or await here.
if (pug.isTrackingEnabled()) pug.identify('user-123', { plan: 'pro' })
})
</script>One-tag install
A single self-initializing tag with no inline code - for pages under a strict
CSP that forbids inline scripts. The SDK auto-inits from the tag’s data-*
attributes: data-project-id and data-api-key are required, data-endpoint
sets a self-hosted origin, and data-options is a JSON object of any other
init option (trackingConsent, autoCapture, …).
<script
async
src="https://cdn.pugs.dev/v0.1.3/pug.min.js"
data-project-id="YOUR_PROJECT_ID"
data-api-key="pub_YOUR_PUBLIC_KEY"
data-options='{"trackingConsent":{"initial":"cookieless","persist":true},"maxAgeDays":390}'
></script>data-options is JSON, so it holds no functions - beforeSend is unavailable
on this path, which makes the JSON-configurable
redactUrlParams the only URL masking a one-tag page gets. It
is on by default. There’s also no pre-load queue: window.pug exists only after
the bundle loads, so call pug.* from code that runs later (or use the loader
snippet above if you need to queue calls immediately).
Nothing here is type-checked, so every field is validated at runtime and fails
closed: a missing data-project-id/data-api-key or unparseable
data-options aborts the auto-init entirely, and an unrecognized
trackingConsent value resolves to 'denied' rather than to the permissive
reading.
Initialize
Call init once at startup with your project ID and public key - every
other call needs it first. Find your project ID in the dashboard URL
(/p/<projectId>), and your public key on your project Overview or under
Settings > API Keys.
import { init } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY'
})init returns nothing. It throws if projectId or apiKey is missing, and
no-ops (with a console warning) if called a second time or outside a browser.
| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
required | Public key (pub_...), sent as x-api-key. |
endpoint |
string |
Pug Cloud | API base URL. Defaults to https://api.pugs.dev - set it only when self-hosting. A non-https origin (other than localhost) warns at init. |
batch |
BatchOptions |
maxSize 10, maxWaitMs 5000, maxQueueSize 1000 |
Queue size and flush timing. |
session |
SessionConfig |
idleTimeoutMinutes 30, maxSessionMinutes 1440 |
Session idle and max-duration timeouts (minutes). |
autoCapture |
boolean | AutoCaptureSelection |
true |
Automatic capture: true enables all listeners, false none, an object is a per-listener allowlist. See Auto-tracking. |
trackingConsent |
TrackingConsent | TrackingConsentConfig |
'cookieless' |
Consent gate. See Tracking consent. |
crossSubdomainTracking |
boolean | { domain } |
false |
Share identity across subdomains via a first-party cookie. Off by default - it relaxes origin isolation to same-site, and it stays inert until consent is 'granted'. See Cross-subdomain identity. |
maxAgeDays |
number |
365 |
How long stored identifiers live. The deadline is absolute (stamped on first write, never extended), so a returning visitor still ages out on schedule. Pass 390 for CNIL’s 13 months. |
redactUrlParams |
readonly string[] | false |
built-in list | Query/fragment params whose values are replaced with redacted in $url, $referrer and form actions. See Redacting PII. |
beforeSend |
BeforeSendFn |
- | Redact, rewrite or drop each event before it is sent. Fails closed. See Redacting PII. |
debug |
boolean |
false |
Log the SDK’s internal activity to console.debug (DevTools’ “Verbose” level). Turn it on when events aren’t arriving. Warnings and errors are logged regardless, so this can only widen what you see. |
dryRun |
boolean |
false |
Build events as normal but never send them. It does not change consent, or what isTrackingEnabled() reports. |
excludeAutomatedBrowsers |
boolean |
false |
Send nothing at all from browsers driven by automation (Playwright, Puppeteer, Selenium, headless Chrome). Off by default - bot traffic is tagged server-side rather than dropped. See Automated browsers. |
Tear down and re-init with destroy() (flushes the queue, removes listeners).
reset() and rotate() are covered under
Identify and sessions.
Track events
Events are the foundation of every analytic in Pug. The first argument is the
event name (the kind): custom names work without a schema, while
well-known events add typed properties and
richer dashboards. Names starting with pug. are reserved.
import { track } from '@pug-sh/browser'
// Custom event - any name
track('button_clicked', { label: 'Sign up', page: '/pricing' })
// Well-known event - typed properties
track('purchase', { productId: 'sku_123', amount: 29.99, currency: 'USD' })track() never throws and returns nothing - events are queued and flushed
in the background, so analytics can’t break or block your app.
Property values accept string (truncated to 1024 bytes client-side),
number, boolean, and Date (stored as a timestamp). Objects and arrays are
JSON-encoded to a string. null, undefined, and non-finite numbers are
dropped. Send numbers as numbers - aggregations like Sum don’t work on strings.
track(event, props?, options?) options:
| Option | Type | Description |
|---|---|---|
immediate |
boolean |
Bypass the batch queue and send right away. |
timestamp |
number |
Override the event time, as epoch milliseconds (e.g. Date.now()). |
// Conversion that must arrive before a redirect
track('purchase', { productId: 'sku_789', amount: 99, currency: 'USD' }, { immediate: true })Identify and sessions
Identity turns anonymous events into actionable profiles. The pattern: events
accumulate against an anonymous ID, you identify() on sign-in to merge that
history into a known profile, and reset() on sign-out. Use a stable,
unique external ID - your database user ID, not an email (which can change).
Only the first identify after an anonymous session merges the prior
activity; the same external ID always resolves to the same profile, and traits
are merged (never removed) server-side.
// On sign-in - merge anonymous history into the known profile
await identify('user-123', { email: 'user@example.com', plan: 'pro' })
// On sign-out - clear identity, start a fresh anonymous session
reset()
// New session ID without clearing identity
rotate()identify(externalId, traits?) returns a promise and never throws -
invalid input, a call before init(), denied consent, cookieless mode, dryRun,
and RPC failures are all logged and the promise resolves without sending, so you
can await it without a try/catch. On the first identify it includes the
anonymous ID so prior anonymous events merge into the profile. The externalId
must not start with cookieless-: the server reserves that prefix for the
identities it derives itself and rejects any batch carrying one.
reset() clears the stored identity and starts a new anonymous session - it does
not delete the profile in the dashboard. It sends any queued events once
before dropping them, since they were collected while that user was signed in,
and returns false when something could not be removed. On a shared machine
that is worth surfacing: with cross-subdomain
identity it means the cookie survived on the
registrable domain and the next person inherits it.
if (!reset()) {
showSignOutWarning()
}| Function | Clears identity | New device ID | New session ID |
|---|---|---|---|
rotate() |
No | No | Yes |
reset() |
Yes | Yes | Yes |
Sessions
Sessions group events within a single visit. The SDK creates a session ID on the first event, extends it on each event within the idle window, and starts a new one once the idle or max-duration timeout passes - identity is preserved across the boundary.
The session is synced across tabs via localStorage. Configure the timeouts at
init with session (idleTimeoutMinutes/maxSessionMinutes).
For the anonymous->identified model, see Core concepts.
Cross-subdomain identity
By default the Web SDK stores identity in origin-scoped localStorage, so
www.example.com and app.example.com are two unrelated visitors and a signup
can’t be traced back to the marketing page that produced it.
crossSubdomainTracking moves that storage to a first-party cookie on the
registrable domain, and the two become one profile with one funnel.
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
crossSubdomainTracking: true, // auto-discover the registrable domain
// crossSubdomainTracking: { domain: 'example.com' }, // or pin it explicitly
})It shares the anonymous ID, an identify()ed external ID, session state, and the
persisted consent choice - so an opt-out on one subdomain applies to its siblings
too. Both ends must be on the same project: the cookie key is project-scoped,
so a mismatch silently splits every visitor in two rather than erroring.
One pattern for a marketing site and an app that share a domain, without a
banner on either: the app grants consent at sign-in, since the account is the
basis for it, and drops back to cookieless on sign-out. The marketing site
grants only when the shared cookie is already there (a device that has signed in
already) and stays cookieless for everyone else. Anonymous readers are still
counted, through the server’s own daily-rotating cookieless identity, and a
returning customer’s visit lands on the profile it belongs to. What that model
gives up is the pre-signup half of the funnel: a cookieless visitor has no
anonymous ID to merge, so identify() links nothing retroactively. If you need
that half, the join has to be a value you know on both sides - an email captured
at signup and passed to identify() on each property.
Four things to know before switching it on:
- It relaxes browser isolation from same-origin to same-site, which is why it is off by default rather than on.
- On a multi-tenant domain not on the Public Suffix
List (
a.myplatform.comandb.myplatform.comas separate customers), auto-discovery resolves to the sharedmyplatform.com, letting sibling tenants read each other’s identity. Pin an explicit{ domain }there. - It degrades rather than failing: a host-only cookie on
localhostand IP hosts, andlocalStoragewhen cookies are blocked. Cookies set over HTTPS carrySecure, so identity is shared only among HTTPS subdomains. - Sessions end on timeout only. The “rotate the session once every tab is closed” heuristic is origin-scoped and is disabled in this mode.
Auto-tracking
With automatic capture on (the default), the SDK captures common interactions
for you - no manual track() calls. Every auto-tracked event also carries the
standard auto-properties, and the server
attaches enrichment properties to every event.
Six trackers install with autoCapture on (the default). Pass a per-listener
allowlist to run only some (omitted keys stay off) and change the selection at
runtime with setAutoCapture():
import { init } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
autoCapture: { pageView: true, click: true }
})It is an allowlist, not a set of switches: the object above enables page
views and clicks, and scroll, form, rage clicks and dead clicks all stay off.
There is no way to spell “everything except X” - setting a key to false never
turns another one on, so { scroll: false } enables nothing at all. The values
are typed true for that reason and TypeScript rejects an explicit false; for
a flag known only at runtime, write || undefined so the key is omitted rather
than set:
autoCapture: { pageView: true, scroll: enableScroll || undefined }Plain JavaScript and the one-tag install aren’t type-checked,
so the SDK also warns whenever a selection ends up enabling nothing (from an
explicit false, a misspelled key, or a stringy "true" out of a config store)
and names what it actually enabled.
| Tracker | Allowlist key | Event(s) | Captured properties |
|---|---|---|---|
| Page views | pageView |
page_view |
auto-properties only ($url, $referrer, …) |
| Clicks | click |
click |
tag, id, class, text (the element’s own text, max 50 chars), x, y |
| Scroll | scroll |
scroll |
percent (0-100), scrollY |
| Forms | form |
form_start, form_submit |
form metadata only - formId, formName (both), plus action on form_submit. Never field values |
| Rage clicks | rageClick |
rage_click |
clickCount, element, x, y |
| Dead clicks | deadClick |
dead_click |
element, text (the element’s own text, max 20 chars), x, y |
page_view fires on the initial load and on SPA navigation via the History API
(pushState/replaceState/popstate), so call init() before your router
mounts. The Next.js App Router navigates without a History push - track those
page_views manually. To gate all capture behind a consent banner, see
Tracking consent.
$pageTitle rides page_view only, not every event. Titles routinely carry
names and order numbers, and a title is still joinable to the events around it
through sessionId.
Consent and privacy
The Web SDK ships GDPR/CCPA controls that run in the browser before anything is sent, so raw values never leave the device.
Tracking consent
Consent has three states, not two:
| State | Events sent | Identifier stored on the device |
|---|---|---|
'granted' |
Yes | Yes - anonymous ID, session, any identify()ed external ID |
'cookieless' |
Yes | No |
'denied' |
No | No |
'cookieless' is the default. Traffic is counted from the first event, but
nothing is written to the device until the reader actually answers your banner -
so an install that never configures consent still doesn’t store identifiers
before it has a basis to. Pass 'granted' to opt into full identity from the
first event, or 'denied' to capture nothing at all.
import { init, optInTracking, optOutTracking, isConsentPending } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
trackingConsent: {
initial: 'cookieless', // default - events flow, nothing is stored
onReject: 'denied', // what optOutTracking() resolves to
persist: true, // remember the choice across reloads
respectGpc: true, // honor the browser's Global Privacy Control signal
}
})
// Show your banner only while the reader hasn't chosen.
if (isConsentPending()) showCookieBanner()
optInTracking() // accepted -> 'granted': attach listeners, start storing identity
optOutTracking() // declined -> resolves to `onReject`TrackingConsentConfig:
| Field | Type | Default | Description |
|---|---|---|---|
initial |
'granted' | 'denied' | 'cookieless' |
'cookieless' |
First-run seed, used when nothing is persisted yet. Named initial rather than default because default is a reserved word. |
onReject |
'denied' | 'cookieless' |
'denied' |
What optOutTracking() resolves to. Set 'cookieless' to keep identity-free traffic counts after a rejection. 'granted' is deliberately not allowed. |
persist |
boolean |
false |
Persist the choice and restore it on the next init(). Recommended for consent-first flows; without it, consent lives in memory and you re-seed initial on every load. |
respectGpc |
boolean |
false |
Honor the browser’s Global Privacy Control signal, resolving it to onReject. Read once per init(); a choice made on your site outranks it. Legally binding under CCPA/CPRA. Pair it with persist: true - see below. |
| Function | Returns | Description |
|---|---|---|
optInTracking() |
boolean |
Grant consent, attach listeners (applying the stored autoCapture selection), allow track() / identify(). |
optOutTracking() |
boolean |
Revoke consent, resolving to onReject. Tears down listeners, drops future track() / identify(), and clears the stored anonymous ID / session. |
setTrackingConsent(state) |
boolean |
Set any of the three states explicitly. setTrackingConsent('denied') always means literally denied, regardless of onReject. |
isTrackingEnabled() |
boolean |
Whether events are flowing right now: true for both 'granted' and 'cookieless'. Independent of dryRun. |
getTrackingConsent() |
TrackingConsent | undefined |
The state the SDK is acting on, or undefined before init() (a persisted choice is only read during init()). |
isConsentPending() |
boolean |
true while the reader hasn’t chosen: gate your banner on this, not on getTrackingConsent(), which can’t tell a seeded 'granted' from a chosen one. |
setAutoCapture(selection) |
void |
Change the automatic-listener allowlist at runtime: applied immediately while tracking is active, deferred until opt-in when denied. |
Cookieless mode
In 'cookieless' the SDK writes no session, no anonymous ID, no cross-subdomain
cookie, not even the queued event payloads, and sends no identity. The server
derives an anonymous ID instead, so consent-rejecting visitors still appear in
traffic metrics while staying out of user counts. That ID is an HMAC-SHA256 over
the project, the request’s IP and its user agent, keyed by a salt that rotates
daily and is deleted within 48 hours; IP and user agent are hash inputs only,
never stored and never returned, so once the salt is gone nothing links an ID
back to either.
The events are otherwise unchanged - a cookieless event carries the same
auto-properties as any other, and only the identity
fields are dropped. identify() is disabled in this state, and granting consent
later starts a fresh identity: pre-consent events are never linked to it
retroactively.
The one thing still written is the consent choice itself, and only under
persist: true - a record of the reader’s refusal, so it survives a reload. That
is a strictly-necessary preference rather than analytics identity.
Setting both initial and onReject to 'cookieless' covers each end of the
flow (before the reader answers, and after they decline), so the banner itself
never has to know the state exists:
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
trackingConsent: { initial: 'cookieless', onReject: 'cookieless', persist: true }
})For a CMP that separates “reject analytics cookies” from “reject everything”,
drive the three states directly instead - onReject only redirects
optOutTracking(), while setTrackingConsent('denied') always means literally
denied:
onAcceptAll(() => setTrackingConsent('granted'))
onRejectAnalyticsCookies(() => setTrackingConsent('cookieless'))
onRejectAll(() => setTrackingConsent('denied'))Withdrawing consent
Leaving 'granted' deletes the stored identity (profile, session, tab registry,
the cross-subdomain cookie) and drops any queued events unsent, because
transmitting them after the reader chose Reject would be a fresh act of
processing on the data they just refused. (reset() is a logout rather than a
consent change, so it still sends them once first.)
Global Privacy Control
GPC is a browser-level “do not sell or share
my data” signal, set by Brave (on by default), Firefox, DuckDuckGo and several
privacy extensions. Opt in with respectGpc: true and a visitor sending it
starts at your rejection state, no banner needed.
Precedence is seed -> GPC -> a choice made on your site. GPC overrides
initial, since it is the reader’s own standing preference rather than your
placeholder; a stored choice or a later optInTracking() overrides GPC, since
accepting on your site is the more specific decision - and without that rule your
banner would loop forever.
Pair it with persist: true. Without persistence there is nowhere to record an
acceptance: GPC re-resolves on every load, isConsentPending() stays false so
your banner never shows, and an optInTracking() dies with the page - leaving a
GPC visitor no way to accept at all. The SDK warns when it resolves consent from
GPC with persistence off.
Retention
maxAgeDays (default 365) bounds every identifier the SDK stores: anonymous ID,
external ID, session state, and the persisted consent choice. The deadline is
absolute, stamped at first write and never extended by later visits, which
is what CNIL’s 13-month rule requires (maxAgeDays: 390). Lowering it reaches
existing visitors too: a stored deadline is clamped to the current window on the
next write.
When the consent record lapses, isConsentPending() is true again and your
banner is shown afresh - which is what makes a stored choice a preference with a
shelf life rather than a permanent one.
Two caveats. Chromium caps any cookie at 400 days and Safari’s ITP caps
script-written cookies far lower, so a longer maxAgeDays cannot outlive those.
And the device-local record is a preference cache, not proof of consent - if you
need to demonstrate consent under Art. 7(1), that belongs in your CMP or on
your server, with a timestamp and the policy version.
When a consent change doesn’t take
setTrackingConsent(), optInTracking() and optOutTracking() return false
when the change did not fully take effect. Four cases are worth handling in a
banner:
- Called before
init(). Nothing is applied: the one case wherefalsereally means “ignored”. A banner racing initialization is the likeliest way to hit it; on the CDN install, wrap the call inpug.ready(). - The state wasn’t recognized. CMPs speak their own vocabulary (
'reject','opt-out', a boolean,null). Rather than keep the previous state - which for someone clicking Reject would mean staying fully tracked - consent fails closed to'denied'. Map your CMP’s values explicitly rather than relying on it. - The choice couldn’t be persisted (
persist: truewith storage full or cookies blocked). It applies in memory, but the next load falls back to theinitialseed - so an opt-out can quietly become a re-consent. - A stored identifier couldn’t be removed. With cross-subdomain identity that means the cookie survived on the registrable domain and will resurface.
if (!setTrackingConsent(choice)) {
reportConsentFailure(getTrackingConsent())
}Once init() has run a valid state is always applied in memory, so false then
means “applied, but not fully durable” rather than “ignored”.
Redacting PII
Four device-side controls keep PII out of captured events.
redactUrlParams: query and fragment params whose values are replaced with
redacted in $url, $referrer and a form’s action. It defaults to a
built-in list of credentials and direct identifiers (token, access_token,
code, email, password, …) plus any param ending in _token, which covers
framework reset-link names like reset_password_token. Pass an array to replace
that list (matched case-insensitively by exact name), or false to disable
redaction entirely. An empty array warns and keeps the default list - it would
otherwise disable redaction exactly like false, but silently.
beforeSend: redact, rewrite or drop each event just before it’s sent.
Mutate autoProperties / customProperties in place and return the event,
return null to drop it, or return nothing at all. $url, $referrer and form
actions arrive with redactUrlParams already applied, so this hook is for
anything further - path segments, whole URLs, custom properties.
import { init } from '@pug-sh/browser'
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
redactUrlParams: ['email', 'invite', 'session_id'],
beforeSend: (event) => {
const url = event.autoProperties.$url
if (typeof url === 'string') {
// Mask path IDs the param-level redaction can't see.
event.autoProperties.$url = url.replace(/\/orders\/\d+/, '/orders/:orderId')
}
if (event.kind === 'debug_ping') return null // drop entirely
return event
}
})It runs synchronously on every event, so keep it cheap. It fails closed: a
throw, a malformed return, or a non-function value drops the event (or every
event) rather than sending it unredacted. $projectId / $platform /
$sdkVersion are re-asserted afterwards, and sessionId / distinctId are not
exposed. Unavailable on the one-tag install - data-options
is JSON, which holds no functions.
Element text is the element’s own text. The click and dead-click trackers capture the clicked element’s direct child text nodes - never the text of everything nested inside it. A click on a card reports the card’s own label, not the name, email or order total in the elements it wraps:
<!-- Click the row: text is "Open", not "Open jane@example.com 4111 1111 1111 1111". -->
<div class="row">
Open
<span>jane@example.com</span>
<span>4111 1111 1111 1111</span>
</div>Nested text is captured only when that element is itself what was clicked.
<textarea> and contenteditable regions never report text at all, since their
content is whatever the user typed - including a click landing on an element
inside an editable region, which is what a rich-text editor’s markup produces.
An explicit contenteditable="false" island inside one is not user input, so its
own text is still captured.
data-pug-no-capture: an HTML attribute for the case the own-text rule
doesn’t cover, where the sensitive value sits directly in the clickable element.
It blanks captured element text for that element and everything inside it,
while still recording structural fields (tag, id, class, coordinates) so
the interaction still counts. id and class are still sent, so keep PII out of
those too.
<!-- The click still counts, but "jane@example.com" is never captured. -->
<button data-pug-no-capture>Account: jane@example.com</button>
<!-- On a container, it covers every element inside. -->
<div data-pug-no-capture>
<span>Card ending 4242</span>
<button>Pay $49.00</button>
</div>Automated browsers
A browser driven by automation - WebDriver/CDP (Playwright, Puppeteer, Selenium) or a headless Chrome build - is tracked like any other visitor by default. That matches the rest of Pug, which tags bot traffic server-side rather than dropping it, and it means an end-to-end test asserting the SDK fired still sees its event.
excludeAutomatedBrowsers stops collecting it at all. init() warns once and
returns without attaching a listener, writing to storage or opening a
connection:
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
excludeAutomatedBrowsers: true,
})What that buys is cost, not accuracy. A bot-tagged event is still stored and still counts toward the project’s event total, so a suite running on every deploy lands on the bill as well as in dashboards; not sending is the only version that doesn’t.
After the bail the SDK is inert: every method does nothing and logs to
console.debug, and optInTracking(), optOutTracking(),
setTrackingConsent() and reset() return false. The two queries keep
answering honestly rather than reporting suppression - isConsentPending()
still returns true, getTrackingConsent() still undefined - so a consent
banner behaves under test exactly as it does in production. Identity stored by
an earlier visit is left alone: the flag means “touch nothing on this browser”,
so a trackingConsent: 'denied' seed skips its usual purge here.
Detection reads navigator.webdriver, plus a HeadlessChrome token in the user
agent or in navigator.userAgentData.brands. Each is read independently, so one
unreadable signal cannot decide for the others, and a signal that throws (a
privacy extension shadowing navigator) counts as a real visitor rather than
silencing one. A driver that hides all three is not caught here - that is what
Pug’s server-side bot signals are for.
Upgrading
v0.1.0
The privacy release. Most of it changes runtime behaviour, so it reaches JavaScript and one-tag installs as well as TypeScript ones.
- Consent now defaults to
'cookieless', not'granted'. An install that doesn’t configuretrackingConsentkeeps sending events, but no longer writes a session, an anonymous ID or a cross-subdomain cookie, andidentify()is a no-op. PasstrackingConsent: 'granted'to keep the old behaviour - and satisfy yourself you have a basis for it, since it stores identifiers before any banner is answered. - Stored identifiers now expire after
maxAgeDays(default 365, absolute rather than sliding), and the stored format changed. Values written by an earlier version are unreadable, so they’re treated as absent and deleted on first read: existing visitors get a fresh anonymous ID once, on upgrade, and anyidentify()ed external ID left by the old build leaves the device. A persisted consent choice is the exception - a baregranted/denied/cookielessfrom an earlier build is adopted and re-persisted with a deadline, so a recorded opt-out survives. - Sensitive URL params are redacted by default in
$url,$referrerand a form’saction. PassredactUrlParams: falsefor the old verbatim behaviour. - Queued events are dropped, not sent, on consent withdrawal.
reset()still sends them once first. clickanddead_clicktextis the element’s own text, not its whole subtree - text captured from wrapper elements gets shorter and stops carrying nested content.$pageTitleridespage_viewonly, where it used to ride every event.crossSubdomainTracking.maxAgeDaysmoved to the top-levelmaxAgeDays, which boundslocalStoragetoo. On a one-tag install this changes behaviour: an object with nodomainused to auto-discover the registrable domain and now resolves to off with a warning - passtrueto keep sharing identity.init()warns whenendpointis nothttps(localhost aside).
sanitizeUrl is removed, replaced by beforeSend, which
reaches every property rather than URL fields only. TypeScript consumers get a
compile error; JavaScript and one-tag installs get only a warning, so what is
silently lost is whatever structural masking sanitizeUrl added on top of the
default param redaction.
init('YOUR_PROJECT_ID', {
apiKey: 'pub_YOUR_PUBLIC_KEY',
beforeSend: (event) => {
event.autoProperties.$url = maskUrl(event.autoProperties.$url)
event.autoProperties.$referrer = maskUrl(event.autoProperties.$referrer)
const action = event.customProperties.action
if (event.kind === 'form_submit' && typeof action === 'string') {
event.customProperties.action = maskUrl(action)
}
return event
}
})Two differences to carry into maskUrl itself. It no longer skips '', so a
base-relative masker would resolve a referrer-less page view into a fabricated
self-referral - guard with if (!url) return url. And customProperties values
are typed as whatever you passed to track(), so narrow with typeof before
treating one as a string, as above. Note action on form_submit, which
sanitizeUrl used to cover for you.
The rest are compile-time breaks for TypeScript consumers only:
| Change | What breaks | Fix |
|---|---|---|
autoCapture values are true, not boolean |
{ scroll: false } and any boolean-typed value |
List only what you want enabled; for a runtime value write scroll: flag || undefined |
track() is one signature, not two overloads |
A wrong type on a well-known field now errors instead of silently compiling | Correct the property type: the error names it. This also surfaces sizeBytes, which is a bigint: pass 1024n, not 1024 |
getTrackingConsent() returns TrackingConsent | undefined |
Code assuming a non-optional return under strictNullChecks |
Handle undefined, which means “called before init()” |
TrackingConsent has a third member, 'cookieless' |
Exhaustive switches and Record<TrackingConsent, ...> maps |
Handle 'cookieless' - events flow without identity |
optOutTracking() applies trackingConsent.onReject |
Nothing by default (onReject defaults to 'denied') |
Opt in with onReject: 'cookieless' to keep identity-free counts after a rejection |
crossSubdomainTracking: { maxAgeDays } |
The object arm is { domain: string } only |
Move it to the top-level maxAgeDays |
crossSubdomainTracking is enabled only by true or { domain } |
A data-options value of {}, "true", "false" or a number no longer enables it (all four used to; "false" enabled it) |
State the opt-in explicitly |
TrackingConsentConfig.default renamed to initial |
trackingConsent: { default: ... } |
Rename the key. A stale default now warns and fails closed to 'denied' - including in data-options JSON, which no compiler checks |