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
2 changes: 1 addition & 1 deletion FIRST_PARTY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/1.guides/2.first-party.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
73 changes: 73 additions & 0 deletions docs/content/scripts/pulse-analytics.md
Original file line number Diff line number Diff line change
@@ -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]
<script setup lang="ts">
useScriptPulseAnalytics({
domain: 'YOUR_DOMAIN',
})
</script>
```
1 change: 1 addition & 0 deletions packages/script/src/registry-logos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const LOGOS = {
vercelAnalytics: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path d="M256 48L496 464H16z" fill="currentColor"/></svg>`,
posthog: `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 128 128"><path fill="#1d4aff" d="M0 .52v32.15l31.79 31.78V32.3L0 .52zm32.3 32.15v32.15l31.78 31.78V64.45L32.3 32.67zM0 64.97v32.15l31.79 31.78V96.75L0 64.97zm64.6-32.3v32.15l31.78 31.78V64.45L64.6 32.67zm31.78 31.78v32.15l31.78 31.78V96.23l-31.78-31.78zm-64.08.52v32.15l31.78 31.78V96.75L32.3 64.97zM64.6 .52v32.15l31.78 31.78V32.3L64.6 .52zm0 64.45v32.15l31.78 31.78V96.75L64.6 64.97z"/></svg>`,
fathomAnalytics: `<svg width="32" height="32" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><circle cx="512" cy="512" r="512" style="fill:#9187ff"/><path d="M558.62 256c-36.31.16-78.78 10-129.45 28.4-170.71 62.15-206.9 139.74-144.79 310.45s139.73 206.89 310.45 144.76S801.74 599.88 739.6 429.16c-43.69-120-95-173.55-181-173.17zm59.1 140.16h26.73a5.33 5.33 0 0 1 5.16 6.72l-59.26 220.48a5.34 5.34 0 0 1-5.15 4h-26.75a5.33 5.33 0 0 1-5.16-6.72l3.6-13.4 2.63-9.75 53-197.38a5.33 5.33 0 0 1 5.14-3.94zM421.79 413.4h10.75a5.33 5.33 0 0 1 5.33 5.33v18a5.33 5.33 0 0 1-5.33 5.33h-9.13a36.76 36.76 0 0 0-5.51.24 4.7 4.7 0 0 0-2.56 1 4.19 4.19 0 0 0-1 1.66 18.91 18.91 0 0 0-.92 6.72v13.67h19.16a5.33 5.33 0 0 1 5.33 5.33v18a5.34 5.34 0 0 1-5.33 5.33h-19.21v108.71a5.34 5.34 0 0 1-5.34 5.34H387a5.33 5.33 0 0 1-5.33-5.34V448.48a36.74 36.74 0 0 1 3.6-16.64 29.76 29.76 0 0 1 9.73-11.16c7.9-5.48 17.62-7.27 26.82-7.31zm82.14 50c16.37 0 30.27 4.65 40.17 13.27s15.47 21.21 15.42 35.59v35.91l-16.11 59.92h-10.24a5.33 5.33 0 0 1-5.33-5.34v-4a39.13 39.13 0 0 1-4.76 3.56c-7.14 4.55-16.85 7.51-29.65 7.51a62.65 62.65 0 0 1-28.52-6.18 40.49 40.49 0 0 1-18.84-19.35 46.81 46.81 0 0 1-4-19.54 40.72 40.72 0 0 1 5.23-21.12 36.78 36.78 0 0 1 13.78-13.18c11.09-6.25 24.75-8.45 38.14-10.24 7.3-1 13.14-1.61 17.64-2.2a42 42 0 0 0 9.2-1.88 3.16 3.16 0 0 0 1.39-.86l.24-.48a6.77 6.77 0 0 0 .16-1.84v-.73a17.24 17.24 0 0 0-5.85-13.6c-3.8-3.31-9.77-5.55-18.07-5.57s-14.64 2.26-19 5.59a17.51 17.51 0 0 0-7.21 12.54 5.33 5.33 0 0 1-5.31 4.86h-22.25a5.33 5.33 0 0 1-5.33-5.57 45.64 45.64 0 0 1 17.6-34c10.47-8.34 24.85-13.12 41.49-13.12zm23.92 80.71c-1.92.48-4 1-6.31 1.45-6.47 1.28-14.29 2.41-21.87 3.48a61 61 0 0 0-14.76 3.65c-4.18 1.75-7.1 4-8.68 6.57a12.12 12.12 0 0 0-1.71 6.54v.2a12.93 12.93 0 0 0 1.32 5.87 11.81 11.81 0 0 0 3.76 4.22c3.41 2.45 9.13 4.14 16.85 4.14 11.95 0 19.52-3.5 24.32-8.32s7-11.56 7.08-19.11v-8.65zm0 0" style="fill:#fff"/></svg>`,
pulseAnalytics: `https://cdn.ciphera.net/pulse/pulse_icon_64.png`,
matomoAnalytics: `<svg xmlns="http://www.w3.org/2000/svg" width="56.5" height="32" viewBox="0 0 256 145"><defs><path id="logosMatomoIcon0" d="m105.426 70.887l.035-.021l-.663-1.01c-.1-.153-.2-.313-.303-.46L58.935 0L0 43.91l43.078 66.305c.185.281.36.566.55.847l.229.35l.025-.016c6.676 9.471 17.678 15.673 30.144 15.673c20.373 0 36.889-16.513 36.889-36.89c0-7.083-2.029-13.675-5.489-19.292"/><path id="logosMatomoIcon1" fill="#000" d="M64.549 19.33c0-20.374-16.517-36.89-36.89-36.89S-9.23-1.044-9.23 19.33a36.686 36.686 0 0 0 6.08 20.263c-.003 0-.003 0-.003-.003l-.019.003L-31.179 0h-.04c-6.499-10.524-18.101-17.56-31.376-17.56c-13.275 0-24.877 7.036-31.376 17.56h-.037l-44.61 69.525c6.633-9.8 17.848-16.235 30.57-16.235c13.39 0 25.077 7.158 31.54 17.832h.047l29.15 40.921h.047c6.718 9.1 17.486 15.026 29.663 15.026c12.181 0 22.95-5.927 29.666-15.026h.05l.297-.46a36.949 36.949 0 0 0 2.116-3.312l43.675-68.256v.003A36.747 36.747 0 0 0 64.55 19.33M2.372 46.141c.213.204.435.397.654.594c-.22-.197-.438-.39-.654-.594m3.28 2.745c.243.181.48.369.728.544c-.247-.175-.485-.363-.729-.544m8.096 4.598c.306.128.628.228.94.347c-.312-.12-.634-.22-.94-.347m8.28 2.263c.428.065.853.143 1.287.197c-.434-.054-.856-.132-1.287-.197m9.93.203c.438-.05.869-.135 1.303-.197c-.434.062-.862.147-1.303.197m8.368-2.01c.393-.144.797-.275 1.184-.434c-.387.159-.788.29-1.185.434m8.368-4.326c.313-.216.61-.456.916-.684c-.307.228-.603.465-.916.684m6.258-5.526c.259-.285.528-.563.778-.857c-.25.294-.519.572-.778.857"/><path id="logosMatomoIcon2" fill="#95C748" d="m250.511 88.448l.035-.022l-.663-1.01c-.1-.153-.2-.312-.303-.46L204.02 17.56l-58.935 43.91l43.078 66.305c.185.281.36.566.55.847l.229.35l.025-.016c6.676 9.471 17.678 15.673 30.144 15.673c20.373 0 36.889-16.513 36.889-36.89c0-7.083-2.029-13.675-5.489-19.291"/><filter id="logosMatomoIcon3" width="106.9%" height="109.7%" x="-3.4%" y="-3.5%" filterUnits="objectBoundingBox"><feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="2"/><feColorMatrix in="shadowBlurOuter1" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0"/></filter></defs><use href="#logosMatomoIcon2"/><path fill="#35BFC0" d="M73.779 107.74c0-20.374-16.516-36.89-36.89-36.89C16.516 70.85 0 87.366 0 107.74c0 20.376 16.516 36.892 36.89 36.892c20.373 0 36.889-16.52 36.889-36.893"/><path fill="#3253A0" d="M172.744 0c20.373 0 36.89 16.516 36.89 36.89a36.747 36.747 0 0 1-6.346 20.688v-.003l-43.675 68.256a36.949 36.949 0 0 1-2.116 3.313l-.297.46h-.05c-6.717 9.098-17.485 15.025-29.666 15.025c-12.177 0-22.945-5.927-29.663-15.026h-.046l-29.15-40.921h-.047C62.114 78.008 50.427 70.85 37.036 70.85c-12.721 0-23.936 6.436-30.569 16.235l44.61-69.525h.037C57.613 7.036 69.215 0 82.49 0c13.275 0 24.877 7.036 31.376 17.56h.04l28.006 39.593l.02-.003c0 .003 0 .003.002.003a36.684 36.684 0 0 1-6.08-20.264C135.855 16.516 152.372 0 172.745 0"/><use href="#logosMatomoIcon2"/><g transform="translate(145.085 17.56)"><mask id="logosMatomoIcon4" fill="#fff"><use href="#logosMatomoIcon0"/></mask><g mask="url(#logosMatomoIcon4)"><use filter="url(#logosMatomoIcon3)" href="#logosMatomoIcon1"/></g></g><path fill="#F38334" d="M209.487 36.89c0-20.374-16.516-36.89-36.89-36.89c-20.373 0-36.89 16.516-36.89 36.89c0 20.373 16.517 36.889 36.89 36.889c20.374 0 36.89-16.516 36.89-36.89"/><path fill="#3152A0" d="M172.597 73.782c-12.887 0-24.214-6.617-30.81-16.629h-.021L113.759 17.56h-.04C107.22 7.04 95.618.003 82.343.003C69.068.003 57.466 7.04 50.967 17.56h-.037L6.323 87.085c6.63-9.796 17.848-16.232 30.566-16.232c13.39 0 25.08 7.155 31.545 17.829h.047l29.15 40.921h.044c6.72 9.096 17.488 15.029 29.665 15.029c12.178 0 22.946-5.93 29.663-15.029h.05l.297-.462a37.588 37.588 0 0 0 2.12-3.307l43.672-68.256c-6.636 9.774-17.839 16.204-30.545 16.204"/></svg>`,
rybbitAnalytics: {
light: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 263.33 173.53" width="37.24" height="32"><g><polygon fill="#0a3a3a" points="181.28 171.2 227.21 123.96 261.15 171.2 181.28 171.2"/><path fill="#0a3a3a" d="M261.15,89.05L206.64,2.33l-33.22,17.75-34.61-7.4c2.88,5.56,4.56,12.11,4.56,19.15,0,20.03-13.46,36.26-30.06,36.26-13.66,0-25.17-11-28.83-26.06l-39.92,71.46L2.18,94.19l22.66,77.01h55.81l22.28-54.01v54.01h64.66l-49.95-82.15h143.51Z"/></g><ellipse fill="#0a3a3a" cx="105.94" cy="28.62" rx="12.9" ry="18.88"/></svg>`,
Expand Down
53 changes: 53 additions & 0 deletions packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>, 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<Parameters<PulseAnalyticsApi['track']>>\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",
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
MixpanelAnalyticsOptions,
NpmOptions,
PostHogOptions,
PulseAnalyticsOptions,
RedditPixelOptions,
RybbitAnalyticsOptions,
SegmentOptions,
Expand Down Expand Up @@ -89,7 +90,7 @@

// -- Privacy descriptions --

export const PRIVACY_DESCRIPTIONS: Record<string, PrivacyDescription> = {

Check warning on line 93 in packages/script/src/registry.ts

View workflow job for this annotation

GitHub Actions / lint

Annotating with `Record<string, PrivacyDescription>` discards the object's known keys. Use `satisfies` to keep them
'ip-only': { label: 'IP Only', description: 'Anonymises IP addresses; other data passes through.' },
'full': { label: 'Full', description: 'All identifying data is anonymised: IP, user agent, language, screen, timezone, and hardware.' },
'heatmap': { label: 'Heatmap', description: 'IP, language, and hardware fingerprints anonymised; screen data preserved for heatmap accuracy.' },
Expand Down Expand Up @@ -140,6 +141,9 @@
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),
Expand Down Expand Up @@ -462,6 +466,16 @@
},
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,
Expand Down
Loading
Loading