diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index 00eb4032..53e5a43a 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -138,7 +138,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, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | -Note: Cloudflare Web Analytics, TikTok, GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, Pulse, and Statable have no proxy capability, so no privacy transforms are applied. +Note: Cloudflare Web Analytics, TikTok, GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, Pulse, Statable, and Privatus Analytics 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 cd1f0e48..efe8badf 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -310,6 +310,7 @@ These integrations serve their main loader from your domain, but some runtime re | [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. | | [Statable](/scripts/statable-analytics) | Statable identifies visitors from the connecting IP and user agent, so proxied beacons would merge everyone into one visitor. Nuxt Scripts bundles the tracker but leaves beacons direct. | +| [Privatus Analytics](/scripts/privatus-analytics) | Privatus Analytics counts visitors from a daily salted hash of the connecting IP and user agent, 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/privatus-analytics.md b/docs/content/scripts/privatus-analytics.md new file mode 100644 index 00000000..ff2a27b3 --- /dev/null +++ b/docs/content/scripts/privatus-analytics.md @@ -0,0 +1,81 @@ +--- +title: Privatus Analytics +description: Load the Privatus Analytics 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/privatus-analytics.ts + size: xs +--- + +[Privatus Analytics](https://privatusanalytics.com/) is cookieless web analytics. The tracker sets no cookies and keeps no visitor ID in the browser. The only thing it stores is a visitor's own opt-out choice. The [docs](https://privatusanalytics.com/docs) list every tracker option. + +A free plan covers 10,000 events a month on up to 3 sites. + +::script-stats +:: + +::script-docs +:: + +## Proxying is not supported + +Privatus Analytics counts visitors from a daily salted hash of the connecting IP address and user agent. If your Nuxt server relays beacons, every visitor looks like your server. + +You can bundle the tracker on your Nuxt origin. Beacons still go to the Privatus Analytics API. The composable always sets `data-api` on the script tag, so a bundled copy keeps posting to the right place. + +## Modules + +`modules` turns on extra tracker modules, such as outbound links and downloads (`auto`), Core Web Vitals (`vitals`) and click maps (`clicks`). Without it, the tracker loads its default module, `engage`, which records time on page and scroll depth. Modules load at runtime from `api`, so bundling only covers the main script. + +```ts +useScriptPrivatusAnalytics({ + site: 'pa_YOURSITEID', + modules: ['engage', 'auto', 'vitals'], +}) +``` + +## Custom events + +Use `proxy.track(name, props)`{lang="ts"} to record an event. Calls made before the tracker loads run once it is ready. Properties can be strings, numbers, booleans or string arrays. `revenue` and `currency` record revenue. + +::code-group + +```ts [Proxy] +const { proxy } = useScriptPrivatusAnalytics() +function trackSignup() { + proxy.track('Signup', { plan: 'pro' }) +} +``` + +```ts [onLoaded] +const { onLoaded } = useScriptPrivatusAnalytics() +onLoaded(({ track }) => { + track('Purchase', { revenue: 49, currency: 'USD' }) +}) +``` + +:: + +You can also tag an element with `data-privatus-event="Signup"` and the tracker records clicks on it, or submits for a form. + +## Page views in Nuxt + +The tracker counts client-side navigation through `history.pushState`, so Vue Router page views need no setup. To send page views yourself, set `manual: true` and call `proxy.pageview()`{lang="ts"}. + +The tracker sends nothing on `localhost` or in automated browsers, so local development and browser tests do not record visits. + +::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 61f53b01..ab047deb 100644 --- a/packages/script/src/registry-logos.ts +++ b/packages/script/src/registry-logos.ts @@ -11,6 +11,7 @@ export const LOGOS = { posthog: ``, fathomAnalytics: ``, pulseAnalytics: ``, + privatusAnalytics: ``, statableAnalytics: { light: ``, dark: ``, diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index edfe16f4..f48e6626 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -1099,6 +1099,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}" } ], + "privatus-analytics": [ + { + "name": "PrivatusAnalyticsOptions", + "kind": "const", + "code": "export const PrivatusAnalyticsOptions = object({\n /**\n * The site ID from your Privatus Analytics dashboard, for example `pa_AbC123`.\n * @see https://privatusanalytics.com/docs\n */\n site: pipe(string(), regex(/^pa_[A-Z0-9]+$/i)),\n /**\n * Origin that serves the tracker and its modules and receives events.\n * Bundling still serves the main script from your Nuxt origin.\n * @default 'https://privatusstats.com'\n */\n api: optional(string()),\n /**\n * Extra tracker modules, rendered as `data-modules`. Leave unset to get the\n * default (`engage`: time on page and scroll depth).\n * Modules are loaded at runtime from `api`, not bundled.\n * @example ['engage', 'auto', 'vitals']\n */\n modules: optional(array(string())),\n /**\n * How client-side navigation is counted.\n * @default 'auto'\n */\n spa: optional(union([literal('auto'), literal('history'), literal('hash'), literal('off')])),\n /**\n * Skip the automatic page view. Send it yourself with `pageview()`.\n */\n manual: optional(boolean()),\n /**\n * Only track on these hostnames.\n */\n domains: optional(array(string())),\n /**\n * Path patterns that are never tracked, for example `['/admin/*']`.\n */\n exclude: optional(array(string())),\n /**\n * Keep the `#fragment` in page URLs (for hash-routed apps).\n */\n hash: optional(boolean()),\n})" + }, + { + "name": "PrivatusAnalyticsProps", + "kind": "type", + "code": "export type PrivatusAnalyticsProps = Record" + }, + { + "name": "PrivatusAnalyticsApi", + "kind": "interface", + "code": "export interface PrivatusAnalyticsApi {\n /**\n * Records a custom event.\n * @param name Event name, for example `Signup`.\n * @param props Optional properties. `revenue` and `currency` record revenue.\n * @param callback Called once the event is sent, or skipped.\n * @see https://privatusanalytics.com/docs\n */\n track: (name: string, props?: PrivatusAnalyticsProps, callback?: () => void) => void\n /** Sends a page view for the current URL. Useful with `manual: true`. */\n pageview: (options?: { props?: PrivatusAnalyticsProps }) => void\n /** Sets properties sent with every following hit on this page load. */\n props: (props: PrivatusAnalyticsProps) => void\n /** Stops tracking in this browser. The visitor's choice is kept by the tracker. */\n optOut: () => void\n /** Undoes `optOut()`. */\n optIn: () => void\n isOptedOut: () => boolean\n}" + } + ], "pulse-analytics": [ { "name": "PulseAnalyticsOptions", @@ -3131,6 +3148,58 @@ "description": "Custom properties attached to every event from the page load, rendered as `data-statable-*` attributes on the script tag." } ], + "PrivatusAnalyticsOptions": [ + { + "name": "site", + "type": "string", + "required": true, + "description": "The site ID from your Privatus Analytics dashboard, for example `pa_AbC123`." + }, + { + "name": "api", + "type": "string", + "required": false, + "description": "Origin that serves the tracker and its modules and receives events. Bundling still serves the main script from your Nuxt origin.", + "defaultValue": "'https://privatusstats.com'" + }, + { + "name": "modules", + "type": "string[]", + "required": false, + "description": "Extra tracker modules, rendered as `data-modules`. Leave unset to get the default (`engage`: time on page and scroll depth). Modules are loaded at runtime from `api`, not bundled." + }, + { + "name": "spa", + "type": "'auto' | 'history' | 'hash' | 'off'", + "required": false, + "description": "How client-side navigation is counted.", + "defaultValue": "'auto'" + }, + { + "name": "manual", + "type": "boolean", + "required": false, + "description": "Skip the automatic page view. Send it yourself with `pageview()`." + }, + { + "name": "domains", + "type": "string[]", + "required": false, + "description": "Only track on these hostnames." + }, + { + "name": "exclude", + "type": "string[]", + "required": false, + "description": "Path patterns that are never tracked, for example `['/admin/*']`." + }, + { + "name": "hash", + "type": "boolean", + "required": false, + "description": "Keep the `#fragment` in page URLs (for hash-routed apps)." + } + ], "ScriptGoogleMapsProps": [ { "name": "trigger", diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index ddff5a0f..af167a09 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -5,6 +5,7 @@ import type { IntercomInput } from './runtime/registry/intercom' import type { MixpanelAnalyticsInput } from './runtime/registry/mixpanel-analytics' import type { NpmInput } from './runtime/registry/npm' import type { PlausibleAnalyticsInput } from './runtime/registry/plausible-analytics' +import type { PrivatusAnalyticsInput } from './runtime/registry/privatus-analytics' import type { RybbitAnalyticsInput } from './runtime/registry/rybbit-analytics' import type { SegmentInput } from './runtime/registry/segment' import type { StatableAnalyticsInput } from './runtime/registry/statable-analytics' @@ -41,6 +42,7 @@ import { MixpanelAnalyticsOptions, NpmOptions, PostHogOptions, + PrivatusAnalyticsOptions, PulseAnalyticsOptions, RedditPixelOptions, RybbitAnalyticsOptions, @@ -151,6 +153,10 @@ export const registryMeta: RegistryScriptMeta[] = [ // visitor identity, so proxied beacons would collapse every visitor into // one. Same family as Fathom (#720) and Pulse. m('statableAnalytics', 'Statable Analytics', 'analytics', 'useScriptStatableAnalytics', { bundle: true }, null), + // No proxy: Privatus Analytics counts visitors from a daily salted hash of + // the connecting IP and user agent, so proxied beacons would collapse every + // visitor into one. Same family as Fathom (#720), Pulse and Statable. + m('privatusAnalytics', 'Privatus Analytics', 'analytics', 'useScriptPrivatusAnalytics', { 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), @@ -499,6 +505,23 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }, }), + def('privatusAnalytics', { + schema: PrivatusAnalyticsOptions, + label: 'Privatus Analytics', + category: 'analytics', + envDefaults: { site: '' }, + // Bundling needs no SDK patch: the tracker reads data-* from its own + // script element, and the composable pins `data-api` so the bundled copy + // keeps posting to the Privatus Analytics API. + bundle: { + resolve: (options?: PrivatusAnalyticsInput) => { + if (!options?.site || !/^pa_[A-Z0-9]+$/i.test(options.site)) + return false + const api = (options.api || 'https://privatusstats.com').replace(/\/+$/, '') + return `${api}/js/pa.js` + }, + }, + }), // ad def('bingUet', { schema: BingUetOptions, diff --git a/packages/script/src/runtime/registry/privatus-analytics.ts b/packages/script/src/runtime/registry/privatus-analytics.ts new file mode 100644 index 00000000..b2fb2b57 --- /dev/null +++ b/packages/script/src/runtime/registry/privatus-analytics.ts @@ -0,0 +1,71 @@ +import type { RegistryScriptInput } from '#nuxt-scripts/types' +import { useRegistryScript } from '../utils' +import { PrivatusAnalyticsOptions } from './schemas' + +export { PrivatusAnalyticsOptions } + +export type PrivatusAnalyticsInput = RegistryScriptInput + +export type PrivatusAnalyticsProps = Record + +export interface PrivatusAnalyticsApi { + /** + * Records a custom event. + * @param name Event name, for example `Signup`. + * @param props Optional properties. `revenue` and `currency` record revenue. + * @param callback Called once the event is sent, or skipped. + * @see https://privatusanalytics.com/docs + */ + track: (name: string, props?: PrivatusAnalyticsProps, callback?: () => void) => void + /** Sends a page view for the current URL. Useful with `manual: true`. */ + pageview: (options?: { props?: PrivatusAnalyticsProps }) => void + /** Sets properties sent with every following hit on this page load. */ + props: (props: PrivatusAnalyticsProps) => void + /** Stops tracking in this browser. The visitor's choice is kept by the tracker. */ + optOut: () => void + /** Undoes `optOut()`. */ + optIn: () => void + isOptedOut: () => boolean +} + +declare global { + interface Window { + privatus: PrivatusAnalyticsApi + } +} + +const DEFAULT_API = 'https://privatusstats.com' + +function list(values?: string[]) { + const joined = (values || []).map(value => value.trim()).filter(Boolean).join(',') + return joined || undefined +} + +export function useScriptPrivatusAnalytics(_options?: PrivatusAnalyticsInput) { + return useRegistryScript('privatusAnalytics', (options) => { + const api = (options.api || DEFAULT_API).replace(/\/+$/, '') + return { + scriptInput: { + 'src': `${api}/js/pa.js`, + 'data-site': options.site, + // The tracker posts to `data-api` and loads its modules from there, + // falling back to the origin it was served from. Bundling serves the + // file from the Nuxt origin, so the API origin is always pinned. + 'data-api': api, + // An empty `data-modules` switches every module off, so only render it when set. + 'data-modules': options.modules ? options.modules.join(',') : undefined, + 'data-spa': options.spa, + 'data-manual': options.manual ? 'true' : undefined, + 'data-domains': list(options.domains), + 'data-exclude': list(options.exclude), + 'data-hash': options.hash ? 'true' : undefined, + }, + schema: import.meta.dev ? PrivatusAnalyticsOptions : undefined, + scriptOptions: { + use() { + return window.privatus + }, + }, + } + }, _options) +} diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts index ff839da5..1fa65a1a 100644 --- a/packages/script/src/runtime/registry/schemas.ts +++ b/packages/script/src/runtime/registry/schemas.ts @@ -1407,3 +1407,45 @@ export const StatableAnalyticsOptions = object({ */ props: optional(record(string(), string())), }) + +export const PrivatusAnalyticsOptions = object({ + /** + * The site ID from your Privatus Analytics dashboard, for example `pa_AbC123`. + * @see https://privatusanalytics.com/docs + */ + site: pipe(string(), regex(/^pa_[A-Z0-9]+$/i)), + /** + * Origin that serves the tracker and its modules and receives events. + * Bundling still serves the main script from your Nuxt origin. + * @default 'https://privatusstats.com' + */ + api: optional(string()), + /** + * Extra tracker modules, rendered as `data-modules`. Leave unset to get the + * default (`engage`: time on page and scroll depth). + * Modules are loaded at runtime from `api`, not bundled. + * @example ['engage', 'auto', 'vitals'] + */ + modules: optional(array(string())), + /** + * How client-side navigation is counted. + * @default 'auto' + */ + spa: optional(union([literal('auto'), literal('history'), literal('hash'), literal('off')])), + /** + * Skip the automatic page view. Send it yourself with `pageview()`. + */ + manual: optional(boolean()), + /** + * Only track on these hostnames. + */ + domains: optional(array(string())), + /** + * Path patterns that are never tracked, for example `['/admin/*']`. + */ + exclude: optional(array(string())), + /** + * Keep the `#fragment` in page URLs (for hash-routed apps). + */ + hash: optional(boolean()), +}) diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 24793ba5..d089787c 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 { PrivatusAnalyticsInput } from './registry/privatus-analytics' import type { PulseAnalyticsInput } from './registry/pulse-analytics' import type { RedditPixelInput } from './registry/reddit-pixel' import type { RybbitAnalyticsInput } from './registry/rybbit-analytics' @@ -304,6 +305,7 @@ export interface ScriptRegistry { paypal?: PayPalInput posthog?: PostHogInput pulseAnalytics?: PulseAnalyticsInput + privatusAnalytics?: PrivatusAnalyticsInput matomoAnalytics?: MatomoAnalyticsInput mixpanelAnalytics?: MixpanelAnalyticsInput rybbitAnalytics?: RybbitAnalyticsInput @@ -336,7 +338,7 @@ export type BuiltInRegistryScriptKey | 'databuddyAnalytics' | 'deskcrew' | 'metaPixel' | 'fathomAnalytics' | 'instagramEmbed' | 'plausibleAnalytics' | 'googleAdsense' | 'googleAnalytics' | 'googleMaps' | 'leaflet' | 'maplibre' | 'googleRecaptcha' | 'googleSignIn' | 'lemonSqueezy' | 'googleTagManager' - | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'pulseAnalytics' | 'matomoAnalytics' + | 'hotjar' | 'intercom' | 'linkedinInsight' | 'paypal' | 'posthog' | 'privatusAnalytics' | 'pulseAnalytics' | 'matomoAnalytics' | 'mixpanelAnalytics' | 'rybbitAnalytics' | 'redditPixel' | 'segment' | 'statableAnalytics' | 'stripe' | 'tawkTo' | '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 f20c2fed..24e56888 100644 --- a/packages/script/src/script-meta.ts +++ b/packages/script/src/script-meta.ts @@ -52,6 +52,10 @@ export const scriptMeta = { urls: ['https://js.ciphera.net/script.js'], trackedData: ['page-views', 'events', 'conversions', 'scrolls'], }, + privatusAnalytics: { + urls: ['https://privatusstats.com/js/pa.js'], + trackedData: ['page-views', 'events', 'conversions', 'scrolls'], + }, statableAnalytics: { urls: ['https://statable.com/js/3078194/s.js'], trackedData: ['page-views', 'events', 'conversions', 'scrolls'], diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 919a5593..3ce5129f 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -38,6 +38,7 @@ export default defineNuxtConfig({ fathomAnalytics: { site: 'BRDEJWKJ' }, pulseAnalytics: { domain: 'example.com' }, statableAnalytics: { siteId: '123456' }, + privatusAnalytics: { site: 'pa_test123' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, @@ -91,6 +92,7 @@ export default defineNuxtConfig({ fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, pulseAnalytics: { domain: 'example.com', trigger: 'manual' }, statableAnalytics: { siteId: '123456', trigger: 'manual' }, + privatusAnalytics: { site: 'pa_test123', 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 30ce9ec0..5f2fbed3 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -26,6 +26,7 @@ function getPlaygroundPath(script: any): string | null { 'rybbit-analytics': '/third-parties/rybbit-analytics', 'pulse-analytics': '/third-parties/pulse-analytics', 'statable-analytics': '/third-parties/statable-analytics', + 'privatus-analytics': '/third-parties/privatus-analytics', 'databuddy-analytics': '/third-parties/databuddy-analytics', 'umami-analytics': '/third-parties/umami-analytics', 'segment': '/third-parties/segment', diff --git a/playground/pages/third-parties/privatus-analytics.vue b/playground/pages/third-parties/privatus-analytics.vue new file mode 100644 index 00000000..08ceb93d --- /dev/null +++ b/playground/pages/third-parties/privatus-analytics.vue @@ -0,0 +1,103 @@ + + + diff --git a/test/e2e-dev/first-party.test.ts b/test/e2e-dev/first-party.test.ts index cdfe601c..28a77acc 100644 --- a/test/e2e-dev/first-party.test.ts +++ b/test/e2e-dev/first-party.test.ts @@ -666,6 +666,7 @@ describe('first-party privacy stripping', () => { // 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) // statableAnalytics — bundle only, no proxy (server-side visitor identity needs the real client IP) + // privatusAnalytics — bundle only, no proxy (server-side visitor identity needs the real client IP) ]) /** @@ -940,6 +941,7 @@ describe('first-party privacy stripping', () => { // 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 // statableAnalytics — bundle only, no proxy in registry; beacons go direct to statable.com/api/event + // privatusAnalytics — bundle only, no proxy in registry; beacons go direct to privatusstats.com/api/event it('intercom', async () => { const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('intercom', '/intercom-test') @@ -1005,6 +1007,7 @@ describe('first-party privacy stripping', () => { { name: 'fathomAnalytics', path: '/fathom' }, { name: 'pulseAnalytics', path: '/pulse' }, { name: 'statableAnalytics', path: '/statable' }, + { name: 'privatusAnalytics', path: '/privatus' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, { name: 'posthog', path: '/posthog' }, @@ -1192,6 +1195,7 @@ describe('first-party privacy stripping', () => { { name: 'fathomAnalytics', path: '/fathom' }, { name: 'pulseAnalytics', path: '/pulse' }, { name: 'statableAnalytics', path: '/statable' }, + { name: 'privatusAnalytics', path: '/privatus' }, { name: 'intercom', path: '/intercom-test' }, { name: 'crisp', path: '/crisp-test' }, { name: 'posthog', path: '/posthog' }, @@ -1244,6 +1248,12 @@ describe('first-party privacy stripping', () => { expect(await script.getAttribute('src')).toContain('/_scripts/assets/') } + if (name === 'privatusAnalytics') { + const script = page.locator('script[data-site="pa_test123"][data-api="https://privatusstats.com"]') + expect(await script.count()).toBe(1) + expect(await script.getAttribute('src')).toContain('/_scripts/assets/') + } + // Click all buttons to trigger SDK interactions and generate proxy requests const buttons = await page.$$('button') for (const btn of buttons) { diff --git a/test/e2e/privatus-analytics.test.ts b/test/e2e/privatus-analytics.test.ts new file mode 100644 index 00000000..eab9b910 --- /dev/null +++ b/test/e2e/privatus-analytics.test.ts @@ -0,0 +1,32 @@ +import { createResolver } from '@nuxt/kit' +import { getBrowser, url } from '@nuxt/test-utils/e2e' +import { describe, expect, it } from 'vitest' +import { setupFixture } from '../utils/setup-fixture' + +const { resolve } = createResolver(import.meta.url) + +describe('privatus analytics bundled script', async () => { + await setupFixture({ + rootDir: resolve('../fixtures/privatus-analytics'), + browser: true, + build: true, + }) + + it('keeps the site ID and API origin on the bundled script', async () => { + const page = await (await getBrowser()).newPage() + try { + await page.goto(url('/'), { waitUntil: 'domcontentloaded' }) + await page.waitForSelector('#status:has-text("loaded")', { timeout: 10000 }) + + const script = page.locator('script[data-site="pa_test123"][data-api="https://privatusstats.com"]') + expect(await script.count()).toBe(1) + expect(await script.getAttribute('src')).toContain('/_scripts/assets/') + + // The bundled tracker ran from the Nuxt origin and installed its API. + expect(await page.evaluate(() => typeof (window as any).privatus?.track)).toBe('function') + } + finally { + await page.close() + } + }) +}) diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 1a31691d..4b5db1a3 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -26,6 +26,7 @@ export default defineNuxtConfig({ fathomAnalytics: { site: 'BRDEJWKJ' }, pulseAnalytics: { domain: 'example.com' }, statableAnalytics: { siteId: '123456' }, + privatusAnalytics: { site: 'pa_test123' }, posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, intercom: { app_id: 'akg5rmxb' }, crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, @@ -73,6 +74,7 @@ export default defineNuxtConfig({ fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, pulseAnalytics: { domain: 'example.com', trigger: 'manual' }, statableAnalytics: { siteId: '123456', trigger: 'manual' }, + privatusAnalytics: { site: 'pa_test123', 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 556e5e5c..326ad422 100644 --- a/test/fixtures/first-party/pages/index.vue +++ b/test/fixtures/first-party/pages/index.vue @@ -10,6 +10,7 @@
  • Fathom Analytics
  • Pulse Analytics
  • Statable Analytics
  • +
  • Privatus Analytics
  • Umami Analytics
  • PostHog
  • Rybbit Analytics
  • diff --git a/test/fixtures/first-party/pages/privatus.vue b/test/fixtures/first-party/pages/privatus.vue new file mode 100644 index 00000000..a8d79a83 --- /dev/null +++ b/test/fixtures/first-party/pages/privatus.vue @@ -0,0 +1,32 @@ + + + diff --git a/test/fixtures/privatus-analytics/nuxt.config.ts b/test/fixtures/privatus-analytics/nuxt.config.ts new file mode 100644 index 00000000..dcfb4dfe --- /dev/null +++ b/test/fixtures/privatus-analytics/nuxt.config.ts @@ -0,0 +1,11 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + modules: ['@nuxt/scripts'], + scripts: { + registry: { + privatusAnalytics: { site: 'pa_test123', trigger: 'manual' }, + }, + }, + compatibilityDate: '2024-07-05', +}) diff --git a/test/fixtures/privatus-analytics/pages/index.vue b/test/fixtures/privatus-analytics/pages/index.vue new file mode 100644 index 00000000..9cc3cf79 --- /dev/null +++ b/test/fixtures/privatus-analytics/pages/index.vue @@ -0,0 +1,11 @@ + + + diff --git a/test/types/types.test-d.ts b/test/types/types.test-d.ts index 4dfb4539..bf99fbc6 100644 --- a/test/types/types.test-d.ts +++ b/test/types/types.test-d.ts @@ -44,6 +44,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/privatus-analytics.test.ts b/test/unit/privatus-analytics.test.ts new file mode 100644 index 00000000..b0780d19 --- /dev/null +++ b/test/unit/privatus-analytics.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment happy-dom + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' +import { getBundleResolve, registry } from '../../packages/script/src/registry' +import { useScriptPrivatusAnalytics } from '../../packages/script/src/runtime/registry/privatus-analytics' +import { PrivatusAnalyticsOptions } from '../../packages/script/src/runtime/registry/schemas' + +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, +})) + +function setup(options: Record = { site: 'pa_AbC123' }) { + mocks.useRegistryScript.mockImplementation((_key: string, factory: (o: any) => any) => { + mocks.definition = factory(options) + return { status: ref('awaitingLoad'), signal: new AbortController().signal, load: vi.fn() } + }) + useScriptPrivatusAnalytics(options as any) + return { + scriptInput: mocks.definition.scriptInput as Record, + use: mocks.definition.scriptOptions.use as () => { track: (...args: any[]) => void }, + } +} + +describe('privatus analytics', () => { + beforeEach(() => { + vi.clearAllMocks() + delete (window as any).privatus + }) + + it('loads the hosted tracker with the site id', () => { + const { scriptInput } = setup() + expect(scriptInput.src).toBe('https://privatusstats.com/js/pa.js') + expect(scriptInput['data-site']).toBe('pa_AbC123') + }) + + it('pins the API origin so bundling does not change it', () => { + // Without data-api the tracker posts to the origin it was served from, + // which is the Nuxt origin once the script is bundled. + const { scriptInput } = setup() + expect(scriptInput['data-api']).toBe('https://privatusstats.com') + }) + + it('follows a custom api origin for the script and the endpoint', () => { + const { scriptInput } = setup({ site: 'pa_AbC123', api: 'https://stats.example.com/' }) + expect(scriptInput.src).toBe('https://stats.example.com/js/pa.js') + expect(scriptInput['data-api']).toBe('https://stats.example.com') + }) + + it('renders options as data attributes', () => { + const { scriptInput } = setup({ + site: 'pa_AbC123', + modules: ['engage', 'auto', 'vitals'], + spa: 'hash', + manual: true, + domains: ['example.com', ' www.example.com '], + exclude: ['/admin/*'], + hash: true, + }) + expect(scriptInput['data-modules']).toBe('engage,auto,vitals') + expect(scriptInput['data-spa']).toBe('hash') + expect(scriptInput['data-manual']).toBe('true') + expect(scriptInput['data-domains']).toBe('example.com,www.example.com') + expect(scriptInput['data-exclude']).toBe('/admin/*') + expect(scriptInput['data-hash']).toBe('true') + }) + + it('leaves optional attributes off by default', () => { + // An empty data-modules would switch the default engage module off. + const { scriptInput } = setup() + for (const key of ['data-modules', 'data-spa', 'data-manual', 'data-domains', 'data-exclude', 'data-hash']) + expect(scriptInput[key]).toBeUndefined() + }) + + it('exposes the tracker global once loaded', () => { + const track = vi.fn() + ;(window as any).privatus = { track } + const { use } = setup() + use().track('Signup', { plan: 'pro' }) + expect(track).toHaveBeenCalledWith('Signup', { plan: 'pro' }) + }) +}) + +describe('privatusAnalytics bundle.resolve', () => { + async function getResolve() { + const all = await registry() + const script = all.find(s => s.registryKey === 'privatusAnalytics')! + const resolve = getBundleResolve(script) + if (!resolve) + throw new Error('privatusAnalytics bundle.resolve missing') + return resolve + } + + it('returns false for a missing site instead of throwing', async () => { + const resolve = await getResolve() + expect(resolve(undefined)).toBe(false) + expect(resolve({})).toBe(false) + }) + + it('builds the src from the api origin', async () => { + const resolve = await getResolve() + expect(resolve({ site: 'pa_AbC123' })).toBe('https://privatusstats.com/js/pa.js') + expect(resolve({ site: 'pa_AbC123', api: 'https://stats.example.com/' })).toBe('https://stats.example.com/js/pa.js') + }) + + it.each(['', ' ', 'AbC123', 'pa_', 'pa_AbC 123', 'pa_AbC"123'])('skips bundling for an invalid site: %s', async (site) => { + const resolve = await getResolve() + expect(resolve({ site })).toBe(false) + }) +}) + +describe('privatusAnalytics dev validation', () => { + function validate(options: Record) { + return (PrivatusAnalyticsOptions as any)['~standard'].validate(options) as { issues?: unknown[] } + } + + it('accepts a site id', () => { + expect(validate({ site: 'pa_AbC123' }).issues).toBeUndefined() + }) + + it.each(['', 'AbC123', 'pa_', 'pa_AbC-123', ' pa_AbC123 ', 'pa_AbC123">