diff --git a/README.md b/README.md index b73ab6c..2396101 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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 `` to avoid hydration mismatches for returning visitors. Call `getAttribution()` from a client action after mounting, such as the form handler above. @@ -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 @@ -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 `` 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 `` 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: diff --git a/scripts/test-compatibility.mjs b/scripts/test-compatibility.mjs index 55d07da..b279053 100644 --- a/scripts/test-compatibility.mjs +++ b/scripts/test-compatibility.mjs @@ -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() @@ -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 diff --git a/src/module.ts b/src/module.ts index c0de81f..4a7fea2 100644 --- a/src/module.ts +++ b/src/module.ts @@ -26,6 +26,7 @@ const defaults = { trackingEnabled: true, trackOnRouteChange: false, captureWithoutCampaign: true, + captureLandingOnEnable: true, } export default defineNuxtModule({ diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 424b3bd..ccaef3d 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -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 + Pick< + ModuleOptions, + 'trackingEnabled' | 'trackOnRouteChange' | 'captureWithoutCampaign' | 'captureLandingOnEnable' + > > & ModuleOptions // A runtime config override skips the build time checks, so a malformed list must not stop tracking @@ -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 @@ -101,7 +108,30 @@ export default defineNuxtPlugin((nuxtApp) => { return nuxtApp.isHydrating && route.fullPath !== landingRoute.fullPath ? landingRoute : route } - function capture(route = visitedRoute()): Promise { + // 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 { + 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 { if (!isClient || !trackingEnabled.value) return Promise.resolve() const query = Object.fromEntries( Object.entries(route.query).map(([key, value]) => [ @@ -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 @@ -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) @@ -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 { diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ab9c52c..c9f59bc 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -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 & {}))[] } diff --git a/test/fixtures/rendering-modes/components/LandingAttribution.vue b/test/fixtures/rendering-modes/components/LandingAttribution.vue index 601a7c8..b3592df 100644 --- a/test/fixtures/rendering-modes/components/LandingAttribution.vue +++ b/test/fixtures/rendering-modes/components/LandingAttribution.vue @@ -24,6 +24,7 @@ onMounted(async () => { diff --git a/test/package-fixture/consumer.mts b/test/package-fixture/consumer.mts index 7c9410b..2a692b0 100644 --- a/test/package-fixture/consumer.mts +++ b/test/package-fixture/consumer.mts @@ -15,6 +15,7 @@ export const options: ModuleOptions = { maxEntries: 100, trackOnRouteChange: true, captureWithoutCampaign: false, + captureLandingOnEnable: false, clickParams: ['fbclid', 'msclkid', 'partner_click_id'], } // Known click IDs must stay literal members of the option type so editors suggest them. diff --git a/test/runtime-plugin.test.ts b/test/runtime-plugin.test.ts index 4daea8d..a8231b6 100644 --- a/test/runtime-plugin.test.ts +++ b/test/runtime-plugin.test.ts @@ -1,7 +1,7 @@ import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' import { createHooks } from 'hookable' import type { NuxtApp, RuntimeNuxtHooks } from '#app' -import { computed, isReadonly, shallowRef } from 'vue' +import { computed, isReadonly, reactive, shallowRef, unref } from 'vue' import type { App, MaybeRef, Ref } from 'vue' import type { RouteLocationNormalized } from 'vue-router' import type { DataObject, AttributionSnapshot, ModuleOptions } from 'nuxt-utm' @@ -79,6 +79,7 @@ beforeEach(() => { config.public.utm = { trackingEnabled: true, captureWithoutCampaign: true, + captureLandingOnEnable: true, trackOnRouteChange: false, } visit('/?utm_source=google') @@ -196,6 +197,169 @@ describe('tracking lifecycle', () => { }) }) +describe('tracking enabled after landing', () => { + const sources = (history: readonly DataObject[]) => + history.map(({ timestamp, utmParams }) => [utmParams.utm_source, timestamp]) + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + config.public.utm.trackingEnabled = false + }) + + it('records the landing page at its arrival time, then the current page', async () => { + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:05:00.000Z'], + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('records the landing page once when tracking is enabled on it', async () => { + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('records the landing page only the first time tracking is enabled', async () => { + const { controls, mount } = await setupTracker() + await mount() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + await controls.utmGetAttribution() + controls.utmDisableTracking() + visit('/contact?utm_source=partner') + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['partner', 'newsletter', 'google']) + }) + + it('records the landing page in an app without pages, whose router updates the route in place', async () => { + const route = reactive(routeTo('/?utm_source=google')) + router.currentRoute = computed(() => route) + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + Object.assign(route, routeTo('/pricing?utm_source=newsletter')) + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:05:00.000Z'], + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('keeps the landing page when tracking is disabled before it is recorded', async () => { + const { controls, mount } = await setupTracker() + await mount() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + controls.utmDisableTracking() + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter', 'google']) + }) + + it('keeps the landing page when tracking is disabled while its hook is pending', async () => { + const { hooks, controls, mount } = await setupTracker() + const entered = deferred() + const resume = deferred() + hooks.hook('utm:before-track', async ({ route }) => { + if (route.path !== '/') return + entered.resolve() + await resume.promise + }) + await mount() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + await entered.promise + controls.utmDisableTracking() + resume.resolve() + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter', 'google']) + }) + + it('keeps the landing page when tracking starts enabled and is disabled while recording it', async () => { + config.public.utm.trackingEnabled = true + const { hooks, controls, mount } = await setupTracker() + const entered = deferred() + const resume = deferred() + hooks.hook('utm:before-track', async ({ route }) => { + if (route.path !== '/') return + entered.resolve() + await resume.promise + }) + const mounted = mount() + await entered.promise + controls.utmDisableTracking() + resume.resolve() + await mounted + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter', 'google']) + }) + + it('keeps recording every visit without consent controls when tracking starts enabled', async () => { + config.public.utm.trackingEnabled = true + config.public.utm.trackOnRouteChange = true + const { controls, mount } = await setupTracker() + await mount() + const [afterNavigation] = router.afterEach.mock.lastCall! + visit('/pricing?utm_source=newsletter') + afterNavigation(unref(router.currentRoute), routeTo('/?utm_source=google')) + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:00:00.000Z'], + ['google', '2026-01-01T00:00:00.000Z'], + ]) + }) + + it('records only the current page when captureLandingOnEnable is off', async () => { + config.public.utm.captureLandingOnEnable = false + const { controls, mount } = await setupTracker() + await mount() + vi.setSystemTime(new Date('2026-01-01T00:05:00Z')) + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect(sources((await controls.utmGetAttribution()).history)).toEqual([ + ['newsletter', '2026-01-01T00:05:00.000Z'], + ]) + }) + + it('forgets the landing page when data is cleared before tracking is enabled', async () => { + const { controls, mount } = await setupTracker() + await mount() + controls.utmClearData() + visit('/pricing?utm_source=newsletter') + controls.utmEnableTracking() + + expect( + (await controls.utmGetAttribution()).history.map(({ utmParams }) => utmParams.utm_source), + ).toEqual(['newsletter']) + }) +}) + describe('reactive history', () => { it('updates history and touches after capture while keeping entries read-only', async () => { const { controls, mount } = await setupTracker()