diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index 760e78aa..00eb4032 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -136,9 +136,9 @@ Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | `PRIVACY_NONE` | all false | (not currently assigned to any script) | | `PRIVACY_FULL` | all true | Meta, X, Snap, Reddit, LinkedIn | | `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 | +| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly | -Note: 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, and Statable have no proxy capability, so no privacy transforms are applied. ## Script Support @@ -161,7 +161,7 @@ Note: TikTok, GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, Pulse, and St | `rybbitAnalytics` | rybbitAnalytics | `PRIVACY_IP_ONLY` | Path A + autoInject + postProcess | | `databuddyAnalytics` | databuddyAnalytics | `PRIVACY_IP_ONLY` | Path A + autoInject | | `fathomAnalytics` | fathomAnalytics | `PRIVACY_IP_ONLY` | Path A + postProcess | -| `cloudflareWebAnalytics` | cloudflareWebAnalytics | `PRIVACY_IP_ONLY` | Path A | +| `cloudflareWebAnalytics` | cloudflareWebAnalytics | n/a | Bundled script, direct collection | | `vercelAnalytics` | vercelAnalytics | `PRIVACY_IP_ONLY` | Path A | | `matomoAnalytics` | matomoAnalytics | `PRIVACY_IP_ONLY` | Path A | | `carbonAds` | carbonAds | `PRIVACY_IP_ONLY` | Path A | diff --git a/docs/content/scripts/cloudflare-web-analytics.md b/docs/content/scripts/cloudflare-web-analytics.md index 3c2b1eb3..a422bd8d 100644 --- a/docs/content/scripts/cloudflare-web-analytics.md +++ b/docs/content/scripts/cloudflare-web-analytics.md @@ -20,6 +20,10 @@ Default: - **Trigger: Client** The script loads during Nuxt hydration to keep Web Vitals metrics accurate. +The beacon script is bundled. Events go directly from the browser to `https://cloudflareinsights.com/cdn-cgi/rum`. +Proxying is unsupported because [Cloudflare RUM cannot collect Worker subrequests](https://developers.cloudflare.com/web-analytics/faq/#can-i-use-real-user-monitoring-rum-with-cloudflare-workers). +An HTTP 204 response from a Worker proxy does not prove that Cloudflare recorded the event. + This registry fixes the trigger to `client`. A `scriptOptions.trigger` value passed by the caller is overwritten, so this integration cannot currently be deferred with a consent or element trigger. ::callout{type="warning"} diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index e131c385..ddff5a0f 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -126,7 +126,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // analytics m('googleAnalytics', 'Google Analytics', 'analytics', 'useScriptGoogleAnalytics', { proxy: true, partytown: true }, PRIVACY_HEATMAP), m('plausibleAnalytics', 'Plausible Analytics', 'analytics', 'useScriptPlausibleAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), - m('cloudflareWebAnalytics', 'Cloudflare Web Analytics', 'analytics', 'useScriptCloudflareWebAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), + m('cloudflareWebAnalytics', 'Cloudflare Web Analytics', 'analytics', 'useScriptCloudflareWebAnalytics', { bundle: true, partytown: true }), m('posthog', 'PostHog', 'analytics', 'useScriptPostHog', { proxy: true }, PRIVACY_IP_ONLY), // proxy intentionally off: proxied beacons reach Fathom from the server's IP // (datacenter) and Fathom's bot detection ignores X-Forwarded-For, flagging @@ -345,10 +345,8 @@ export async function registry(resolve?: (path: string) => Promise): Pro category: 'analytics', envDefaults: { token: '' }, bundle: true, - proxy: { - domains: ['static.cloudflareinsights.com', 'cloudflareinsights.com'], - privacy: PRIVACY_IP_ONLY, - }, + // Cloudflare RUM cannot collect Worker subrequests. Keep collection in the browser. + // https://developers.cloudflare.com/web-analytics/faq/#can-i-use-real-user-monitoring-rum-with-cloudflare-workers partytown: { forwards: ['__cfBeacon'] }, }), def('vercelAnalytics', { diff --git a/test/unit/cloudflare-analytics-transport.test.ts b/test/unit/cloudflare-analytics-transport.test.ts new file mode 100644 index 00000000..7680ff57 --- /dev/null +++ b/test/unit/cloudflare-analytics-transport.test.ts @@ -0,0 +1,57 @@ +import type { RenderedScriptMeta } from '../../packages/script/src/plugins/transform' +import { runInNewContext } from 'node:vm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' +import { buildProxyConfigsFromRegistry, registry } from '../../packages/script/src/registry' +import { runTransform } from '../utils/unplugin' + +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: () => ({ + hasItem: async () => false, + setItem: async () => {}, + setItemRaw: async () => {}, + }), +})) + +afterEach(() => vi.unstubAllGlobals()) + +describe('cloudflare web analytics transport', () => { + it('keeps bundled beacon events on the Cloudflare client endpoint', async () => { + const beacon = `navigator.sendBeacon('https://cloudflareinsights.com/cdn-cgi/rum', '{"eventType":1}')` + vi.stubGlobal('fetch', vi.fn(async () => new Response(beacon))) + const scripts = await registry() + const renderedScript = new Map() + const plugin = NuxtScriptBundleTransformer({ + scripts, + renderedScript, + proxyConfigs: buildProxyConfigsFromRegistry(scripts), + proxyPrefix: '/_scripts/p', + nuxt: { + options: { buildDir: '.nuxt', app: { baseURL: '/' }, runtimeConfig: { app: {} } }, + hooks: { hook: vi.fn() }, + } as any, + }).vite() + + await runTransform(plugin, { + id: 'analytics.ts', + code: `useScriptCloudflareWebAnalytics({ token: 'test-token' })`, + }) + + const sent: { url: string, body: string }[] = [] + const sendBeacon = (url: string, body: string) => sent.push({ url, body }) + for (const asset of renderedScript.values()) { + if (asset instanceof Error) + throw asset + runInNewContext(asset.content.toString(), { + navigator: { sendBeacon }, + __nuxtScripts: { sendBeacon }, + self: { location: { origin: 'https://example.com' } }, + }) + } + + expect(sent).toEqual([{ + url: 'https://cloudflareinsights.com/cdn-cgi/rum', + body: '{"eventType":1}', + }]) + }) +}) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index d8d2e09a..8c9306e9 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -347,13 +347,6 @@ describe('proxy configs', () => { expect(configs.databuddyAnalytics?.configDomainFields).toEqual(['scriptUrl']) }) - it('returns proxy config for cloudflareWebAnalytics', async () => { - const config = (await getProxyConfigs()).cloudflareWebAnalytics - expect(config).toBeDefined() - expect(config?.domains).toContain('static.cloudflareinsights.com') - expect(config?.domains).toContain('cloudflareinsights.com') - }) - it('returns proxy config for rybbitAnalytics', async () => { const config = (await getProxyConfigs()).rybbitAnalytics expect(config).toBeDefined() @@ -465,7 +458,6 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('linkedinInsight') expect(configs).toHaveProperty('posthog') expect(configs).toHaveProperty('plausibleAnalytics') - expect(configs).toHaveProperty('cloudflareWebAnalytics') expect(configs).toHaveProperty('rybbitAnalytics') expect(configs).toHaveProperty('umamiAnalytics') expect(configs).toHaveProperty('databuddyAnalytics') @@ -485,7 +477,7 @@ describe('proxy configs', () => { it('all configs have valid structure', async () => { const configs = await getProxyConfigs() const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel', 'linkedinInsight'] - const ipOnly = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'ahrefsAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar', 'calendly'] + const ipOnly = ['posthog', 'plausibleAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'ahrefsAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar', 'calendly'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have domains`).toHaveProperty('domains') expect(Array.isArray(config.domains), `${key}.domains should be an array`).toBe(true)