From ebe854dd386145f95819e32316dea04380ea3d54 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 13 Sep 2026 17:27:02 +0200 Subject: [PATCH 1/6] feat: Pulse Analytics Adds Pulse (cookie-free web analytics by Ciphera) to the registry as pulseAnalytics / useScriptPulseAnalytics. Options domain, apiUrl, trackScroll, trackOutbound and trackDownloads map to the tracker's data-* attributes. The tracker treats data-no-* as presence flags and Unhead renders a false prop as data-x="false", so the composable writes '' or undefined, never a boolean. track() calls made before load are queued and replayed once the script is in. Bundle is on; proxy is intentionally off: Pulse identifies visitors server-side from the connecting IP and user agent, so beacons routed through the Nuxt server would collapse every visitor into one identity, and its bot filtering counts a datacenter origin as a signal. --- FIRST_PARTY.md | 2 +- docs/content/docs/1.guides/2.first-party.md | 1 + docs/content/scripts/pulse-analytics.md | 79 ++++++++ packages/script/src/registry-logos.ts | 1 + packages/script/src/registry-types.json | 53 +++++ packages/script/src/registry.ts | 21 ++ .../src/runtime/registry/pulse-analytics.ts | 104 ++++++++++ .../script/src/runtime/registry/schemas.ts | 29 +++ packages/script/src/runtime/types.ts | 4 +- packages/script/src/script-meta.ts | 4 + packages/script/src/script-sizes.json | 69 +++++++ playground/nuxt.config.ts | 2 + playground/pages/index.vue | 1 + .../pages/third-parties/pulse-analytics.vue | 184 ++++++++++++++++++ test/e2e-dev/first-party.test.ts | 4 + test/fixtures/first-party/nuxt.config.ts | 2 + test/fixtures/first-party/pages/index.vue | 11 +- test/fixtures/first-party/pages/pulse.vue | 40 ++++ test/types/types.test-d.ts | 1 + test/unit/proxy-configs.test.ts | 9 + 20 files changed, 614 insertions(+), 7 deletions(-) create mode 100644 docs/content/scripts/pulse-analytics.md create mode 100644 packages/script/src/runtime/registry/pulse-analytics.ts create mode 100644 playground/pages/third-parties/pulse-analytics.vue create mode 100644 test/fixtures/first-party/pages/pulse.vue diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index 920cdfb54..60959c927 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -131,7 +131,7 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | | `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | -Note: GTM, Segment, Crisp, Mixpanel, Bing UET, and SpeedCurve have no proxy capability, so no privacy transforms are applied. +Note: GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, and Pulse have no proxy capability, so no privacy transforms are applied. ## Script Support diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index fc7cdbb21..81bdddb14 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -285,6 +285,7 @@ These integrations serve their main loader from your domain, but some runtime re |--------|--------------------------| | [Google Tag Manager](/scripts/google-tag-manager) | GTM's core function is loading other scripts at runtime. Those runtime scripts bypass build-time rewriting. | | [Fathom](/scripts/fathom-analytics) | Fathom's bot detection rejects beacons from the server's IP, so Nuxt Scripts bundles the SDK but leaves beacons direct. | +| [Pulse Analytics](/scripts/pulse-analytics) | Pulse identifies visitors server-side from the connecting IP, so proxied beacons would merge every visitor into one; Nuxt Scripts bundles the tracker but leaves beacons direct. | | [Segment](/scripts/segment) | SDK constructs API URLs dynamically, bypassing request interception. | | [Crisp](/scripts/crisp) | SDK loads secondary scripts and CSS at runtime from `client.crisp.chat`. | | [Mixpanel](/scripts/mixpanel-analytics) | No proxy integration yet. | diff --git a/docs/content/scripts/pulse-analytics.md b/docs/content/scripts/pulse-analytics.md new file mode 100644 index 000000000..d0d4d461c --- /dev/null +++ b/docs/content/scripts/pulse-analytics.md @@ -0,0 +1,79 @@ +--- +title: Pulse Analytics +description: Load the Pulse tracker and record custom events. +links: + - label: Source + icon: i-simple-icons-github + to: https://github.com/nuxt/scripts/blob/main/packages/script/src/runtime/registry/pulse-analytics.ts + size: xs +--- + +[Pulse](https://pulse.ciphera.net/) is cookie-free web analytics by [Ciphera](https://ciphera.net/). The tracker sets no cookies and stores no client-side identifier: Pulse identifies visitors server-side with rotating hashes. The [script reference](https://docs.ciphera.net/pulse/script-installation) documents every attribute the composable maps. + +::script-stats +:: + +::script-docs +:: + +## Proxying is not supported + +Pulse **cannot** be proxied (`proxy: true`). + +Pulse derives visitor identity on the server from the connecting IP address and user agent. Beacons routed through your Nuxt server would all arrive from that server's IP, so every visitor would collapse into one identity per user agent. Pulse's [bot filtering](https://docs.ciphera.net/pulse/bot-filtering) also counts a datacenter or hosting-provider origin as one of its signals, which is where a proxied server sits. Because identity never reaches the browser, there is nothing for a first-party proxy to protect. + +Bundling (`bundle: true`) **is** supported: the tracker is served from your origin, and the browser sends its beacons directly to the Pulse API. + +## Self-hosted or proxied API + +`apiUrl` sets the origin the tracker posts to (the `data-api` attribute). Leave it unset for the hosted Pulse API. + +```ts +useScriptPulseAnalytics({ + domain: 'example.com', + apiUrl: 'https://pulse-api.example.com', +}) +``` + +## Defaults + +- **Trigger: `onNuxtReady`** The script loads when the Nuxt app is ready. +- Pageviews, SPA route changes, scroll depth, outbound links and file downloads are recorded automatically. Set `trackScroll`, `trackOutbound` or `trackDownloads` to `false` to switch one off. +- Do Not Track and Global Privacy Control are honoured by the tracker itself. + +Use the composable's `proxy` object for `track` calls. Calls made before the script has loaded are queued and sent once it has. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptPulseAnalytics() +function trackSignup() { + proxy.track('signup', { plan: 'pro' }) +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptPulseAnalytics() +onLoaded(({ track }) => { + track('purchase', { product: 'annual_plan' }, 99) +}) +``` + +:: + +Event names may contain letters, numbers and underscores. Property values must be strings, and the optional third argument is revenue. See the [custom events reference](https://docs.ciphera.net/pulse/custom-events). + +::script-types +:: + +## Example + +The default trigger waits until Nuxt is ready: + +```vue [app.vue] + +``` diff --git a/packages/script/src/registry-logos.ts b/packages/script/src/registry-logos.ts index 0e2e3dec6..77a698ce7 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -10,6 +10,7 @@ export const LOGOS = { vercelAnalytics: ``, posthog: ``, fathomAnalytics: ``, + pulseAnalytics: `https://cdn.ciphera.net/pulse/pulse_icon_64.png`, matomoAnalytics: ``, rybbitAnalytics: { light: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 7a8588858..2d80904f1 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1064,6 +1064,23 @@ "code": "export interface PostHogConsent {\n /** Call `posthog.opt_in_capturing()`. */\n optIn: () => void\n /** Call `posthog.opt_out_capturing()`. For boot-time opt-out, use `defaultConsent: 'opt-out'` instead. */\n optOut: () => void\n}" } ], + "pulse-analytics": [ + { + "name": "PulseAnalyticsOptions", + "kind": "const", + "code": "export const PulseAnalyticsOptions = object({\n /**\n * The site domain as registered in Pulse.\n * @see https://docs.ciphera.net/pulse/script-installation\n */\n domain: string(),\n /**\n * The API origin the tracker posts events to. Only needed for a self-hosted\n * or reverse-proxied Pulse API.\n * @default 'https://pulse-api.ciphera.net'\n */\n apiUrl: optional(string()),\n /**\n * Record scroll depth.\n * @default true\n */\n trackScroll: optional(boolean()),\n /**\n * Record outbound link clicks as `outbound_link` events.\n * @default true\n */\n trackOutbound: optional(boolean()),\n /**\n * Record file download clicks as `file_download` events.\n * @default true\n */\n trackDownloads: optional(boolean()),\n})" + }, + { + "name": "PulseAnalyticsApi", + "kind": "interface", + "code": "export interface PulseAnalyticsApi {\n /**\n * Records a custom event.\n * @param name Event name: letters, numbers and underscores, up to 64 characters.\n * Trimmed and lowercased before storage.\n * @param props Optional properties. Values must be strings; a number or boolean\n * rejects the whole event.\n * @param revenue Optional monetary value, zero or greater.\n * @see https://docs.ciphera.net/pulse/custom-events\n */\n track: (name: string, props?: Record, revenue?: number) => void\n /**\n * The current pathname as Pulse records it: trailing slash stripped, root kept\n * as `/`. Returns `null` until the tracker has loaded.\n */\n cleanPath: () => string | null\n}" + }, + { + "name": "PulseQueueState", + "kind": "interface", + "code": "interface PulseQueueState {\n queue: Array>\n flushed: boolean\n}" + } + ], "reddit-pixel": [ { "name": "StandardEvents", @@ -2376,6 +2393,42 @@ "description": "Your Reddit Pixel advertiser ID." } ], + "PulseAnalyticsOptions": [ + { + "name": "domain", + "type": "string", + "required": true, + "description": "The site domain as registered in Pulse." + }, + { + "name": "apiUrl", + "type": "string", + "required": false, + "description": "The API origin the tracker posts events to. Only needed for a self-hosted or reverse-proxied Pulse API.", + "defaultValue": "'https://pulse-api.ciphera.net'" + }, + { + "name": "trackScroll", + "type": "boolean", + "required": false, + "description": "Record scroll depth.", + "defaultValue": "true" + }, + { + "name": "trackOutbound", + "type": "boolean", + "required": false, + "description": "Record outbound link clicks as `outbound_link` events.", + "defaultValue": "true" + }, + { + "name": "trackDownloads", + "type": "boolean", + "required": false, + "description": "Record file download clicks as `file_download` events.", + "defaultValue": "true" + } + ], "RybbitAnalyticsOptions": [ { "name": "siteId", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index b1c84d3e1..62a728c83 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -41,6 +41,7 @@ import { MixpanelAnalyticsOptions, NpmOptions, PostHogOptions, + PulseAnalyticsOptions, RedditPixelOptions, RybbitAnalyticsOptions, SegmentOptions, @@ -140,6 +141,14 @@ export const registryMeta: RegistryScriptMeta[] = [ m('vercelAnalytics', 'Vercel Analytics', 'analytics', 'useScriptVercelAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), m('mixpanelAnalytics', 'Mixpanel', 'analytics', 'useScriptMixpanelAnalytics', { bundle: true, partytown: true }, null), m('ahrefsAnalytics', 'Ahrefs Web Analytics', 'analytics', 'useScriptAhrefsAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), + // proxy intentionally off: Pulse identifies visitors server-side from the + // connecting IP + user agent, so proxied beacons — all arriving from the Nuxt + // server's IP — would collapse every visitor into one identity. Its bot + // filtering also counts a datacenter origin as a signal + // (docs.ciphera.net/pulse/bot-filtering). Same family as Fathom (#720). + // Bundle is safe: the tracker reads its config from the script element and + // posts to its own API origin wherever it was served from. + m('pulseAnalytics', 'Pulse Analytics', 'analytics', 'useScriptPulseAnalytics', { bundle: true }, null), // ad m('bingUet', 'Bing UET', 'ad', 'useScriptBingUet', { bundle: true, partytown: true }, null), m('metaPixel', 'Meta Pixel', 'ad', 'useScriptMetaPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), @@ -462,6 +471,18 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, partytown: { forwards: ['mixpanel', 'mixpanel.init', 'mixpanel.track', 'mixpanel.identify', 'mixpanel.people.set', 'mixpanel.reset', 'mixpanel.register', 'mixpanel.opt_in_tracking', 'mixpanel.opt_out_tracking'] }, }), + def('pulseAnalytics', { + schema: PulseAnalyticsOptions, + label: 'Pulse Analytics', + src: 'https://js.ciphera.net/script.js', + category: 'analytics', + envDefaults: { domain: '' }, + // Bundle without proxy: the tracker takes its config from the script + // element's data-* attributes and always posts to `data-api` (default + // pulse-api.ciphera.net), so serving it from /_scripts/assets needs no + // SDK patch. Proxying is unsupported — see the registryMeta note. + bundle: true, + }), // ad def('bingUet', { schema: BingUetOptions, diff --git a/packages/script/src/runtime/registry/pulse-analytics.ts b/packages/script/src/runtime/registry/pulse-analytics.ts new file mode 100644 index 000000000..278d0094f --- /dev/null +++ b/packages/script/src/runtime/registry/pulse-analytics.ts @@ -0,0 +1,104 @@ +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { PulseAnalyticsOptions } from './schemas' + +export { PulseAnalyticsOptions } + +export type PulseAnalyticsInput = RegistryScriptInput + +export interface PulseAnalyticsApi { + /** + * Records a custom event. + * @param name Event name: letters, numbers and underscores, up to 64 characters. + * Trimmed and lowercased before storage. + * @param props Optional properties. Values must be strings; a number or boolean + * rejects the whole event. + * @param revenue Optional monetary value, zero or greater. + * @see https://docs.ciphera.net/pulse/custom-events + */ + track: (name: string, props?: Record, revenue?: number) => void + /** + * The current pathname as Pulse records it: trailing slash stripped, root kept + * as `/`. Returns `null` until the tracker has loaded. + */ + cleanPath: () => string | null +} + +declare global { + interface Window { + pulse: PulseAnalyticsApi + } +} + +// Calls made before the tracker has loaded are queued on globalThis under a +// Symbol so the queue survives across module instances, and flushed from use(). +// This is deliberately not a stub on window.pulse: the tracker runs +// `window.pulse = window.pulse || {}` and then assigns `track`, so a stub's +// queue would be overwritten and lost. +const PULSE_QUEUE_KEY = Symbol.for('nuxt-scripts.pulse-queue') + +interface PulseQueueState { + queue: Array> + flushed: boolean +} + +function getPulseState(): PulseQueueState | undefined { + if (!import.meta.client) + return + const g = globalThis as any + if (!g[PULSE_QUEUE_KEY]) { + g[PULSE_QUEUE_KEY] = { queue: [], flushed: false } + } + return g[PULSE_QUEUE_KEY] +} + +export function useScriptPulseAnalytics(_options?: PulseAnalyticsInput) { + const isPulseReady = () => import.meta.client + && typeof window !== 'undefined' + && typeof window.pulse?.track === 'function' + + // Replay queued calls once the real tracker is present + const flushQueue = () => { + const state = getPulseState() + if (!state || state.flushed || !isPulseReady()) + return + state.flushed = true + while (state.queue.length > 0) { + const args = state.queue.shift()! + window.pulse.track(...args) + } + } + + const track: PulseAnalyticsApi['track'] = (name, props, revenue) => { + if (isPulseReady()) + window.pulse.track(name, props, revenue) + else + getPulseState()?.queue.push([name, props, revenue]) + } + + return useRegistryScript('pulseAnalytics', options => ({ + scriptInput: { + 'src': 'https://js.ciphera.net/script.js', + 'data-domain': options.domain, + 'data-api': options.apiUrl || undefined, + // The tracker treats these as presence flags: the feature is off whenever + // the attribute exists, whatever its value. `false` must therefore never + // be written (Unhead renders it as data-no-scroll="false", which still + // counts as present); an empty string mirrors the documented bare attribute. + 'data-no-scroll': options.trackScroll === false ? '' : undefined, + 'data-no-outbound': options.trackOutbound === false ? '' : undefined, + 'data-no-downloads': options.trackDownloads === false ? '' : undefined, + }, + schema: import.meta.dev ? PulseAnalyticsOptions : undefined, + scriptOptions: { + use() { + // use() runs on status changes; flush as soon as the tracker is loaded + flushQueue() + return { + track, + cleanPath: () => window.pulse?.cleanPath?.() ?? null, + } as PulseAnalyticsApi + }, + }, + }), _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index 815800a11..4683052e1 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -808,6 +808,35 @@ export const RedditPixelOptions = object({ id: string(), }) +export const PulseAnalyticsOptions = object({ + /** + * The site domain as registered in Pulse. + * @see https://docs.ciphera.net/pulse/script-installation + */ + domain: string(), + /** + * The API origin the tracker posts events to. Only needed for a self-hosted + * or reverse-proxied Pulse API. + * @default 'https://pulse-api.ciphera.net' + */ + apiUrl: optional(string()), + /** + * Record scroll depth. + * @default true + */ + trackScroll: optional(boolean()), + /** + * Record outbound link clicks as `outbound_link` events. + * @default true + */ + trackOutbound: optional(boolean()), + /** + * Record file download clicks as `file_download` events. + * @default true + */ + trackDownloads: optional(boolean()), +}) + export const RybbitAnalyticsOptions = object({ /** * Your Rybbit site ID. diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index edcf317c7..5db5700bf 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -36,6 +36,7 @@ import type { NpmInput } from './registry/npm' import type { PayPalInput } from './registry/paypal' import type { PlausibleAnalyticsInput } from './registry/plausible-analytics' import type { PostHogInput } from './registry/posthog' +import type { PulseAnalyticsInput } from './registry/pulse-analytics' import type { RedditPixelInput } from './registry/reddit-pixel' import type { RybbitAnalyticsInput } from './registry/rybbit-analytics' import type { SegmentInput } from './registry/segment' @@ -277,6 +278,7 @@ export interface ScriptRegistry { linkedinInsight?: LinkedInInsightInput paypal?: PayPalInput posthog?: PostHogInput + pulseAnalytics?: PulseAnalyticsInput matomoAnalytics?: MatomoAnalyticsInput mixpanelAnalytics?: MixpanelAnalyticsInput rybbitAnalytics?: RybbitAnalyticsInput @@ -307,7 +309,7 @@ export type BuiltInRegistryScriptKey | 'databuddyAnalytics' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' - | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'matomoAnalytics' + | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'pulseAnalytics' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'stripe' | 'tiktokPixel' | 'xEmbed' | 'xPixel' | 'snapchatPixel' | 'speedcurve' | 'youtubePlayer' | 'vercelAnalytics' | 'vimeoPlayer' | 'umamiAnalytics' | 'usercentrics' | 'gravatar' | 'npm' diff --git a/packages/script/src/script-meta.ts b/packages/script/src/script-meta.ts index 59c490a67..e5046d8c9 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -48,6 +48,10 @@ export const scriptMeta = { urls: ['https://app.rybbit.io/api/script.js'], trackedData: ['page-views', 'events'], }, + pulseAnalytics: { + urls: ['https://js.ciphera.net/script.js'], + trackedData: ['page-views', 'events', 'conversions', 'scrolls'], + }, databuddyAnalytics: { urls: ['https://cdn.databuddy.cc/databuddy.js'], trackedData: ['page-views', 'events'], diff --git a/packages/script/src/script-sizes.json b/packages/script/src/script-sizes.json index ffacbeaae..52a3d57b2 100644 --- a/packages/script/src/script-sizes.json +++ b/packages/script/src/script-sizes.json @@ -459,6 +459,75 @@ } ] }, + "pulseAnalytics": { + "totalTransferKb": 3.5, + "totalDecodedKb": 6.4, + "loadTimeMs": 510, + "collectsWebVitals": false, + "apis": { + "cookies": false, + "localStorage": true, + "sessionStorage": true, + "indexedDB": false, + "canvas": false, + "webgl": false, + "audioContext": false, + "userAgent": false, + "doNotTrack": true, + "hardwareConcurrency": false, + "deviceMemory": false, + "plugins": true, + "languages": true, + "screen": true, + "timezone": false, + "platform": true, + "vendor": false, + "connection": false, + "maxTouchPoints": true, + "devicePixelRatio": false, + "mediaDevices": false, + "getBattery": false, + "referrer": true, + "windowName": false, + "rtcPeerConnection": false, + "geolocation": false, + "serviceWorker": false, + "cacheApi": false, + "sendBeacon": true, + "fetch": true, + "xhr": false, + "websocket": false, + "mutationObserver": true, + "performanceObserver": false, + "intersectionObserver": false + }, + "cookies": [], + "network": { + "requestCount": 1, + "domains": [ + "js.ciphera.net" + ], + "outboundBytes": 0, + "inboundBytes": 3586, + "injectedElements": [] + }, + "performance": { + "taskDurationMs": 31, + "scriptDurationMs": 0, + "heapDeltaKb": 726 + }, + "scripts": [ + { + "url": "https://js.ciphera.net/script.js", + "transferKb": 3.5, + "decodedKb": 6.4, + "encoding": "zstd", + "durationMs": 510, + "initiatorType": "script", + "protocol": "unknown" + } + ] + }, "databuddyAnalytics": { "totalTransferKb": 10.8, "totalDecodedKb": 29.4, diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 93dc2b641..4274a04c4 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -36,6 +36,7 @@ export default defineNuxtConfig({ umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c' }, databuddyAnalytics: { clientId: 'demo-client-123' }, fathomAnalytics: { site: 'BRDEJWKJ' }, + pulseAnalytics: { domain: 'example.com' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, @@ -86,6 +87,7 @@ export default defineNuxtConfig({ plausibleAnalytics: { scriptId: 'gYyxvZhkMzdzXBAtSeSNz', trigger: 'manual' }, umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c', trigger: 'manual' }, fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, + pulseAnalytics: { domain: 'example.com', trigger: 'manual' }, cloudflareWebAnalytics: { token: 'ade278253a19413c9bd923b079870902', trigger: 'manual' }, matomoAnalytics: { matomoUrl: 'https://cdn.matomo.cloud', siteId: '1', trigger: 'manual' }, vercelAnalytics: { trigger: 'manual' }, diff --git a/playground/pages/index.vue b/playground/pages/index.vue index e69ff39b7..7194f5b64 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -24,6 +24,7 @@ function getPlaygroundPath(script: any): string | null { 'matomo-analytics': '/third-parties/matomo-analytics/nuxt-scripts', 'ahrefs-analytics': '/third-parties/ahrefs-analytics/nuxt-scripts', 'rybbit-analytics': '/third-parties/rybbit-analytics', + 'pulse-analytics': '/third-parties/pulse-analytics', 'databuddy-analytics': '/third-parties/databuddy-analytics', 'umami-analytics': '/third-parties/umami-analytics', 'segment': '/third-parties/segment', diff --git a/playground/pages/third-parties/pulse-analytics.vue b/playground/pages/third-parties/pulse-analytics.vue new file mode 100644 index 000000000..dbcd56072 --- /dev/null +++ b/playground/pages/third-parties/pulse-analytics.vue @@ -0,0 +1,184 @@ + + + diff --git a/test/e2e-dev/first-party.test.ts b/test/e2e-dev/first-party.test.ts index 41f67ce29..18cf3ec70 100644 --- a/test/e2e-dev/first-party.test.ts +++ b/test/e2e-dev/first-party.test.ts @@ -663,6 +663,7 @@ describe('first-party privacy stripping', () => { 'umamiAnalytics', // umami.track() triggers fetch POST // cloudflareWebAnalytics — auto-engagement only, no CTA buttons // fathomAnalytics — bundle/proxy disabled (Fathom bot-detection flags self-hosted/proxied traffic, see #720) + // pulseAnalytics — bundle only, no proxy (server-side visitor identity needs the real client IP) ]) /** @@ -935,6 +936,7 @@ describe('first-party privacy stripping', () => { }, 30000) // fathomAnalytics — bundle/proxy disabled in registry (see #720), script loads directly from CDN + // pulseAnalytics — bundle only, no proxy in registry; beacons go direct to pulse-api.ciphera.net it('intercom', async () => { const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('intercom', '/intercom-test') @@ -998,6 +1000,7 @@ describe('first-party privacy stripping', () => { { name: 'umamiAnalytics', path: '/umami' }, { name: 'databuddyAnalytics', path: '/databuddy' }, { name: 'fathomAnalytics', path: '/fathom' }, + { name: 'pulseAnalytics', path: '/pulse' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, { name: 'posthog', path: '/posthog' }, @@ -1183,6 +1186,7 @@ describe('first-party privacy stripping', () => { { name: 'umamiAnalytics', path: '/umami' }, { name: 'databuddyAnalytics', path: '/databuddy' }, { name: 'fathomAnalytics', path: '/fathom' }, + { name: 'pulseAnalytics', path: '/pulse' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, { name: 'posthog', path: '/posthog' }, diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 9e42f4acd..764a0bdf8 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -24,6 +24,7 @@ export default defineNuxtConfig({ umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c' }, databuddyAnalytics: { clientId: 'demo-client-123' }, fathomAnalytics: { site: 'BRDEJWKJ' }, + pulseAnalytics: { domain: 'example.com' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, @@ -69,6 +70,7 @@ export default defineNuxtConfig({ umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c', trigger: 'manual' }, databuddyAnalytics: { clientId: 'demo-client-123', trigger: 'manual' }, fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, + pulseAnalytics: { domain: 'example.com', trigger: 'manual' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W', trigger: 'manual' }, intercom: { app_id: 'akg5rmxb', trigger: 'manual' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417', trigger: 'manual' }, diff --git a/test/fixtures/first-party/pages/index.vue b/test/fixtures/first-party/pages/index.vue index cf9c8c223..fd6569218 100644 --- a/test/fixtures/first-party/pages/index.vue +++ b/test/fixtures/first-party/pages/index.vue @@ -8,6 +8,7 @@
  • Plausible Analytics
  • Cloudflare Web Analytics
  • Fathom Analytics
  • +
  • Pulse Analytics
  • Umami Analytics
  • PostHog
  • Rybbit Analytics
  • @@ -20,7 +21,7 @@
  • Hotjar
  • Ads / Pixels

    -
      +
      1. Meta Pixel
      2. X Pixel
      3. TikTok Pixel
      4. @@ -31,23 +32,23 @@
      5. Carbon Ads

      Support

      -
        +
        1. Intercom
        2. Crisp

        Payments

        -
          +
          1. Stripe
          2. PayPal
          3. Lemon Squeezy

          Video

          -
            +
            1. Vimeo Player
            2. YouTube Player

            Utility

            -
              +
              1. NPM (js-confetti)
              2. Gravatar
              3. Google reCAPTCHA
              4. diff --git a/test/fixtures/first-party/pages/pulse.vue b/test/fixtures/first-party/pages/pulse.vue new file mode 100644 index 000000000..c8ee1dbfc --- /dev/null +++ b/test/fixtures/first-party/pages/pulse.vue @@ -0,0 +1,40 @@ + + + diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index cdde41b55..02b8aca8c 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -41,6 +41,7 @@ describe('module options registry', () => { expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() + expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() expectTypeOf().not.toBeAny() diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index e819b274d..3bdc5c8bf 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -378,6 +378,14 @@ describe('proxy configs', () => { expect(config).toBeUndefined() }) + it('does not return proxy config for pulseAnalytics (server-side visitor identity needs the real client IP)', async () => { + // Pulse hashes the connecting IP + user agent into its visitor identity, so + // beacons routed through the Nuxt server would merge every visitor into one; + // its bot filtering also counts a datacenter origin as a signal. + const config = (await getProxyConfigs()).pulseAnalytics + expect(config).toBeUndefined() + }) + it('returns proxy config for intercom', async () => { const config = (await getProxyConfigs()).intercom expect(config).toBeDefined() @@ -452,6 +460,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('databuddyAnalytics') expect(configs).toHaveProperty('ahrefsAnalytics') expect(configs).not.toHaveProperty('fathomAnalytics') + expect(configs).not.toHaveProperty('pulseAnalytics') expect(configs).toHaveProperty('intercom') expect(configs).not.toHaveProperty('crisp') expect(configs).not.toHaveProperty('deskcrew') From 91770f0da62a86494b300509af1c3f53dc8531e1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 13 Sep 2026 17:46:45 +0200 Subject: [PATCH 2/6] fix: bound the Pulse Analytics queue when the tracker declines or never loads The pre-load queue could grow for the life of the page whenever isPulseReady() never became true. That is the normal case for an opted-out visitor: the tracker sets window.__pulseInstalled and then exits on Do Not Track, Global Privacy Control, the ?pulse-ignore toggle or automation without ever defining window.pulse. Once the tracker has run and declined, the queue is dropped and later track() calls are no-ops; if the script never runs at all, the queue is capped at 100 entries. Covered by a unit test that also pins the data-* presence-flag mapping. --- docs/content/scripts/pulse-analytics.md | 2 +- packages/script/src/registry-types.json | 2 +- .../src/runtime/registry/pulse-analytics.ts | 59 +++++++-- test/unit/pulse-analytics-queue.test.ts | 122 ++++++++++++++++++ 4 files changed, 172 insertions(+), 13 deletions(-) create mode 100644 test/unit/pulse-analytics-queue.test.ts diff --git a/docs/content/scripts/pulse-analytics.md b/docs/content/scripts/pulse-analytics.md index d0d4d461c..20c5e029a 100644 --- a/docs/content/scripts/pulse-analytics.md +++ b/docs/content/scripts/pulse-analytics.md @@ -41,7 +41,7 @@ useScriptPulseAnalytics({ - Pageviews, SPA route changes, scroll depth, outbound links and file downloads are recorded automatically. Set `trackScroll`, `trackOutbound` or `trackDownloads` to `false` to switch one off. - Do Not Track and Global Privacy Control are honoured by the tracker itself. -Use the composable's `proxy` object for `track` calls. Calls made before the script has loaded are queued and sent once it has. +Use the composable's `proxy` object for `track` calls. Calls made before the script has loaded are queued and sent once it has. When the visitor has opted out (Do Not Track, Global Privacy Control), the tracker does not run and the queue is discarded. ::code-group diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 2d80904f1..e6abb5da4 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1078,7 +1078,7 @@ { "name": "PulseQueueState", "kind": "interface", - "code": "interface PulseQueueState {\n queue: Array>\n flushed: boolean\n}" + "code": "interface PulseQueueState {\n queue: Array>\n flushed: boolean\n /** The tracker ran but declined to record: nothing will ever consume the queue. */\n declined: boolean\n}" } ], "reddit-pixel": [ diff --git a/packages/script/src/runtime/registry/pulse-analytics.ts b/packages/script/src/runtime/registry/pulse-analytics.ts index 278d0094f..8e6dd4b89 100644 --- a/packages/script/src/runtime/registry/pulse-analytics.ts +++ b/packages/script/src/runtime/registry/pulse-analytics.ts @@ -27,6 +27,11 @@ export interface PulseAnalyticsApi { declare global { interface Window { pulse: PulseAnalyticsApi + /** + * Set by the tracker before its opt-out checks run. Present without + * `pulse.track` means the tracker ran and declined to record. + */ + __pulseInstalled?: boolean } } @@ -35,32 +40,54 @@ declare global { // This is deliberately not a stub on window.pulse: the tracker runs // `window.pulse = window.pulse || {}` and then assigns `track`, so a stub's // queue would be overwritten and lost. +// +// The queue is bounded. The tracker exits without defining `window.pulse` when +// the visitor has opted out (Do Not Track, Global Privacy Control, the +// `?pulse-ignore` toggle, automation), so readiness can legitimately never come: +// once the tracker has run and declined, the queue is dropped and later calls +// are no-ops; if the script never runs at all (blocked, offline) the queue stops +// growing at MAX_QUEUED_EVENTS. const PULSE_QUEUE_KEY = Symbol.for('nuxt-scripts.pulse-queue') +const MAX_QUEUED_EVENTS = 100 interface PulseQueueState { queue: Array> flushed: boolean + /** The tracker ran but declined to record: nothing will ever consume the queue. */ + declined: boolean } function getPulseState(): PulseQueueState | undefined { - if (!import.meta.client) + if (typeof window === 'undefined') return const g = globalThis as any if (!g[PULSE_QUEUE_KEY]) { - g[PULSE_QUEUE_KEY] = { queue: [], flushed: false } + g[PULSE_QUEUE_KEY] = { queue: [], flushed: false, declined: false } } return g[PULSE_QUEUE_KEY] } -export function useScriptPulseAnalytics(_options?: PulseAnalyticsInput) { - const isPulseReady = () => import.meta.client - && typeof window !== 'undefined' - && typeof window.pulse?.track === 'function' +function isPulseReady() { + return typeof window !== 'undefined' && typeof window.pulse?.track === 'function' +} + +function hasPulseDeclined() { + return typeof window !== 'undefined' && window.__pulseInstalled === true && !isPulseReady() +} - // Replay queued calls once the real tracker is present +export function useScriptPulseAnalytics(_options?: PulseAnalyticsInput) { + // Replay queued calls once the real tracker is present; drop them once it + // is known that it never will be. const flushQueue = () => { const state = getPulseState() - if (!state || state.flushed || !isPulseReady()) + if (!state || state.flushed || state.declined) + return + if (hasPulseDeclined()) { + state.declined = true + state.queue.length = 0 + return + } + if (!isPulseReady()) return state.flushed = true while (state.queue.length > 0) { @@ -70,10 +97,20 @@ export function useScriptPulseAnalytics(_options?: } const track: PulseAnalyticsApi['track'] = (name, props, revenue) => { - if (isPulseReady()) + if (isPulseReady()) { window.pulse.track(name, props, revenue) - else - getPulseState()?.queue.push([name, props, revenue]) + return + } + const state = getPulseState() + if (!state || state.declined) + return + if (hasPulseDeclined()) { + state.declined = true + state.queue.length = 0 + return + } + if (state.queue.length < MAX_QUEUED_EVENTS) + state.queue.push([name, props, revenue]) } return useRegistryScript('pulseAnalytics', options => ({ diff --git a/test/unit/pulse-analytics-queue.test.ts b/test/unit/pulse-analytics-queue.test.ts new file mode 100644 index 000000000..5e95e3b5c --- /dev/null +++ b/test/unit/pulse-analytics-queue.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { useScriptPulseAnalytics } from '../../packages/script/src/runtime/registry/pulse-analytics' + +const mocks = vi.hoisted(() => ({ + definition: undefined as any, + useRegistryScript: vi.fn(), +})) + +vi.mock('@unhead/vue', () => ({ useHead: vi.fn() })) + +vi.mock('../../packages/script/src/runtime/utils', () => ({ + useRegistryScript: mocks.useRegistryScript, +})) + +const QUEUE_KEY = Symbol.for('nuxt-scripts.pulse-queue') + +function queue(): unknown[] { + return (globalThis as any)[QUEUE_KEY]?.queue ?? [] +} + +function setup(options: Record = { domain: 'example.com' }) { + mocks.useRegistryScript.mockImplementation((_key: string, factory: (o: any) => any) => { + mocks.definition = factory(options) + return { status: ref('awaitingLoad'), signal: new AbortController().signal, load: vi.fn() } + }) + useScriptPulseAnalytics(options as any) + const use = mocks.definition.scriptOptions.use as () => { track: (...args: any[]) => void, cleanPath: () => string | null } + return { use, scriptInput: mocks.definition.scriptInput as Record } +} + +describe('pulse analytics', () => { + beforeEach(() => { + vi.clearAllMocks() + delete (globalThis as any)[QUEUE_KEY] + delete (window as any).pulse + delete window.__pulseInstalled + }) + + describe('script input', () => { + it('writes only data-domain by default', () => { + const { scriptInput } = setup() + expect(scriptInput).toEqual({ + 'src': 'https://js.ciphera.net/script.js', + 'data-domain': 'example.com', + 'data-api': undefined, + 'data-no-scroll': undefined, + 'data-no-outbound': undefined, + 'data-no-downloads': undefined, + }) + }) + + it('writes a presence flag (empty string, never a boolean) only for options set to false', () => { + // The tracker reads data-no-* with hasAttribute(), and Unhead renders a + // `false` prop as data-x="false", which would still count as present. + const { scriptInput } = setup({ domain: 'example.com', apiUrl: 'https://pulse-api.example.com', trackScroll: false, trackOutbound: true }) + expect(scriptInput['data-api']).toBe('https://pulse-api.example.com') + expect(scriptInput['data-no-scroll']).toBe('') + expect(scriptInput['data-no-outbound']).toBeUndefined() + expect(scriptInput['data-no-downloads']).toBeUndefined() + }) + }) + + describe('queue', () => { + it('queues track() before load and replays in order with full arguments once the tracker is ready', () => { + const { use } = setup() + const api = use() + api.track('signup', { plan: 'pro' }) + api.track('purchase', { product: 'annual_plan' }, 99) + expect(queue()).toHaveLength(2) + + const track = vi.fn() + window.__pulseInstalled = true + window.pulse = { track, cleanPath: () => '/' } + use() // use() runs again on the status change + expect(track.mock.calls).toEqual([ + ['signup', { plan: 'pro' }, undefined], + ['purchase', { product: 'annual_plan' }, 99], + ]) + expect(queue()).toHaveLength(0) + + api.track('later') + expect(track).toHaveBeenLastCalledWith('later', undefined, undefined) + expect(queue()).toHaveLength(0) + }) + + it('drops the queue and stops queuing once the tracker has run and declined (visitor opted out)', () => { + const { use } = setup() + const api = use() + api.track('before') + expect(queue()).toHaveLength(1) + + // The tracker sets __pulseInstalled first, then exits on Do Not Track / + // Global Privacy Control without ever defining window.pulse. + window.__pulseInstalled = true + use() + expect(queue()).toHaveLength(0) + + api.track('after') + expect(queue()).toHaveLength(0) + }) + + it('caps the queue when the tracker never runs', () => { + const { use } = setup() + const api = use() + for (let i = 0; i < 150; i++) + api.track(`event_${i}`) + expect(queue()).toHaveLength(100) + }) + + it('cleanPath() is null before load and the tracker value after', () => { + const { use } = setup() + expect(use().cleanPath()).toBeNull() + window.__pulseInstalled = true + window.pulse = { track: vi.fn(), cleanPath: () => '/pricing' } + expect(use().cleanPath()).toBe('/pricing') + }) + }) +}) From f8531ab2f1d4bb80e8639ea3af49d0d21b5acc71 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 13:26:16 +1000 Subject: [PATCH 3/6] docs(pulse-analytics): condense registry comments --- packages/script/src/registry.ts | 15 ++++--------- .../src/runtime/registry/pulse-analytics.ts | 21 +++++-------------- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 62a728c83..95a8d539b 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -141,13 +141,8 @@ export const registryMeta: RegistryScriptMeta[] = [ m('vercelAnalytics', 'Vercel Analytics', 'analytics', 'useScriptVercelAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), m('mixpanelAnalytics', 'Mixpanel', 'analytics', 'useScriptMixpanelAnalytics', { bundle: true, partytown: true }, null), m('ahrefsAnalytics', 'Ahrefs Web Analytics', 'analytics', 'useScriptAhrefsAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), - // proxy intentionally off: Pulse identifies visitors server-side from the - // connecting IP + user agent, so proxied beacons — all arriving from the Nuxt - // server's IP — would collapse every visitor into one identity. Its bot - // filtering also counts a datacenter origin as a signal - // (docs.ciphera.net/pulse/bot-filtering). Same family as Fathom (#720). - // Bundle is safe: the tracker reads its config from the script element and - // posts to its own API origin wherever it was served from. + // No proxy: Pulse derives visitor identity from the connecting IP, so proxied + // beacons collapse every visitor into one. Same family as Fathom (#720). m('pulseAnalytics', 'Pulse Analytics', 'analytics', 'useScriptPulseAnalytics', { bundle: true }, null), // ad m('bingUet', 'Bing UET', 'ad', 'useScriptBingUet', { bundle: true, partytown: true }, null), @@ -477,10 +472,8 @@ export async function registry(resolve?: (path: string) => Promise): Pro src: 'https://js.ciphera.net/script.js', category: 'analytics', envDefaults: { domain: '' }, - // Bundle without proxy: the tracker takes its config from the script - // element's data-* attributes and always posts to `data-api` (default - // pulse-api.ciphera.net), so serving it from /_scripts/assets needs no - // SDK patch. Proxying is unsupported — see the registryMeta note. + // Bundling needs no SDK patch: the tracker reads data-* from its own + // script element and posts to `data-api` wherever it is served from. bundle: true, }), // ad diff --git a/packages/script/src/runtime/registry/pulse-analytics.ts b/packages/script/src/runtime/registry/pulse-analytics.ts index 8e6dd4b89..52ab68f35 100644 --- a/packages/script/src/runtime/registry/pulse-analytics.ts +++ b/packages/script/src/runtime/registry/pulse-analytics.ts @@ -35,18 +35,9 @@ declare global { } } -// Calls made before the tracker has loaded are queued on globalThis under a -// Symbol so the queue survives across module instances, and flushed from use(). -// This is deliberately not a stub on window.pulse: the tracker runs -// `window.pulse = window.pulse || {}` and then assigns `track`, so a stub's -// queue would be overwritten and lost. -// -// The queue is bounded. The tracker exits without defining `window.pulse` when -// the visitor has opted out (Do Not Track, Global Privacy Control, the -// `?pulse-ignore` toggle, automation), so readiness can legitimately never come: -// once the tracker has run and declined, the queue is dropped and later calls -// are no-ops; if the script never runs at all (blocked, offline) the queue stops -// growing at MAX_QUEUED_EVENTS. +// Queued on globalThis, not on a `window.pulse` stub: the tracker runs +// `window.pulse = window.pulse || {}` then assigns `track`, so a stub is lost. +// Bounded because the tracker can decline (DNT, GPC, `?pulse-ignore`, automation). const PULSE_QUEUE_KEY = Symbol.for('nuxt-scripts.pulse-queue') const MAX_QUEUED_EVENTS = 100 @@ -118,10 +109,8 @@ export function useScriptPulseAnalytics(_options?: 'src': 'https://js.ciphera.net/script.js', 'data-domain': options.domain, 'data-api': options.apiUrl || undefined, - // The tracker treats these as presence flags: the feature is off whenever - // the attribute exists, whatever its value. `false` must therefore never - // be written (Unhead renders it as data-no-scroll="false", which still - // counts as present); an empty string mirrors the documented bare attribute. + // Presence flags: the tracker reads them with hasAttribute(), so a + // rendered `data-no-scroll="false"` would still switch scroll tracking off. 'data-no-scroll': options.trackScroll === false ? '' : undefined, 'data-no-outbound': options.trackOutbound === false ? '' : undefined, 'data-no-downloads': options.trackDownloads === false ? '' : undefined, From 1c15a4a15e63ff463ec43b78c6c5de7690005556 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 13:30:27 +1000 Subject: [PATCH 4/6] test(e2e): remove timing races in bundle and reload waits --- test/e2e/base.test.ts | 2 ++ test/e2e/basic.test.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/base.test.ts b/test/e2e/base.test.ts index 9e22fd090..dead02b16 100644 --- a/test/e2e/base.test.ts +++ b/test/e2e/base.test.ts @@ -17,6 +17,8 @@ describe('base', async () => { }) it('bundle', async () => { const page = await createPage('/foo/bundle-use-script') + // the bundled script is injected on onNuxtReady, which lands after hydration + await page.waitForSelector('script[src^="/foo/_scripts/assets/"]', { state: 'attached', timeout: 15000 }) const sources = await page.$$eval('script[src]', scripts => scripts.map(script => script.getAttribute('src'))) expect(sources).toContain('/foo/_scripts/assets/ff1523fb7389539c.js') }) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index b50f3cf68..b48427707 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -60,7 +60,7 @@ async function waitForLogCount( logs: () => { text: string }[], text: string, expected: number, - timeoutMs = 10000, + timeoutMs = 20000, ) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { From 6523795986acef8171f11e87b9251c864c8b85e6 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 13:48:29 +1000 Subject: [PATCH 5/6] docs(pulse-analytics): humanize prose, align with sibling pages --- docs/content/docs/1.guides/2.first-party.md | 2 +- docs/content/scripts/pulse-analytics.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 81bdddb14..6f088c14c 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -285,7 +285,7 @@ These integrations serve their main loader from your domain, but some runtime re |--------|--------------------------| | [Google Tag Manager](/scripts/google-tag-manager) | GTM's core function is loading other scripts at runtime. Those runtime scripts bypass build-time rewriting. | | [Fathom](/scripts/fathom-analytics) | Fathom's bot detection rejects beacons from the server's IP, so Nuxt Scripts bundles the SDK but leaves beacons direct. | -| [Pulse Analytics](/scripts/pulse-analytics) | Pulse identifies visitors server-side from the connecting IP, so proxied beacons would merge every visitor into one; Nuxt Scripts bundles the tracker but leaves beacons direct. | +| [Pulse](/scripts/pulse-analytics) | Pulse identifies visitors from the connecting IP, so proxied beacons would merge everyone into one visitor. Nuxt Scripts bundles the tracker but leaves beacons direct. | | [Segment](/scripts/segment) | SDK constructs API URLs dynamically, bypassing request interception. | | [Crisp](/scripts/crisp) | SDK loads secondary scripts and CSS at runtime from `client.crisp.chat`. | | [Mixpanel](/scripts/mixpanel-analytics) | No proxy integration yet. | diff --git a/docs/content/scripts/pulse-analytics.md b/docs/content/scripts/pulse-analytics.md index 20c5e029a..9a7e9f165 100644 --- a/docs/content/scripts/pulse-analytics.md +++ b/docs/content/scripts/pulse-analytics.md @@ -8,7 +8,7 @@ links: size: xs --- -[Pulse](https://pulse.ciphera.net/) is cookie-free web analytics by [Ciphera](https://ciphera.net/). The tracker sets no cookies and stores no client-side identifier: Pulse identifies visitors server-side with rotating hashes. The [script reference](https://docs.ciphera.net/pulse/script-installation) documents every attribute the composable maps. +[Pulse](https://pulse.ciphera.net/) is cookie-free web analytics by [Ciphera](https://ciphera.net/). The tracker leaves nothing in the browser, no cookie and no stored identifier; Pulse works out who a visitor is on its own servers, from rotating hashes. The [script reference](https://docs.ciphera.net/pulse/script-installation) lists every attribute this composable maps. ::script-stats :: @@ -20,9 +20,9 @@ links: Pulse **cannot** be proxied (`proxy: true`). -Pulse derives visitor identity on the server from the connecting IP address and user agent. Beacons routed through your Nuxt server would all arrive from that server's IP, so every visitor would collapse into one identity per user agent. Pulse's [bot filtering](https://docs.ciphera.net/pulse/bot-filtering) also counts a datacenter or hosting-provider origin as one of its signals, which is where a proxied server sits. Because identity never reaches the browser, there is nothing for a first-party proxy to protect. +Pulse builds visitor identity on its server from the connecting IP address and user agent. Route the beacons through your Nuxt server and they all arrive from one IP, so every visitor on the same user agent collapses into a single identity. Its [bot filtering](https://docs.ciphera.net/pulse/bot-filtering) counts a datacenter or hosting-provider origin as a signal too, which is where a proxied server sits. The tracker keeps no identifier in the browser either, so a first-party proxy has nothing client-side to shield. -Bundling (`bundle: true`) **is** supported: the tracker is served from your origin, and the browser sends its beacons directly to the Pulse API. +Bundling (`bundle: true`) **is** supported: the tracker is served from your origin, and the browser sends its beacons straight to the Pulse API. ## Self-hosted or proxied API @@ -30,7 +30,7 @@ Bundling (`bundle: true`) **is** supported: the tracker is served from your orig ```ts useScriptPulseAnalytics({ - domain: 'example.com', + domain: 'YOUR_DOMAIN', apiUrl: 'https://pulse-api.example.com', }) ``` @@ -38,10 +38,10 @@ useScriptPulseAnalytics({ ## Defaults - **Trigger: `onNuxtReady`** The script loads when the Nuxt app is ready. -- Pageviews, SPA route changes, scroll depth, outbound links and file downloads are recorded automatically. Set `trackScroll`, `trackOutbound` or `trackDownloads` to `false` to switch one off. -- Do Not Track and Global Privacy Control are honoured by the tracker itself. +- The tracker records pageviews, SPA route changes, scroll depth, outbound links and file downloads on its own. Set `trackScroll`, `trackOutbound` or `trackDownloads` to `false` to turn one off. +- The tracker honours Do Not Track and Global Privacy Control itself. -Use the composable's `proxy` object for `track` calls. Calls made before the script has loaded are queued and sent once it has. When the visitor has opted out (Do Not Track, Global Privacy Control), the tracker does not run and the queue is discarded. +Use the composable's `proxy` object for `track` calls. Call it before the script has loaded and Nuxt Scripts holds the call, then replays it once the tracker is in. If the visitor has opted out, the tracker never runs and the queue is dropped. ::code-group @@ -73,7 +73,7 @@ The default trigger waits until Nuxt is ready: ```vue [app.vue] ``` From d9af1222c2c639aaa85a9ded9150d1e498ce6dec Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 16 Sep 2026 13:52:19 +1000 Subject: [PATCH 6/6] docs(pulse-analytics): drop prose the doc components already render --- docs/content/scripts/pulse-analytics.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/docs/content/scripts/pulse-analytics.md b/docs/content/scripts/pulse-analytics.md index 9a7e9f165..ccebd33e7 100644 --- a/docs/content/scripts/pulse-analytics.md +++ b/docs/content/scripts/pulse-analytics.md @@ -8,7 +8,7 @@ links: size: xs --- -[Pulse](https://pulse.ciphera.net/) is cookie-free web analytics by [Ciphera](https://ciphera.net/). The tracker leaves nothing in the browser, no cookie and no stored identifier; Pulse works out who a visitor is on its own servers, from rotating hashes. The [script reference](https://docs.ciphera.net/pulse/script-installation) lists every attribute this composable maps. +[Pulse](https://pulse.ciphera.net/) is cookie-free web analytics by [Ciphera](https://ciphera.net/). The tracker leaves nothing in the browser, no cookie and no stored identifier; Pulse works out who a visitor is on its own servers, from rotating hashes. It honours Do Not Track and Global Privacy Control without extra config. The [script reference](https://docs.ciphera.net/pulse/script-installation) lists every attribute this composable maps. ::script-stats :: @@ -18,15 +18,13 @@ links: ## Proxying is not supported -Pulse **cannot** be proxied (`proxy: true`). +Pulse builds visitor identity on its server from the connecting IP address and user agent. Route the beacons through your Nuxt server and they all arrive from one IP, so every visitor on the same user agent collapses into a single identity. Its [bot filtering](https://docs.ciphera.net/pulse/bot-filtering) counts a datacenter or hosting-provider origin as a signal too, which is where a proxied server sits. -Pulse builds visitor identity on its server from the connecting IP address and user agent. Route the beacons through your Nuxt server and they all arrive from one IP, so every visitor on the same user agent collapses into a single identity. Its [bot filtering](https://docs.ciphera.net/pulse/bot-filtering) counts a datacenter or hosting-provider origin as a signal too, which is where a proxied server sits. The tracker keeps no identifier in the browser either, so a first-party proxy has nothing client-side to shield. - -Bundling (`bundle: true`) **is** supported: the tracker is served from your origin, and the browser sends its beacons straight to the Pulse API. +So Nuxt Scripts bundles the tracker and serves it from your origin, but its beacons go straight to the Pulse API. Nothing is lost client-side: the tracker keeps no identifier in the browser for a first-party proxy to shield. ## Self-hosted or proxied API -`apiUrl` sets the origin the tracker posts to (the `data-api` attribute). Leave it unset for the hosted Pulse API. +`apiUrl` sets the origin the tracker posts to. Leave it unset for the hosted Pulse API. ```ts useScriptPulseAnalytics({ @@ -35,11 +33,7 @@ useScriptPulseAnalytics({ }) ``` -## Defaults - -- **Trigger: `onNuxtReady`** The script loads when the Nuxt app is ready. -- The tracker records pageviews, SPA route changes, scroll depth, outbound links and file downloads on its own. Set `trackScroll`, `trackOutbound` or `trackDownloads` to `false` to turn one off. -- The tracker honours Do Not Track and Global Privacy Control itself. +## Custom events Use the composable's `proxy` object for `track` calls. Call it before the script has loaded and Nuxt Scripts holds the call, then replays it once the tracker is in. If the visitor has opted out, the tracker never runs and the queue is dropped.