Browser-side analytics and event tracking for Pug. Auto-captures page views, clicks, scrolls, form interactions, and frustration signals.
npm install pug-webimport { init, identify, track, destroy } from 'pug-web'
init('your-project-id', {
apiKey: 'your-api-key',
})
// Identify a signed-in user
await identify('user@example.com', {
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 selectively enable only some automatically captured events, use autoCapture. Object mode is an allowlist: omitted keys are disabled.
init('your-project-id', {
apiKey: 'your-api-key',
autoCapture: {
pageView: true,
click: true,
scroll: false,
},
})For consent-first flows, start with tracking consent denied. 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 in localStorage; otherwise consent is in-memory and you pass the initial value yourself on each init().
import { init, optInTracking, optOutTracking, setAutoCapture } from 'pug-web'
init('your-project-id', {
apiKey: 'your-api-key',
trackingConsent: { default: 'denied', 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()| Option | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
— | Required. API key. |
endpoint |
string |
https://api.pugs.dev |
Backend base URL. |
batch |
Partial<BatchConfig> |
— | Batching overrides (size, wait, storage key). |
autoCapture |
boolean | AutoCaptureSelection |
true |
Controls SDK-owned automatic listeners. false disables all automatic capture; an object enables only keys set to true. |
trackingConsent |
'granted' | 'denied' | { default?, persist? } |
'granted' |
Tracking consent. While denied, automatic listeners stay off and track() / identify() are ignored. Object form: default is the first-run seed; persist: true stores the choice in localStorage and restores it on the next init(). |
sanitizeUrl |
(url: string) => string |
— | Redacts URLs before they leave the device — rewrites $url, $referrer, and captured form actions to mask routes or strip PII query params. Fails closed: throwing or returning a non-string drops the URL. See Privacy controls. |
| Function | Description |
|---|---|
optInTracking() |
Grants consent, applies the stored autoCapture selection, and allows track() / identify() to send. |
optOutTracking() |
Revokes consent, tears down automatic listeners, and drops future track() / identify() calls. |
isTrackingEnabled() |
Returns true when tracking consent is granted. Reflects consent only — independent of dryRun, which suppresses delivery without changing consent. Warns and returns false before init(). |
getTrackingConsent() |
Returns 'granted' or 'denied'. Warns and returns 'denied' before init(). |
setAutoCapture(selection) |
Stores the desired automatic listener selection. Applies immediately when consent is granted; deferred until optInTracking() when denied. |
Two device-side controls keep PII out of captured events. Both run in the browser before anything is sent, so raw values never leave the device.
Add the data-pug-no-capture attribute to any element whose text should not be tracked. The click and dead-click 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 "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>Put the attribute on an ancestor of every element that can be clicked — a marker on a sensitive leaf won't protect it if a surrounding element is the click target. Only free text is redacted; id and class are still sent, so keep PII out of those as well.
Pass a sanitizeUrl function to init() to rewrite $url, $referrer, and captured form actions before they are sent. The SDK can't know your routes, so the rules live in your app:
init('your-project-id', {
apiKey: 'your-api-key',
sanitizeUrl: (url) => {
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()
},
})- Runs synchronously on every event — keep it cheap and side-effect-free.
- Fails closed: if it throws or returns a non-string, the URL is dropped to an empty string rather than sent raw, so a bug in your sanitizer can't leak the PII it was meant to strip.
- Covers URL fields only.
$utm*params are parsed from the raw query string separately, so don't put PII in UTM parameters.
A runnable demo of both controls lives in examples/privacy/.
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-web'
await identify('user_123', {
email: 'user@example.com',
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.
- If push is configured, the first identify call also links the browser's push device ID to the profile.
identify()returns a promise and never throws — invalid input, denied consent, dry-run, and RPC failures are logged and the call resolves without sending. CheckisTrackingEnabled()first if you need to branch on consent.
Use reset() when a user signs out or switches accounts:
import { reset } from 'pug-web'
reset()Sends a manual event. Custom event names are allowed:
track('upgrade_clicked', { source: 'settings' })Well-known events are validated against typed property schemas:
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 })These event names get typed properties and runtime validation. Extra properties are allowed and are sent as custom properties.
| Event | Properties |
|---|---|
page_view |
— |
click |
class, id, tag, text, x, y |
rage_click |
clickCount (>= 2), element, x, y |
dead_click |
element, text, x, y |
scroll |
percent (0–100), scrollY (>= 0) |
search |
query (required) |
add_to_cart |
productId (required), amount (> 0), currency (3-letter code, required when amount is set) |
checkout_started |
productId (required), amount (> 0), currency (3-letter code, required when amount is set) |
checkout_completed |
productId (required), amount (> 0), currency (3-letter code, required when amount is set) |
purchase |
productId (required), amount (> 0), currency (3-letter code, required when amount is set) |
form_start |
formId (required), formName |
form_submit |
action, formId (required), formName |
signup |
— |
login |
— |
logout |
— |
app_open |
— |
app_close |
— |
notification_received |
campaignId (required), notificationType |
notification_clicked |
campaignId (required), notificationType |
notification_dismissed |
campaignId (required), notificationType |
video_play |
videoId (required), positionS (>= 0) |
video_pause |
videoId (required), positionS (>= 0) |
error_occurred |
errorCode (required) |
share |
— |
Push notifications are opt-in. Import subscribePush / unsubscribePush only if you need push — users who only use analytics pay zero bundle cost.
- A VAPID key pair — generate one with:
npx web-push generate-vapid-keys
- Your backend configured with the private VAPID key to sign push messages.
- A service worker (see options below).
You need a service worker to receive push messages. Choose one of two approaches:
Copy pug_sw.js from this package into your public root (or wherever your site is served from). It handles install, activate, push, and notificationclick out of the box.
cp node_modules/pug-web/pug_sw.js public/pug_sw.js
Then pass the path when calling subscribePush (defaults to /pug_sw.js if omitted):
await subscribePush(VAPID_PUBLIC_KEY, { swPath: '/pug_sw.js' })If you already have a service worker, add these event listeners to it:
self.addEventListener('push', (event) => {
const data = event.data.json()
event.waitUntil(self.registration.showNotification(data.title, data.options))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
if (event.notification.data?.url) {
clients.openWindow(event.notification.data.url)
}
})Note: This simplified handler does not support
setupNotificationClickTracking. For notification click tracking, use the fullpug_sw.jsinstead.
Then pass your existing service worker path to subscribePush:
await subscribePush(VAPID_PUBLIC_KEY, { swPath: '/my-sw.js' })Registers the browser for push notifications and sends the subscription to Pug's DevicesService.Subscribe RPC.
- Registers (or reuses) the service worker at
options.swPath(default:/pug_sw.js). - Calls
pushManager.subscribe()with your VAPID public key. - Generates (or retrieves) a persistent device ID stored in
localStorageunderpug_device_id. - Sends the subscription token to the backend.
You are responsible for requesting notification permission before calling subscribePush. The browser's pushManager.subscribe() will throw if permission has not been granted.
import { subscribePush } from 'pug-web'
const handleEnablePush = async () => {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return
await subscribePush('BExampleVAPIDPublicKeyBase64url...', {
endpoint: 'https://your-backend.example.com', // same as init()
apiKey: 'your-api-key', // same as init()
swPath: '/pug_sw.js', // optional, defaults to /pug_sw.js
profileId: 'user-uuid', // optional, links push device to a known profile
profileExternalId: 'user@example.com', // optional
})
}| Option | Type | Description |
|---|---|---|
endpoint |
string |
Required. Backend base URL (same value passed to init()). |
apiKey |
string |
Required. API key (same value passed to init()). |
swPath |
string |
Path to the service worker file. Defaults to /pug_sw.js. |
profileId |
string |
Pug profile UUID to associate with this device. |
profileExternalId |
string |
External identifier (e.g. email) to associate with this device. |
Tracks notification_clicked events reliably across two cases:
- Page already open — the service worker sends a
postMessage; this function listens for it and callstrack. - Page opened by the click — the service worker appends
?pug_nc=<data>to the URL; this function reads it on load, callstrack, then removes the param withhistory.replaceState.
Call it once after init(). It returns a cleanup function — pass it to destroy() or call it on SPA teardown.
import { init, track, destroy } from 'pug-web'
import { setupNotificationClickTracking } from 'pug-web'
init('your-project-id', { apiKey: 'your-api-key' })
const cleanupPushTracking = setupNotificationClickTracking(track)
// On teardown:
// cleanupPushTracking()
// destroy()The notification_clicked event receives whatever was set in event.notification.data when the notification was shown:
{
"title": "New message",
"options": {
"body": "You have a reply.",
"data": {
"url": "https://your-app.example.com/inbox",
"campaignId": "abc123"
}
}
}→ track('notification_clicked', { url: '...', campaignId: 'abc123' })
If
campaignIdis absent or empty in the notification data, it defaults to'(unknown)'.
Unsubscribes the browser from push notifications. Does not remove the device from the backend — call your own backend API if you need server-side cleanup.
import { unsubscribePush } from 'pug-web'
await unsubscribePush({ swPath: '/pug_sw.js' })Your backend should send push messages with this JSON body:
{
"title": "Hello!",
"options": {
"body": "You have a new message.",
"icon": "/icon.png",
"data": {
"url": "https://your-app.example.com/inbox"
}
}
}options is passed directly to showNotification. The data.url field is used by notificationclick to open a URL when the user taps the notification.