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
51 changes: 35 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,12 @@ The [recipes](docs/RECIPES.md) show how to send the Google Analytics client ID a

## Configuration

All new collection and retention options are opt-in. Existing behavior is preserved by default.

| Option | Default | Behavior |
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------- |
| `trackingEnabled` | `true` | Initial tracking preference when no saved preference exists. |
| `trackOnRouteChange` | `false` | Also capture successful client-side navigations whose full URL path changes. |
| `captureWithoutCampaign` | `true` | Include visits without UTM, Google click, or configured click parameters. |
| `captureLandingOnEnable` | `true` | When tracking starts disabled, `enableTracking()` also records the landing page, held in memory until then. |
| `maxAge` | Unset | Retain entries younger than this many seconds. Must be positive and finite. |
| `maxEntries` | Unset | Retain at most this many entries. Must be a positive integer. |
| `clickParams` | Unset | Extra campaign parameters to capture, such as other ad platforms' click IDs. Must not repeat UTM or Google names. |
Expand Down Expand Up @@ -197,18 +196,18 @@ const payload = await utm.getAttribution()
await utm.capture()
```

| Member | Purpose |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `data` | Read-only reactive history, newest first. |
| `firstTouch`, `lastTouch` | Read-only reactive campaign entries, or `null`. |
| `trackingEnabled` | Read-only reactive tracking preference. |
| `storageAvailable` | Whether browser history can persist in localStorage. |
| `getAttribution()` | Prepare a detached snapshot after pending persistence completes. |
| `capture()` | Capture the current route manually, respecting tracking controls, hooks, and deduplication. |
| `enableTracking()` | Save the enabled preference and start capturing the current route. |
| `disableTracking()` | Save the disabled preference and cancel pending collection. |
| `clearData()` | Clear history and the session ID, and cancel pending collection. Keeps the tracking preference. |
| `onBeforeTrack(cb)`, `onBeforePersist(cb)`, `onTracked(cb)` | Register a hook; return a function that unregisters it. |
| Member | Purpose |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `data` | Read-only reactive history, newest first. |
| `firstTouch`, `lastTouch` | Read-only reactive campaign entries, or `null`. |
| `trackingEnabled` | Read-only reactive tracking preference. |
| `storageAvailable` | Whether browser history can persist in localStorage. |
| `getAttribution()` | Prepare a detached snapshot after pending persistence completes. |
| `capture()` | Capture the current route manually, respecting tracking controls, hooks, and deduplication. |
| `enableTracking()` | Save the enabled preference and capture the current route, after a held landing route. |
| `disableTracking()` | Save the disabled preference and cancel pending collection. |
| `clearData()` | Clear history, the session ID, and a held landing route, and cancel pending collection. Keeps the tracking preference. |
| `onBeforeTrack(cb)`, `onBeforePersist(cb)`, `onTracked(cb)` | Register a hook; return a function that unregisters it. |

Access refs with `.value` in script, for example `utm.lastTouch.value?.utmParams.utm_source`. Data is empty during server rendering; collection occurs in the browser after mounting. The server also renders the configured `trackingEnabled` value, while the browser loads any saved tracking preference. Wrap displays of browser history or the tracking preference in Nuxt’s `<ClientOnly>` to avoid hydration mismatches for returning visitors. Call `getAttribution()` from a client action after mounting, such as the form handler above.

Expand Down Expand Up @@ -246,7 +245,23 @@ The module reads its localStorage keys and applies retention when the app starts

Whether you need consent depends on where your visitors are and what you use the data for. In the EU, [Article 5(3) of the ePrivacy Directive](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:02002L0058-20091219) requires consent to store information on a visitor's device or read it, with narrow exceptions such as storage strictly necessary for a service the visitor explicitly requested. The EDPB's [Guidelines 2/2023](https://www.edpb.europa.eu/our-work-tools/our-documents/guidelines/guidelines-22023-technical-scope-art-53-eprivacy-directive_en) explain that this covers more than cookies, and the UK ICO's [guidance on storage and access technologies](https://ico.org.uk/for-organisations/direct-marketing-and-privacy-and-electronic-communications/guidance-on-the-use-of-storage-and-access-technologies/) covers local storage under the UK rules. Data protection law such as the GDPR applies when the data is personal data. The GDPR's definition includes online identifiers, so stored click IDs can qualify before any form is submitted, and history submitted with a lead's contact details qualifies. This README is not legal advice.

To collect only after consent, start with tracking disabled as described in [Tracking preferences and storage](#tracking-preferences-and-storage), then connect the module to your consent tool:
Nuxt UTM never checks consent itself. Your application chooses how consent affects it:

- **Track regardless of consent.** This is the default: keep `trackingEnabled: true` and don't call `disableTracking()`.
- **Track, and check consent before using the data.** Collection continues, and your application checks consent where the data leaves the browser. The history is stored on the visitor's device either way, and the rules above cover that storage. Call `clearData()` if a refusal should delete it.
- **Track only after consent.** Start with tracking disabled and enable it when the visitor accepts.

To check consent before using the data, guard the call in the form handler from [Send attribution with a form](#send-attribution-with-a-form):

```ts
import { hasConsent } from '~/utils/consent'

