Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions FIRST_PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions docs/content/scripts/cloudflare-web-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

- **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`.

Check warning on line 23 in docs/content/scripts/cloudflare-web-analytics.md

View workflow job for this annotation

GitHub Actions / lint

Passive voice: "is bundled". Consider rewriting in active voice
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.

Check warning on line 25 in docs/content/scripts/cloudflare-web-analytics.md

View workflow job for this annotation

GitHub Actions / lint

"Cloudflare" should link to https://cloudflare.com

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"}
Expand Down
8 changes: 3 additions & 5 deletions packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -345,10 +345,8 @@ export async function registry(resolve?: (path: string) => Promise<string>): 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', {
Expand Down
57 changes: 57 additions & 0 deletions test/unit/cloudflare-analytics-transport.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, RenderedScriptMeta | Error>()
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}',
}])
})
})
10 changes: 1 addition & 9 deletions test/unit/proxy-configs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand Down
Loading