Skip to content

Commit 7ca6a27

Browse files
sadjowhakenprog
andauthored
Add hooks mechanism for extensible tracking (#95)
* feat: add hooks mechanism for extensible tracking Co-authored-by: Luis David Barrera Díaz <dbarrera@stackbuilders.com> * fix: harden hook pipeline and validate execution order * feat(playground): add page category hook testing flow * docs: add pageCategory hook example to README --------- Co-authored-by: Luis David Barrera Díaz <dbarrera@stackbuilders.com>
1 parent 183b03f commit 7ca6a27

14 files changed

Lines changed: 663 additions & 26 deletions

File tree

README.md

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ If a visitor arrives at a website that uses the Nuxt UTM module and a UTM parame
2525
- **📍 UTM Tracking**: Easily capture UTM parameters to gain insights into traffic sources and campaign performance.
2626
- **🔍 Intelligent De-duplication**: Smart recognition of page refreshes to avoid data duplication, ensuring each visit is uniquely accounted for.
2727
- **🔗 Comprehensive Data Collection**: Alongside UTM parameters, gather additional context such as referrer details, user agent, landing page url, browser language, and screen resolution. This enriched data empowers your marketing strategies with a deeper understanding of campaign impact.
28+
- **🔌 Hooks & Extensibility**: Three runtime hooks (`utm:before-track`, `utm:before-persist`, `utm:tracked`) let you skip tracking, enrich data with custom parameters, or trigger side effects after tracking completes.
2829

2930
## Quick Setup
3031

@@ -86,6 +87,9 @@ const utm = useNuxtUTM()
8687
// - enableTracking(): Enable UTM tracking
8788
// - disableTracking(): Disable UTM tracking
8889
// - clearData(): Clear all stored UTM data
90+
// - onBeforeTrack(cb): Hook called before data collection
91+
// - onBeforePersist(cb): Hook called to enrich/modify collected data before saving
92+
// - onTracked(cb): Hook called after data is saved
8993
</script>
9094
```
9195

@@ -125,11 +129,7 @@ const rejectTracking = () => {
125129
<div class="privacy-settings">
126130
<h3>Privacy Settings</h3>
127131
<label>
128-
<input
129-
type="checkbox"
130-
:checked="utm.trackingEnabled.value"
131-
@change="toggleTracking"
132-
/>
132+
<input type="checkbox" :checked="utm.trackingEnabled.value" @change="toggleTracking" />
133133
Enable UTM tracking
134134
</label>
135135
<button @click="utm.clearData" v-if="utm.data.value.length > 0">
@@ -195,6 +195,9 @@ The `data` property contains an array of UTM parameters collected. Each element
195195
"gclidParams": {
196196
"gclid": "CjklsefawEFRfeafads",
197197
"gad_source": "1"
198+
},
199+
"customParams": {
200+
"fbclid": "abc123"
198201
}
199202
}
200203
]
@@ -210,6 +213,149 @@ Each entry provides a `timestamp` indicating when the UTM parameters were collec
210213
- **Data Clearing**: Ability to completely remove all collected data
211214
- **Session Management**: Automatically manages sessions to avoid duplicate tracking
212215

216+
### Hooks
217+
218+
The module provides three runtime hooks that let you extend the tracking pipeline. You can use them to skip tracking, enrich data with custom parameters, or trigger side effects after tracking completes. Hooks can be registered via a Nuxt plugin or through the `useNuxtUTM` composable.
219+
220+
This keeps your tracking strategy flexible: enrich once in your app, then forward the same enriched payload wherever you need it.
221+
222+
#### Available Hooks
223+
224+
| Hook | When it fires | Receives | Purpose |
225+
| ------------------ | -------------------------------------- | ----------------------------------------------- | ---------------------------------------------------- |
226+
| `utm:before-track` | Before data collection | `BeforeTrackContext` (`{ route, query, skip }`) | Conditionally skip tracking by setting `skip = true` |
227+
| `utm:before-persist` | After data is collected, before saving | `DataObject` (mutable) | Enrich or modify the data, add `customParams` |
228+
| `utm:tracked` | After data is saved to localStorage | `DataObject` (final) | Side effects: send to API, fire analytics, log |
229+
230+
#### Registering Hooks via Plugin
231+
232+
Create a Nuxt plugin to register hooks that run on every page visit:
233+
234+
```typescript
235+
// plugins/utm-hooks.client.ts
236+
export default defineNuxtPlugin((nuxtApp) => {
237+
// Skip tracking on admin pages
238+
nuxtApp.hook('utm:before-track', (context) => {
239+
if (context.route.path.startsWith('/admin')) {
240+
context.skip = true
241+
}
242+
})
243+
244+
// Add custom marketing parameters
245+
nuxtApp.hook('utm:before-persist', (data) => {
246+
const query = nuxtApp._route.query
247+
if (query.fbclid) {
248+
data.customParams = {
249+
...data.customParams,
250+
fbclid: String(query.fbclid),
251+
}
252+
}
253+
if (query.msclkid) {
254+
data.customParams = {
255+
...data.customParams,
256+
msclkid: String(query.msclkid),
257+
}
258+
}
259+
})
260+
261+
// Send data to your backend after tracking
262+
nuxtApp.hook('utm:tracked', async (data) => {
263+
await $fetch('/api/marketing/track', {
264+
method: 'POST',
265+
body: data,
266+
})
267+
})
268+
})
269+
```
270+
271+
#### Registering Hooks via Composable
272+
273+
The `useNuxtUTM` composable provides convenience methods for registering hooks. Each method returns a cleanup function to unregister the hook.
274+
275+
```vue
276+
<script setup>
277+
const utm = useNuxtUTM()
278+
279+
// Register a before-persist hook
280+
const cleanup = utm.onBeforePersist((data) => {
281+
data.customParams = { ...data.customParams, source: 'vue-component' }
282+
})
283+
284+
// Unregister when no longer needed
285+
// cleanup()
286+
</script>
287+
```
288+
289+
#### Example: add `pageCategory`
290+
291+
Use `utm:before-persist` to enrich every tracked event with a normalized `pageCategory`. This pattern is useful when you want one internal taxonomy that can be reused across your app and backend.
292+
293+
```typescript
294+
// plugins/utm-page-category.client.ts
295+
export default defineNuxtPlugin((nuxtApp) => {
296+
nuxtApp.hook('utm:before-persist', (data) => {
297+
const url = new URL(data.additionalInfo.landingPageUrl)
298+
const explicitCategory = url.searchParams.get('page_category')
299+
300+
// Optional fallback categorization from pathname
301+
const fallbackCategory = url.pathname.startsWith('/pricing') ? 'pricing' : 'general'
302+
303+
data.customParams = {
304+
...data.customParams,
305+
pageCategory: explicitCategory ?? fallbackCategory,
306+
}
307+
})
308+
})
309+
```
310+
311+
Tracked data will include:
312+
313+
```json
314+
{
315+
"customParams": {
316+
"pageCategory": "pricing"
317+
}
318+
}
319+
```
320+
321+
#### Hook: `utm:before-track`
322+
323+
Called before any data collection begins. The handler receives a `BeforeTrackContext` object with `route`, `query`, and a `skip` flag. Set `skip = true` to prevent tracking for the current page visit.
324+
325+
```typescript
326+
nuxtApp.hook('utm:before-track', (context) => {
327+
// context.route - the current route object
328+
// context.query - the current URL query parameters
329+
// context.skip - set to true to skip tracking
330+
})
331+
```
332+
333+
#### Hook: `utm:before-persist`
334+
335+
Called after the `DataObject` is built but before it is checked for duplicates and saved. The handler receives the `DataObject` directly and can mutate it to add or modify fields. This is the primary hook for adding `customParams`.
336+
337+
```typescript
338+
nuxtApp.hook('utm:before-persist', (data) => {
339+
// Add any custom tracking parameters
340+
data.customParams = {
341+
...data.customParams,
342+
myCustomField: 'value',
343+
}
344+
})
345+
```
346+
347+
> Note: `customParams` are not included in the de-duplication check. Only UTM parameters, GCLID parameters, and session ID are compared.
348+
349+
#### Hook: `utm:tracked`
350+
351+
Called after data is saved to localStorage. The handler receives the final `DataObject`. Use this for side effects like sending data to a backend or triggering analytics events.
352+
353+
```typescript
354+
nuxtApp.hook('utm:tracked', async (data) => {
355+
console.log('Tracked:', data.utmParams, data.customParams)
356+
})
357+
```
358+
213359
## Development
214360

215361
```bash

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default createConfigForNuxt({
1717
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
1818
'@stylistic/semi': ['error', 'never'],
1919
'@stylistic/comma-dangle': ['error', 'always-multiline'],
20+
'@stylistic/arrow-parens': ['error', 'always'],
2021
'@stylistic/operator-linebreak': 'off',
2122
'@stylistic/brace-style': 'off',
2223
'@stylistic/indent-binary-ops': 'off',

playground/app.vue

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,18 @@
66
<h2>Tracking Controls</h2>
77
<p>
88
Tracking is currently:
9-
<strong :class="{ enabled: utm.trackingEnabled.value, disabled: !utm.trackingEnabled.value }">
9+
<strong
10+
:class="{ enabled: utm.trackingEnabled.value, disabled: !utm.trackingEnabled.value }"
11+
>
1012
{{ utm.trackingEnabled.value ? 'ENABLED' : 'DISABLED' }}
1113
</strong>
1214
</p>
1315

1416
<div class="buttons">
15-
<button
16-
:disabled="utm.trackingEnabled.value"
17-
@click="utm.enableTracking"
18-
>
17+
<button @click="utm.enableTracking">
1918
Enable Tracking
2019
</button>
21-
<button
22-
:disabled="!utm.trackingEnabled.value"
23-
@click="utm.disableTracking"
24-
>
20+
<button @click="utm.disableTracking">
2521
Disable Tracking
2622
</button>
2723
<button
@@ -32,11 +28,25 @@
3228
</button>
3329
</div>
3430

31+
<h2>Custom Hook Testing</h2>
32+
<div class="buttons">
33+
<button @click="visitWithPageCategory('pricing')">
34+
Track pageCategory: pricing
35+
</button>
36+
<button @click="visitWithPageCategory('features')">
37+
Track pageCategory: features
38+
</button>
39+
</div>
40+
3541
<div class="info">
3642
<p>Try visiting with UTM parameters:</p>
3743
<a href="/?utm_source=test&utm_medium=demo&utm_campaign=playground">
3844
Add UTM params to URL
3945
</a>
46+
<p class="hint">
47+
Then click a custom hook button above and check
48+
<code>customParams.pageCategory</code> in collected data.
49+
</p>
4050
</div>
4151
</div>
4252

@@ -51,14 +61,26 @@
5161
import { useNuxtUTM } from '#imports'
5262
5363
const utm = useNuxtUTM()
64+
65+
const visitWithPageCategory = (pageCategory) => {
66+
const url = new URL(window.location.href)
67+
url.searchParams.set('utm_source', 'playground')
68+
url.searchParams.set('utm_medium', 'manual-test')
69+
url.searchParams.set('utm_campaign', 'custom-hook')
70+
url.searchParams.set('page_category', pageCategory)
71+
window.location.href = `${url.pathname}?${url.searchParams.toString()}`
72+
}
5473
</script>
5574

5675
<style scoped>
5776
.container {
5877
max-width: 1200px;
5978
margin: 0 auto;
6079
padding: 2rem;
61-
font-family: system-ui, -apple-system, sans-serif;
80+
font-family:
81+
system-ui,
82+
-apple-system,
83+
sans-serif;
6284
}
6385
6486
h1 {
@@ -129,6 +151,10 @@ button.danger:hover {
129151
text-decoration: underline;
130152
}
131153
154+
.hint {
155+
margin-top: 0.75rem;
156+
}
157+
132158
.data {
133159
background: #f8f9fa;
134160
padding: 1.5rem;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineNuxtPlugin } from '#app'
2+
import { useNuxtUTM } from '#imports'
3+
4+
export default defineNuxtPlugin(() => {
5+
const utm = useNuxtUTM()
6+
7+
utm.onBeforePersist((data) => {
8+
const pageCategory = new URL(data.additionalInfo.landingPageUrl).searchParams.get('page_category')
9+
if (!pageCategory) return
10+
11+
data.customParams = {
12+
...data.customParams,
13+
pageCategory,
14+
}
15+
})
16+
})

src/module.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit'
1+
import { defineNuxtModule, addPlugin, addImports, addTypeTemplate, createResolver } from '@nuxt/kit'
22

33
export interface ModuleOptions {
44
trackingEnabled?: boolean
@@ -27,5 +27,21 @@ export default defineNuxtModule<ModuleOptions>({
2727
name: 'useNuxtUTM',
2828
from: resolver.resolve('runtime/composables'),
2929
})
30+
31+
addTypeTemplate({
32+
filename: 'types/utm-hooks.d.ts',
33+
getContents: () =>
34+
[
35+
'import type { DataObject, BeforeTrackContext } from "nuxt-utm"',
36+
'',
37+
'declare module "#app" {',
38+
' interface RuntimeNuxtHooks {',
39+
' "utm:before-track": (context: BeforeTrackContext) => void | Promise<void>',
40+
' "utm:before-persist": (data: DataObject) => void | Promise<void>',
41+
' "utm:tracked": (data: DataObject) => void | Promise<void>',
42+
' }',
43+
'}',
44+
].join('\n'),
45+
})
3046
},
3147
})

src/runtime/composables.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import type { Ref } from 'vue'
2-
import type { DataObject } from 'nuxt-utm'
2+
import type { DataObject, BeforeTrackContext } from 'nuxt-utm'
33
import { useNuxtApp } from '#imports'
44

5+
type HookCleanup = () => void
6+
57
export interface UseNuxtUTMReturn {
68
data: Readonly<Ref<readonly DataObject[]>>
79
trackingEnabled: Readonly<Ref<boolean>>
810
enableTracking: () => void
911
disableTracking: () => void
1012
clearData: () => void
13+
onBeforeTrack: (cb: (context: BeforeTrackContext) => void | Promise<void>) => HookCleanup
14+
onBeforePersist: (cb: (data: DataObject) => void | Promise<void>) => HookCleanup
15+
onTracked: (cb: (data: DataObject) => void | Promise<void>) => HookCleanup
1116
}
1217

1318
export const useNuxtUTM = (): UseNuxtUTMReturn => {
@@ -19,5 +24,8 @@ export const useNuxtUTM = (): UseNuxtUTMReturn => {
1924
enableTracking: nuxtApp.$utmEnableTracking,
2025
disableTracking: nuxtApp.$utmDisableTracking,
2126
clearData: nuxtApp.$utmClearData,
27+
onBeforeTrack: (cb) => nuxtApp.hook('utm:before-track', cb),
28+
onBeforePersist: (cb) => nuxtApp.hook('utm:before-persist', cb),
29+
onTracked: (cb) => nuxtApp.hook('utm:tracked', cb),
2230
}
2331
}

0 commit comments

Comments
 (0)