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..6f088c14c 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](/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 new file mode 100644 index 000000000..ccebd33e7 --- /dev/null +++ b/docs/content/scripts/pulse-analytics.md @@ -0,0 +1,73 @@ +--- +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 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 +:: + +::script-docs +:: + +## Proxying is not supported + +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. + +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. Leave it unset for the hosted Pulse API. + +```ts +useScriptPulseAnalytics({ + domain: 'YOUR_DOMAIN', + apiUrl: 'https://pulse-api.example.com', +}) +``` + +## 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. + +::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..e6abb5da4 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 /** The tracker ran but declined to record: nothing will ever consume the queue. */\n declined: 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..95a8d539b 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,9 @@ 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), + // 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), m('metaPixel', 'Meta Pixel', 'ad', 'useScriptMetaPixel', { bundle: true, proxy: true, partytown: true }, PRIVACY_FULL), @@ -462,6 +466,16 @@ 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: '' }, + // 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 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..52ab68f35 --- /dev/null +++ b/packages/script/src/runtime/registry/pulse-analytics.ts @@ -0,0 +1,130 @@ +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 + /** + * Set by the tracker before its opt-out checks run. Present without + * `pulse.track` means the tracker ran and declined to record. + */ + __pulseInstalled?: boolean + } +} + +// 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 + +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 (typeof window === 'undefined') + return + const g = globalThis as any + if (!g[PULSE_QUEUE_KEY]) { + g[PULSE_QUEUE_KEY] = { queue: [], flushed: false, declined: false } + } + return g[PULSE_QUEUE_KEY] +} + +function isPulseReady() { + return typeof window !== 'undefined' && typeof window.pulse?.track === 'function' +} + +function hasPulseDeclined() { + return typeof window !== 'undefined' && window.__pulseInstalled === true && !isPulseReady() +} + +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 || state.declined) + return + if (hasPulseDeclined()) { + state.declined = true + state.queue.length = 0 + return + } + if (!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) + 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 => ({ + scriptInput: { + 'src': 'https://js.ciphera.net/script.js', + 'data-domain': options.domain, + 'data-api': options.apiUrl || undefined, + // 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, + }, + 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/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) { 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') 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') + }) + }) +})