const attribution = hasConsent() ? await utm.getAttribution() : null
```

Here and below, `~/utils/consent` stands for your consent tool's API. To tell later which entries were collected with consent, record it on each entry in a `utm:before-persist` hook with `entry.customParams = { ...entry.customParams, consent: hasConsent() }`.

To track only after consent, start with tracking disabled as described in [Tracking preferences and storage](#tracking-preferences-and-storage), then connect the module to your consent tool:

```ts
// plugins/utm-consent.client.ts
Expand All @@ -270,7 +285,11 @@ export default defineNuxtPlugin((nuxtApp) => {
})
```

Replace `~/utils/consent` with your consent tool's API. A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. `enableTracking()` captures the current page only; campaign parameters from pages visited before consent are not recovered. If your consent UI shows `trackingEnabled`, see the `<ClientOnly>` note in [Composable](#composable).
A saved preference outlives consent that expires without a callback, so the hook checks consent on every capture. If your consent UI shows `trackingEnabled`, see the `<ClientOnly>` note in [Composable](#composable).

Visitors often give consent after leaving the landing page. When the app starts with tracking disabled, the module holds the landing route in memory, never in storage, until tracking is enabled. `enableTracking()` then records the landing page with the time of the visit, followed by the current page. Hooks for the landing page run at that point, so read the page from `context.route`, not `window.location`. Pages visited in between are not recovered. When tracking is already enabled at startup, as with a saved preference that outlived expired consent, the landing page is recorded right away instead, so a `utm:before-track` consent check decides then; if `disableTracking()` cancels that capture, the landing page stays held.

The held route lives only as long as the page, so a reload or a new visit starts without it, and it is discarded once recorded. `disableTracking()` keeps it, so a visitor who declines and then accepts during the same visit still has the landing page recorded. `clearData()` discards it; if your consent tool reports a refusal before the visitor has chosen, call `clearData()` only once the visitor actually refuses. Set `captureLandingOnEnable: false` to hold nothing, so `enableTracking()` records only the current page.

To collect less:

Expand Down
49 changes: 37 additions & 12 deletions scripts/test-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,13 @@ export async function testCompatibility({ root, directory, tarball }) {
mode,
storedLandingPages: [landingPage],
landingPagesOnMount: [landingPage],
storedAfterLateConsent: ['/contact?utm_source=newsletter', landingPage],
errors: [],
}
}),
)
console.log(
`Nuxt ${installed.version}: package types, build, generate, landing pages, navigation, reload, and form attribution passed`,
`Nuxt ${installed.version}: package types, build, generate, landing pages, late consent, navigation, reload, and form attribution passed`,
)
} finally {
await browser?.close()
Expand Down Expand Up @@ -203,28 +204,52 @@ async function findFreePort() {
}

async function visitLandingPage(browser, origin, mode) {
const context = await browser.newContext()
try {
const page = await context.newPage()
page.setDefaultTimeout(15_000)
const errors = collectPageErrors(page)
await page.goto(`${origin}/landing/${mode}?utm_source=${mode}`)
const landingPage = `${origin}/landing/${mode}?utm_source=${mode}`
const { errors: trackedErrors, ...tracked } = await withNewPage(browser, async (page) => {
await page.goto(landingPage)
const readOnMount = await page.getByTestId('on-mount').filter({ hasText: '[' }).textContent()
await page.waitForLoadState('networkidle')
const stored = await page.evaluate(() => localStorage.getItem('nuxt-utm-data'))
return {
mode,
storedLandingPages: JSON.parse(stored ?? '[]').map(({ additionalInfo }) =>
relativeUrl(additionalInfo.landingPageUrl),
storedLandingPages: storedLandingPages(
await page.evaluate(() => localStorage.getItem('nuxt-utm-data')),
),
landingPagesOnMount: JSON.parse(readOnMount).map(relativeUrl),
errors,
}
})
const { errors: lateConsentErrors, ...lateConsent } = await withNewPage(browser, async (page) => {
await page.addInitScript(() => localStorage.setItem('nuxt-utm-tracking-enabled', 'false'))
await page.goto(landingPage)
await page.getByTestId('on-mount').filter({ hasText: '[' }).waitFor()
await page.getByRole('link', { name: 'Newsletter contact link' }).click()
await page.waitForURL('**/contact?utm_source=newsletter')
await page.getByRole('button', { name: 'Accept tracking' }).click()
const stored = await page.waitForFunction(() => {
const stored = localStorage.getItem('nuxt-utm-data')
return stored?.includes('"newsletter"') && stored
})
return { storedAfterLateConsent: storedLandingPages(await stored.jsonValue()) }
})
return { mode, ...tracked, ...lateConsent, errors: [...trackedErrors, ...lateConsentErrors] }
}

async function withNewPage(browser, visit) {
const context = await browser.newContext()
try {
const page = await context.newPage()
page.setDefaultTimeout(15_000)
const errors = collectPageErrors(page)
return { ...(await visit(page)), errors }
} finally {
await context.close()
}
}

function storedLandingPages(stored) {
return JSON.parse(stored ?? '[]').map(({ additionalInfo }) =>
relativeUrl(additionalInfo.landingPageUrl),
)
}

function relativeUrl(url) {
const { pathname, search } = new URL(url)
return pathname + search
Expand Down
1 change: 1 addition & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const defaults = {
trackingEnabled: true,
trackOnRouteChange: false,
captureWithoutCampaign: true,
captureLandingOnEnable: true,
}

export default defineNuxtModule<ModuleOptions>({
Expand Down
48 changes: 43 additions & 5 deletions src/runtime/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ const LOCAL_STORAGE_KEY = 'nuxt-utm-data'
const SESSION_ID_KEY = 'nuxt-utm-session-id'
const TRACKING_ENABLED_KEY = 'nuxt-utm-tracking-enabled'

type HeldRoute = { route: RouteLocationNormalized; timestamp: string }

export default defineNuxtPlugin((nuxtApp) => {
const options = useRuntimeConfig().public.utm as Required<
Pick<ModuleOptions, 'trackingEnabled' | 'trackOnRouteChange' | 'captureWithoutCampaign'>
Pick<
ModuleOptions,
'trackingEnabled' | 'trackOnRouteChange' | 'captureWithoutCampaign' | 'captureLandingOnEnable'
>
> &
ModuleOptions
// A runtime config override skips the build time checks, so a malformed list must not stop tracking
Expand All @@ -50,6 +55,8 @@ export default defineNuxtPlugin((nuxtApp) => {
storedPreference === null ? options.trackingEnabled : storedPreference === 'true',
)
const touches = computed(() => getCampaignTouches(data.value))
let untrackedLanding: HeldRoute | undefined
let capturingLanding: HeldRoute | undefined
let generation = 0
let pending = Promise.resolve()
let cancelPending!: () => void
Expand Down Expand Up @@ -101,7 +108,30 @@ export default defineNuxtPlugin((nuxtApp) => {
return nuxtApp.isHydrating && route.fullPath !== landingRoute.fullPath ? landingRoute : route
}

function capture(route = visitedRoute()): Promise<void> {
// Consent often arrives after the visitor has left the landing page, so its campaign waits in
// memory until tracking is enabled. Without pages, Nuxt updates the route object in place on
// navigation, so the held route is a copy
function holdLanding(route: RouteLocationNormalized) {
untrackedLanding = {
route: { ...route, query: { ...route.query } },
timestamp: new Date().toISOString(),
}
}

// The held route is released once its capture completes; disableTracking() hands a cancelled
// capture back so the next enableTracking() still records it
function captureHeldLanding(): Promise<void> {
const landing = untrackedLanding
if (!landing) return Promise.resolve()
untrackedLanding = undefined
capturingLanding = landing
const startedIn = generation
return capture(landing.route, landing.timestamp).then(() => {
if (generation === startedIn) capturingLanding = undefined
})
}

function capture(route = visitedRoute(), timestamp = new Date().toISOString()): Promise<void> {
if (!isClient || !trackingEnabled.value) return Promise.resolve()
const query = Object.fromEntries(
Object.entries(route.query).map(([key, value]) => [
Expand All @@ -111,7 +141,6 @@ export default defineNuxtPlugin((nuxtApp) => {
)
const context: BeforeTrackContext = { route: { ...route, query }, query, skip: false }
const landingPageUrl = new URL(route.fullPath, window.location.href).href
const timestamp = new Date().toISOString()
const currentGeneration = generation
const isActive = () => trackingEnabled.value && generation === currentGeneration

Expand Down Expand Up @@ -159,18 +188,25 @@ export default defineNuxtPlugin((nuxtApp) => {
trackingEnabled.value = true
if (isClient) {
local.setItem(TRACKING_ENABLED_KEY, 'true')
void capture()
const current = visitedRoute()
const landing = untrackedLanding
void captureHeldLanding()
if (landing?.route.fullPath !== current.fullPath) void capture(current)
}
}

const disableTracking = () => {
invalidatePending()
trackingEnabled.value = false
untrackedLanding ??= capturingLanding
capturingLanding = undefined
if (isClient) local.setItem(TRACKING_ENABLED_KEY, 'false')
}

const clearData = () => {
invalidatePending()
untrackedLanding = undefined
capturingLanding = undefined
data.value = []
if (isClient) {
local.removeItem(LOCAL_STORAGE_KEY)
Expand Down Expand Up @@ -201,7 +237,9 @@ export default defineNuxtPlugin((nuxtApp) => {
void capture(to)
})
}
await capture(landing)
if (!options.captureLandingOnEnable) return capture(landing)
holdLanding(landing)
if (trackingEnabled.value) await captureHeldLanding()
})

return {
Expand Down
1 change: 1 addition & 0 deletions src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface ModuleOptions {
maxEntries?: number
trackOnRouteChange?: boolean
captureWithoutCampaign?: boolean
captureLandingOnEnable?: boolean
// Intersecting string with {} keeps the known names as editor suggestions while accepting any name
clickParams?: (AdClickParameter | (string & {}))[]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ onMounted(async () => {
<template>
<NuxtLink to="/ssr?utm_source=next">Next campaign</NuxtLink>
<button type="button" @click="utm.capture()">Capture</button>
<button type="button" @click="utm.enableTracking()">Accept tracking</button>
<output data-testid="on-mount">{{
landingPagesOnMount && JSON.stringify(landingPagesOnMount)
}}</output>
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/route-change/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ async function openNewsletter() {

<template>
<button type="button" @click="openNewsletter">Newsletter</button>
<button type="button" @click="utm.enableTracking()">Accept tracking</button>
<p v-if="lastSource">Last touch: {{ lastSource }}</p>
</template>
Loading
Loading