@pug-sh/browser
v0.1.2
Published
Pug browser SDK for event tracking and product analytics
Downloads
1,233
Maintainers
Readme
Pug Web SDK
Browser-side analytics and event tracking for Pug. Auto-captures page views, clicks, scrolls, form interactions, and frustration signals.
Installation
npm install @pug-sh/browserScript tag (CDN)
No bundler? Load the SDK from the Pug CDN (cdn.pugs.dev) with the loader snippet — paste it into <head>. It fetches a single self-contained file (the whole SDK in one request), and exposes the full npm API on window.pug. Calls made before the script loads are queued and replayed in order after it arrives; calls queued before the first init are dropped:
<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.2/pug.min.js';
s.onerror = function () { console.warn('[Pug SDK] Failed to load ' + s.src); };
d.head.appendChild(s);
})(window, document);
pug.init('your-project-id', { apiKey: 'your-api-key' });
</script>Always call pug.init() first in the snippet — the SDK drops calls made before init. To keep the page free of inline JavaScript (e.g. under a strict CSP), use the one-tag install instead:
<script
async
src="https://cdn.pugs.dev/v0.1.2/pug.min.js"
data-project-id="your-project-id"
data-api-key="your-api-key"
data-options='{"trackingConsent":{"initial":"cookieless","persist":true},"maxAgeDays":390}'
></script>data-options is JSON, so it carries any init option that is JSON-expressible — including the privacy ones: trackingConsent, maxAgeDays, redactUrlParams, autoCapture, session, batch, debug. That matters here more than elsewhere, because beforeSend is a function and therefore not available on this install: redactUrlParams is the only URL masking a one-tag page gets, and it is on by default. If you need to mask paths or whole URLs, use the loader snippet with a queued init call, or the npm package.
The whole object is untrusted input that no compiler ever checks, so every field is validated at runtime and fails closed: an unknown trackingConsent key, an unparseable data-options, or a missing data-project-id/data-api-key aborts the auto-init or falls back to the most restrictive setting rather than the most permissive one.
ready(cb) — read state after the bundle loads
Calls made through the snippet's stub before the bundle arrives are queued, and they return undefined instead of their real value. That matters for anything that reads state or awaits a promise: a getter called too early answers undefined, not the truth. pug.ready(cb) runs cb once the SDK is loaded — at its queue position during replay, or synchronously if the bundle is already there:
<script>
// Wrong: queued before load, so isTrackingEnabled() returns undefined and the toggle renders "off".
renderPrivacyToggle(pug.isTrackingEnabled())
// Right: runs once the SDK has loaded and init has replayed, when the getter can actually answer.
pug.ready(function () {
renderPrivacyToggle(pug.isTrackingEnabled())
})
</script>ready() exists only on the CDN build — under npm install the module is fully loaded before your code runs, so there is nothing to wait for.
Note that ready() fixes when you read, not which getter you want. isTrackingEnabled() answers "are events flowing right now"; getTrackingConsent() answers "what state is the SDK acting on"; isConsentPending() answers "has the user actually chosen yet" — that last one is the consent-banner gate. See the API reference.
Usage
Analytics
import { init, identify, track, destroy } from '@pug-sh/browser'
init('your-project-id', {
apiKey: 'your-api-key',
})
// Identify a signed-in user
await identify('[email protected]', {
name: 'Ada Lovelace',
plan: 'pro',
})
// Manual event
track('signup', { plan: 'pro' })
// Teardown (e.g. in SPA route cleanup)
destroy()All standard events (page views, clicks, scrolls, forms, rage clicks, dead clicks) are captured automatically after init().
To capture only some of them, pass an autoCapture object. It is an allowlist: name the trackers you want and every other one stays off. Pass false to disable automatic capture entirely.
init('your-project-id', {
apiKey: 'your-api-key',
autoCapture: {
pageView: true,
click: true,
},
})
// Captures page views and clicks. scroll, form, rageClick and deadClick stay off.Because it is an allowlist rather than a denylist, there is no way to spell "everything except X". Setting a key to false never turns other trackers on: { scroll: false } enables nothing at all, and { pageView: true, scroll: false } enables only page views — click, form, rage click and dead click are off too. So the values are typed true and TypeScript rejects an explicit false — list what you want enabled, or pass false as the whole value to turn everything off deliberately.
For a value known only at runtime, write || undefined so the key is omitted rather than set to false:
autoCapture: { pageView: true, scroll: enableScroll || undefined }Plain JS and the one-tag install aren't type-checked, so the SDK also warns at runtime whenever a selection ends up enabling nothing — whether from an explicit false, a non-true value like "true" or 1 from a template or config store, or a misspelled key — and names what it actually enabled.
Tracking consent starts at 'cookieless' by default: events flow, but the SDK writes nothing to the device until the user actually chooses — so an install that never mentions consent still does not store an identifier before it has a basis to (see Cookieless mode). Pass 'granted' to opt into full identity from the first event, or 'denied' to capture nothing at all. While denied, automatic listeners are not attached, and manual track() and identify() are dropped (events are not queued for later replay). Set persist: true to remember the user's choice across reloads — it is persisted like identity (through the cross-subdomain cookie when active, so an opt-out on one subdomain applies on siblings, plus localStorage); otherwise consent is in-memory and you pass the initial value yourself on each init().
persist: true is recommended for consent-first flows. Once a visitor has actually made a choice, that recorded choice is what init() resolves — so if it is no longer 'granted', init() clears identity left over from an earlier consented visit. It leaves stored identity alone when the state came from your initial seed rather than from a stored choice, in either direction: adding a 'denied' default to an existing site does not delete your existing visitors' identities, and a placeholder 'denied' corrected by a later optInTracking() does not mint a new identity on every page load. (Queued events from a prior visit are still dropped unsent on any non-granted init, seed or not — see the breaking-changes note.)
import { init, optInTracking, optOutTracking, setAutoCapture } from '@pug-sh/browser'
init('your-project-id', {
apiKey: 'your-api-key',
trackingConsent: { initial: 'cookieless', persist: true },
autoCapture: { pageView: true, click: true },
})
// After consent is granted, stored autoCapture selection is applied:
optInTracking()
// To change automatic listeners while opted in:
setAutoCapture({ pageView: true, click: true })
// If consent is revoked, listeners are torn down automatically:
optOutTracking()Init options
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | — | Required. API key. |
| endpoint | string | https://api.pugs.dev | Backend base URL. |
| batch | BatchOptions | — | Batching overrides (maxSize, maxWaitMs, maxQueueSize). |
| debug | boolean | false | Logs internal activity (each event tracked, plus the consent-denied and dryRun drops) to console.debug. Turn it on when events aren't arriving. Warnings and errors are always logged regardless, so this can only widen what you see. See Debugging. |
| dryRun | boolean | false | Builds events as normal but never sends them. Does not change consent, or what isTrackingEnabled() reports. |
| excludeAutomatedBrowsers | boolean | false | Send nothing from browsers driven by automation (Playwright, Puppeteer, Selenium, headless Chrome). See Automated browsers. |
| autoCapture | boolean \| AutoCaptureSelection | true | Controls SDK-owned automatic listeners. false disables all automatic capture; an object is an allowlist enabling only the keys set to true, with every omitted key off. |
| trackingConsent | 'granted' \| 'cookieless' \| 'denied' \| TrackingConsentConfig | 'cookieless' | Initial consent. Defaults to 'cookieless' — events flow, no identifier is written to the device — so the out-of-the-box install stores nothing before the user answers. While denied, automatic listeners stay off and track() / identify() are ignored; 'cookieless' keeps events flowing without identity. Object form: initial seeds the state used until the user answers, onReject sets what optOutTracking() applies ('denied' by default, or 'cookieless'), persist: true remembers the choice across reloads, respectGpc: true honors the browser's Global Privacy Control signal. |
| crossSubdomainTracking | boolean \| { domain: string } | false | Off by default — sharing identity across subdomains weakens browser isolation from same-origin to same-site, so it is an explicit opt-in. false keeps persistence origin-scoped in localStorage; true shares identity (anonymous ID, external ID, session, consent) across subdomains via a first-party cookie on the auto-discovered registrable domain, and { domain } pins that cookie domain explicitly. See Cross-subdomain tracking for fallback behavior and the multi-tenant caveat. |
| maxAgeDays | number | 365 | How long the SDK's stored identifiers are kept. The deadline is absolute — stamped at first write, never extended by later visits. See Retention. |
| redactUrlParams | readonly string[] \| false | built-in list | Query and fragment params whose values are replaced with redacted in $url, $referrer and a form's action. See Privacy controls. |
| beforeSend | (event) => event \| null \| void | — | Redact, rewrite or drop any event before it's sent — mask URLs, strip PII from properties. See Privacy controls. |
Automated browsers
By default, a browser driven by automation — WebDriver/CDP (Playwright, Puppeteer, Selenium) or a headless Chrome build — is tracked like any other traffic. That matches the rest of the platform, which tags bot traffic server-side rather than dropping it, and it means a test asserting the SDK fired still sees the event.
Pass excludeAutomatedBrowsers: true to keep it out entirely — init() then warns once and returns without attaching a listener, writing to storage or sending anything:
// An e2e suite that runs on every deploy otherwise lands in dashboards as visitors,
// and each event counts against the project's metered usage.
pug.init('project-id', { apiKey: 'pub_...', excludeAutomatedBrowsers: true })After that bail the SDK is inert: every other method does nothing and logs to console.debug, and reset(), setTrackingConsent(), optInTracking() and optOutTracking() return false. The two queries keep answering honestly rather than reporting suppression — isConsentPending() still returns true and getTrackingConsent() undefined, because nobody has answered a banner here either, so a consent banner behaves under e2e exactly as it does in production. Stored identity from an earlier visit is left untouched: the flag means "touch nothing on this browser", so a trackingConsent: 'denied' seed does not run its usual purge.
Detection is navigator.webdriver, plus the HeadlessChrome token in the user agent or navigator.userAgentData.brands. Each is read independently, so one unreadable signal does not decide for the others. A driver that hides all three is not caught here — that is what the server-side bot signals are for. If a check throws (a privacy extension shadowing navigator), the visitor is treated as real and tracking proceeds.
Cross-subdomain tracking
With crossSubdomainTracking: true, identity is written to a first-party cookie on the registrable domain (e.g. .example.com), auto-discovered with a write-probe. It degrades to a host-only cookie on localhost and IP hosts, and to localStorage when cookies are blocked; cookies set from HTTPS carry Secure, so identity is shared only among HTTPS subdomains. Sessions end by idle/max timeout only — the "rotate when all tabs closed" heuristic is origin-scoped and is disabled in this mode.
Warning: on a custom multi-tenant domain not on the Public Suffix List (e.g. a.myplatform.com and b.myplatform.com as separate tenants), the write-probe returns the shared myplatform.com, letting sibling tenants read each other's identity — pin an explicit { domain } there.
Retention
maxAgeDays (default 365) bounds the identifiers the SDK stores on the device: the anonymous ID, an identify()ed external ID, session state, and the persisted consent choice. It applies in both storage modes — plain localStorage has no expiry of its own, so without this the default install kept identity indefinitely.
Lowering it applies to existing visitors, not just new devices: a stored deadline is clamped to the current window on the next write, so tightening retention reaches the population that already has identifiers.
Two things it does not bound: the outbound event queue and the tab-liveness registry, which stay on raw localStorage. Neither is a way for identity to survive a withdrawal — the registry is cleared by every consent teardown, and the queue by every teardown that reduces consent (leaving 'granted', or landing on 'denied'); it survives only a transition that reduces nothing — a consent callback re-asserting an unchanged 'cookieless' (identity-free events by construction), or a 'denied' → 'cookieless' change, where the entry to 'denied' already emptied it. reset() additionally sends-and-drops the queue, while the registry — per-tab timestamps, no identifiers — is left for the still-open tab and prunes its own stale entries. A queue that cannot reach your endpoint is bounded by batch.maxQueueSize, not by this deadline. (A third key, pug_device_id, would sit outside it too, but it belongs to the push module, which is not currently shipped — nothing writes it today.)
init('your-project-id', {
apiKey: 'your-api-key',
maxAgeDays: 390, // CNIL's 13 months
})The deadline is absolute: it is stamped when a value is first written and carried through every later write, so a returning visitor refreshes the value but not the retention window. (CNIL's 13-month rule is explicit that a tracker's lifetime must not be extended automatically on new visits.) A value past its deadline is deleted, not merely ignored, on the first read after it lapses — including the cross-subdomain cookie, whose max-age is set to the value's remaining lifetime so the two expire together.
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 regardless, and Safari's ITP caps script-written cookies far lower — 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 record belongs in your CMP or on your server, with a timestamp and the policy version.
Tracking consent API
| Function | Description |
|---|---|
| setTrackingConsent(state) | Sets the consent state: 'granted', 'cookieless', or 'denied'. Leaving 'granted' deletes the stored identity (profile + session + tab registry + any queued events, including the cross-subdomain cookie). Queued events are dropped unsent: transmitting them after the user chose Reject would be a fresh act of processing on the data they just refused. (reset() — a logout, where consent is unchanged — still sends them first.) Granting later starts a fresh identity on the next event — pre-consent events are never linked to it. Returns false if the change did not fully take effect: an unrecognized state (consent then fails closed to 'denied'), a choice that could not be persisted, or an identifier that could not be removed. See Handling a failed consent change. |
| optInTracking() | Shorthand for setTrackingConsent('granted'): applies the stored autoCapture selection and allows track() / identify() to send with a persistent identity. Returns the same boolean. |
| optOutTracking() | Applies the rejection state — 'denied' by default, or whatever trackingConsent.onReject is set to. With the default it tears down automatic listeners and drops future track() / identify() calls entirely. Returns the same boolean. |
| isTrackingEnabled() | Whether events are flowing right now — true for both 'granted' and 'cookieless' (use getTrackingConsent() to distinguish). Independent of dryRun, which suppresses delivery without changing consent. Warns and returns false before init(), which is accurate: nothing is being tracked yet. |
| getTrackingConsent() | The state the SDK is acting on: 'granted', 'cookieless', 'denied', or undefined before init(). It reports undefined rather than 'denied' because a persisted choice is only read from storage during init(). Before the user answers this is the initial seed, so use isConsentPending() — not this — to decide whether to show a banner. |
| isConsentPending() | Whether the user has yet to choose. true before init() and until a stored choice is restored or setTrackingConsent() / optInTracking() / optOutTracking() runs. This is the banner gate: a seeded 'granted' and a chosen 'granted' are the same value to getTrackingConsent(), so keying a banner on that re-prompts users who already opted in. |
| setAutoCapture(selection) | Stores the desired automatic listener selection. Applies immediately while tracking is active (granted or cookieless); deferred until consent allows tracking when denied. |
Cookieless mode
'cookieless' is the middle consent state, and the default: events keep flowing, but the SDK
writes no identifiers to the device — no session, no profile, no cross-subdomain cookie, not
even the queued event payloads — and sends no identity. The server derives a daily-rotating
anonymous id instead, so consent-rejecting visitors still appear in traffic metrics while
staying anonymous and excluded from user counts by default.
It is the default because the alternative is worse: an install that has not configured consent yet would otherwise write a 365-day identifier on the first page view, before the banner it is about to show has been answered. Starting here means the only thing an unconfigured install can get wrong is collecting too little.
The one thing still written is the consent choice itself, and only when you opt into
persist: true: a record of the user's refusal, so it survives a reload and applies across
sibling subdomains. That record is a strictly-necessary preference rather than analytics
identity. With persist: false (the default) no value is stored — init() still writes and
immediately deletes a capability probe to check whether storage is usable at all.
Events themselves are unchanged. A cookieless event carries the same automatic properties as any
other: page URL and referrer, screen dimensions, locale, and user-agent client hints (plus the page
title, on page_view only). Only the identity fields are dropped.
What this settles, and what it doesn't. Nothing identifying is stored on the device, and the id the server derives cannot be reversed into one. It 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, never returned — so once that salt is gone the ids cannot be linked back to either, by us or by anyone.
It does not follow that the mode needs no consent banner. Reading device characteristics — screen dimensions, locale, client hints — is itself within scope of ePrivacy Art. 5(3) under EDPB Guidelines 2/2023, which extends "access to information stored in terminal equipment" beyond cookies to fingerprinting surfaces, and the derivation above runs on IP and user agent regardless of what the SDK stores.
Whether that is exempt in your jurisdiction is yours to decide as the controller. This mode minimizes what is collected and stored; it does not by itself establish a lawful basis, and the SDK does not assume one on your behalf.
Set initial and onReject and cookieless covers both ends of the flow — before the user answers
and after they decline — so the banner itself never has to know the state exists:
pug.init('<project-id>', {
apiKey: '<public-api-key>',
trackingConsent: { initial: 'cookieless', onReject: 'cookieless', persist: true },
})
if (pug.isConsentPending()) showBanner()
onAccept(() => pug.optInTracking()) // → 'granted'
onReject(() => pug.optOutTracking()) // → 'cookieless', per onRejectonReject only redirects optOutTracking(); setTrackingConsent('denied') always means literally
denied. For a CMP that separates "reject analytics cookies" from "reject everything", drive the
three states directly instead:
onAcceptAll(() => pug.setTrackingConsent('granted'))
onRejectAnalyticsCookies(() => pug.setTrackingConsent('cookieless'))
onRejectAll(() => pug.setTrackingConsent('denied'))Granting consent later starts a fresh identity — pre-consent events are never linked
to it. Revoking from 'granted' deletes the stored identity (including the
cross-subdomain cookie). identify() is disabled in cookieless mode.
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. It is legally binding under CCPA/CPRA and a growing list of US state laws; under GDPR it is at most an Art. 21 objection.
Off by default. Opt in and a visitor sending the signal starts at your rejection state, with no banner needed:
pug.init('<project-id>', {
apiKey: '<public-api-key>',
trackingConsent: { respectGpc: true, onReject: 'cookieless', persist: true },
})Precedence is seed → GPC → choice made on your site. GPC overrides initial, since it is the user's own standing preference rather than your placeholder, but a stored choice or a later optInTracking() overrides GPC — a visitor who explicitly accepts on your site has made the more specific decision, and without that rule your banner would loop forever.
Pair respectGpc with persist: true, as above. Without it there is nowhere to record that 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. The SDK warns when it resolves consent from GPC with persistence off.
A GPC visitor is not isConsentPending(), so a banner keyed on it stays hidden, and identity left over from an earlier consented visit is cleared at init(). The signal is read once per init().
Handling a failed consent change
setTrackingConsent(), optInTracking() and optOutTracking() return a boolean. It is true
when the change fully took effect, and false in four cases worth handling in a consent banner:
- It was called before
init(). Nothing is applied — this is the one case wherefalsereally does mean "ignored", and a banner racing initialization is the likeliest way to hit it. On the script-tag install useready()(below); withnpm, call it afterinit(). - The state was not recognized. CMPs speak their own vocabulary (
'reject','opt-out', a boolean, ornullbefore the user answers). Rather than keep the previous state — which for a user clicking Reject would silently mean staying fully tracked — consent fails closed to'denied', matching howinit()already treats the same untrusted input. Map your CMP's values explicitly rather than relying on this. - The choice could not be persisted (
persist: truewith storage full or cookies blocked). The state applies in memory, but the next page load falls back to theinitialseed — so an opt-out can quietly become a re-consent. - A stored identifier could not be removed. With
crossSubdomainTrackingthis means the identity cookie survived on the registrable domain and will resurface. This also covers the persisted event queue, whose payloads carry the identity attached at collection time.
// Script-tag install: wrap in ready() so the call runs against the real SDK. Before the bundle
// loads, every stub method returns `undefined` — and `!undefined` is truthy, so the bare form
// below would report a failure that never happened, with a state of `undefined`.
pug.ready(function () {
if (!pug.setTrackingConsent(choice)) {
// Surface it — don't assume the device is clean or that the choice will survive a reload.
reportConsentFailure(pug.getTrackingConsent())
}
})With an npm install there is no queue and no stub, so the direct form is fine:
import { getTrackingConsent, setTrackingConsent } from '@pug-sh/browser'
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", not "ignored". Before init() it does mean ignored, which is why the
script-tag form above wraps the call in ready().
Privacy controls
Four device-side controls keep PII out of captured events. All run in the browser before anything is sent, so raw values never leave the device.
Sensitive URL params are redacted
$url, $referrer and a form's action have known-sensitive query and fragment params replaced with redacted before anything else sees them — including beforeSend. The default list covers credentials and direct identifiers that ride reset links, magic links and OAuth callbacks:
access_token, api_key, apikey, auth, authorization, code, email, id_token, otp, passwd, password, phone, pwd, refresh_token, secret, sig, signature, ssn, token — plus any param ending in _token, which covers framework reset-link names like reset_password_token, confirmation_token and invite_token
https://app.example.com/reset?token=s3cr3t&plan=pro
→ https://app.example.com/reset?token=redacted&plan=proMatching is case-insensitive, and the fragment is covered too — an OAuth implicit-flow #access_token=… never reaches a server except through analytics. URLs with nothing to redact are passed through byte for byte. When something is redacted, the whole query is re-serialized in standard form (%20 becomes +, a bare ?flag becomes flag=), so joining a redacted $url against raw server logs may need form-decoding-aware comparison. Redaction also fails closed: a URL too malformed to parse (a template bug in a form's action) still gets its query and fragment params redacted at the string level rather than passing through carrying a live token.
Pass redactUrlParams an array to replace the list (matched case-insensitively by exact name — the _token suffix rule rides the default list, never a replacement), or false to capture URLs verbatim:
init('your-project-id', { apiKey: 'your-api-key', redactUrlParams: ['token', 'orderId'] })This is a floor, not a full masking solution — it cannot know that /orders/8817 is an order ID. Use beforeSend for anything structural.
Element text is the element's own text
The click and dead-click trackers capture the clicked element's own text — its 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 [email protected] 4111 1111 1111 1111". -->
<div class="row">
Open
<span>[email protected]</span>
<span>4111 1111 1111 1111</span>
</div>Nested text is captured only when that element is itself what the user clicked. <textarea> and contenteditable regions never report text at all, since their content is whatever the user typed — including clicks that land 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 — don't capture element text
Add the data-pug-no-capture attribute to any element whose text should not be tracked, for cases the own-text rule doesn't already cover — the sensitive value sits directly in the clickable element. The trackers blank the captured text for that element and everything inside it, while still recording the structural fields (tag, id, class, coordinates) so the interaction is still counted.
<!-- The click still counts, but "[email protected]" is never captured. -->
<button data-pug-no-capture>Account: [email protected]</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>Only free text is redacted; id and class are still sent, so keep PII out of those as well.
beforeSend — redact, rewrite or drop any event
beforeSend receives every property the SDK is about to send, as plain JavaScript values, and returns what should actually go out:
init('your-project-id', {
apiKey: 'your-api-key',
beforeSend: (event) => {
if (event.kind === 'internal_health_check') return null // drop it
delete event.customProperties.ssn
delete event.autoProperties.$utmContent
event.autoProperties.$locale = 'REDACTED'
return event
},
})event is { kind, autoProperties, customProperties }. kind is read-only, and so are the two bags themselves — mutate their contents in place (assign, or delete to remove a property) rather than replacing a bag wholesale.
- Return the event to send it,
nullto drop it, or nothing at all if you only mutated in place. autoPropertiesvalues are always strings.customPropertiesvalues are whatever you passed totrack(), so narrow withtypeofbefore using one as a string.$projectId,$platformand$sdkVersionare re-asserted after your hook — the backend keys on$projectIdand cannot re-derive the other two, so removing them has no effect.sessionIdanddistinctIdare not exposed. They're top-level fields on the event, already gated by tracking consent; usecookielessmode oroptOutTracking()to suppress them.- Runs synchronously on every event — keep it cheap and side-effect-free.
- Fails closed: if it throws or returns something malformed, the whole event is dropped rather than sent unredacted, and the SDK warns once per failure kind. When it throws, only the error's type is logged, never its message, so a hook that interpolates the value it was redacting can't re-surface it in the console.
- Not available on the one-tag install:
data-optionsis JSON, which can't carry a function. Passing a non-functionbeforeSendthere drops every event. Use the loader snippet with a queuedinitcall, or the npm package. One-tag pages still get the default param redaction above, which is JSON-configurable viaredactUrlParams.
Masking URLs is the common case — redactUrlParams has already stripped known-sensitive params, so what is left is structural (IDs in paths, whole URLs). $url and $referrer are auto-properties, and a form's action is a custom property on form_submit:
const maskUrl = (url: string) => {
if (!url) return url // '' is a referrer-less page view — resolving it would fabricate a self-referral
const u = new URL(url, window.location.origin)
u.pathname = u.pathname.replace(/\/orders\/\d+/, '/orders/:orderId') // mask IDs
u.searchParams.delete('email') // strip PII params
return u.toString()
}
init('your-project-id', {
apiKey: 'your-api-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
},
})A runnable demo of these controls lives in examples/privacy/.
API
identify(externalId, traits?)
Creates or updates a profile for a known user. Call it after init() when a visitor signs in or when you learn their stable user ID.
import { identify } from '@pug-sh/browser'
await identify('user_123', {
email: '[email protected]',
name: 'Ada Lovelace',
plan: 'pro',
})externalIdmust be a non-empty string, such as your database user ID or email.traitsis an optional object of profile properties. Values should be JSON-compatible.- On the first identify call, the SDK includes the anonymous ID so anonymous events can be merged into the identified profile.
identify()returns a promise and never throws — invalid input, denied consent, cookieless mode, dry-run, and RPC failures are logged and the call resolves without sending.externalIdmust not start withcookieless-; the server reserves that prefix for the identities it derives in cookieless mode, and rejects any batch carrying one.- To branch on consent, check
getTrackingConsent() === 'granted'— notisTrackingEnabled(), which is alsotruein cookieless mode, whereidentify()is disabled and resolves without doing anything.
Use reset() when a user signs out or switches accounts:
import { reset } from '@pug-sh/browser'
if (!reset()) {
// The previous user's identity may still be on this device — worth surfacing on a shared machine.
}It clears the stored profile, starts a fresh session and device ID, and drops any queued events
(sending them once first, since they were collected while that user was signed in) so the next
person on a shared device does not inherit them. Like setTrackingConsent(), it returns false
when something could not be removed — with crossSubdomainTracking that means an identity cookie
survived on the registrable domain, which is exactly the case a logout needs to know about.
track(event, properties?, options?)
Sends a manual event. Custom event names are allowed:
track('upgrade_clicked', { source: 'settings' })Well-known event names get typed, autocompleted properties:
track('purchase', {
productId: 'sku_123',
amount: 49,
currency: 'USD',
})Pass { immediate: true } to bypass batching for priority events, or { timestamp } to set an explicit epoch-millisecond occurrence time:
track('error_occurred', { errorCode: 'PAYMENT_FAILED' }, { immediate: true })Debugging
If events aren't arriving, pass debug: true to init(). The SDK then logs every track() call, the drops this flag governs — denied consent, dryRun, and the calls ignored after an automated-browser bail — and whether auto-capture ended up with any trackers active:
init('your-project-id', { apiKey: 'your-api-key', debug: true })On the one-tag install, pass it through data-options: data-options='{"debug":true}'.
Two things worth knowing:
- Debug output goes to
console.debug, which browsers file under the Verbose log level. It is hidden until you enable Verbose in the DevTools console's level filter — an empty console does not mean the SDK is silent. - Warnings and errors are never gated behind this flag, so it can only widen what you see, never narrow it. A rejected batch, a bad API key, a misconfigured option, or a
track()call made beforeinit()is reported whether or notdebugis on.
Every line the SDK logs is prefixed with [Pug SDK], so filtering the console on that string isolates its output.
Well-known events
The SDK ships a large set of well-known event names with typed, autocompleted properties — pass one to track() and your editor completes and type-checks the payload. Any other string is accepted as a custom event.
Typing is compile-time only: at runtime every event takes the same path and the SDK does not validate properties client-side. Extra properties beyond the typed ones are always allowed and sent as custom properties.
What the types do catch is a wrong type on a known field — track('purchase', { amount: '49' }) is a compile error, since amount is a number. What they don't catch is anything only the server knows: field constraints (amount > 0, currency matching ^[A-Z]{3}$), and required fields. Those are enforced server-side and surface as a rejected request, so the editor is your first line of defense and not your only one.
A few fields are 64-bit integers and take a bigint rather than a number — sizeBytes on the file, export and chat-attachment events. Write them with the n suffix: track('file_uploaded', { fileId: 'f1', sizeBytes: 1024n }). WELL_KNOWN_EVENTS.md marks each one bigint. Property values may also be Date, which is sent as a timestamp.
See WELL_KNOWN_EVENTS.md for the full list — each event's properties, types, and server-side constraints — grouped into these domains:
API · App · Auth · Billing · Chat · Commerce · Discovery · Error · File · Form · Integration · Invitation · Media · Navigation · Notification · Social · Support · Workspace
Upgrading
v0.1.0 — breaking changes
Several runtime changes affect every install, including JavaScript and one-tag:
Tracking consent now defaults to
'cookieless', not'granted'. An install that does not configuretrackingConsentkeeps sending events, but no longer writes a session, anonymous ID or cross-subdomain cookie, andidentify()is a no-op. To keep the previous behavior, passtrackingConsent: 'granted'explicitly — and satisfy yourself that you have a basis for it, since it stores identifiers before any banner is answered. One upgrade-day side effect: any events the previous build left in the persisted queue (a hard-killed tab, an offline session) are dropped unsent on the first non-granted load, since a device whose consent is not'granted'must not hold or transmit identified payloads.Stored identifiers now expire after
maxAgeDays(default 365, absolute rather than sliding). Identity that used to live inlocalStorageforever now ages out, and the stored format changed — values written by an earlier version are unreadable, so they are 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 rather than sitting there under no deadline. The one exception is the consent record itself: a baregranted/denied/cookielesspersisted by an earlier build is adopted as the user's recorded choice and re-persisted with a deadline, so a recorded opt-out survives the upgrade instead of silently reverting to yourinitialseed. On the CDN install, where each page pins its owncdn.pugs.dev/vX.Y.ZURL, roll the version out across all your pages together. The envelope rideslocalStorageas well as the cross-subdomain cookie, so pages on the old version and pages on the new one do not understand each other's stored format in any mode — including the default one, where same-origin pages thrash the anonymous ID back and forth. Worse for anidentify()ed external ID: the old build has no envelope to unwrap, so it adopts the raw1770000000000|[email protected]as the profile'sdistinctIdand sends it on every event until the upgrade is finished.Sensitive URL params are redacted by default in
$url,$referrerand a form'saction(token,access_token,code,email,password, …; full list). PassredactUrlParams: falsefor the old verbatim behavior.Queued events are dropped, not sent, on consent withdrawal.
optOutTracking()/setTrackingConsent()leaving'granted'no longer beacons the queue out first.reset()still does.crossSubdomainTracking.maxAgeDaysmoved to the top-levelmaxAgeDays, which boundslocalStoragetoo.{ maxAgeDays }as a cross-subdomain value no longer typechecks.init()warns whenendpointis nothttps(and is not localhost).The
clickanddead_clicktextproperty is now the clicked element's own text rather than its whole subtree (see Privacy controls). Text captured from wrapper elements gets shorter and stops carrying nested content; nothing else about the events changes.$pageTitleis sent onpage_viewonly, not on every event. It used to ride every click, scroll, form and frustration event; titles routinely carry names and order numbers, and the title is still joinable to later events throughsessionId.sanitizeUrlis removed, replaced bybeforeSend, which reaches every property rather than URL fields only. TypeScript consumers get a compile error. JavaScript and one-tag installs do not — the option is accepted and ignored, soinit()logs a warning when it sees the stale key.$url/$referrer/form.actionstill get the defaultredactUrlParamspass, so what is lost is whatever structural masking yoursanitizeUrladded on top. Migrate:
// Before
init('p', { apiKey: 'k', sanitizeUrl: maskUrl })
// After — note `action` on form_submit, which sanitizeUrl used to cover for you
init('p', {
apiKey: 'k',
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 from sanitizeUrl to carry over 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 the caller's own, so narrow with typeof before treating one as a string, as above.
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 |
| 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 stop compiling | 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; setTrackingConsent('denied') is unaffected |
| crossSubdomainTracking: { maxAgeDays } | The object arm is now { domain: string } only | Move it to the top-level maxAgeDays, which also bounds localStorage. On a one-tag install this changes behavior at runtime: an object with no domain used to auto-discover the registrable domain, and now resolves to off with a warning — pass true to keep cross-subdomain identity |
| crossSubdomainTracking is only enabled by true or { domain } | A data-options JSON value of {}, "true", "false" or a number no longer enables it (all four used to; "false" enabled it) | State the opt-in: true to discover the registrable domain, { domain: 'example.com' } to pin one. TypeScript already rejected these |
| sanitizeUrl removed | init(p, { sanitizeUrl }) no longer typechecks. JS/one-tag installs get no error — just a warning, and only the default param redaction rather than your own masking | Use beforeSend — see the migration above |
| TrackingConsentConfig.default renamed to initial | init(p, { trackingConsent: { default: … } }) | Rename the key. default is a reserved word, so const { default } = cfg was a SyntaxError; initial destructures normally. A stale key now warns and fails closed to 'denied' rather than silently falling back to the 'cookieless' default seed — including in data-options JSON, which no compiler checks |
The track() change also surfaced an int64 issue that the old permissive overload had been hiding: sizeBytes on the file, export and chat-attachment events is a bigint, so pass 1024n rather than 1024. It never encoded correctly as a plain number — the overload just made it compile.
