@duffcloudservices/telemetry 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -1
- package/dist/chunk-DKX4AC2U.js +131 -0
- package/dist/chunk-DKX4AC2U.js.map +1 -0
- package/dist/{config-BPmCpvmt.d.ts → config-F0cscN3Z.d.ts} +11 -0
- package/dist/config.d.ts +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +64 -9
- package/dist/index.js.map +1 -1
- package/dist/journey.d.ts +111 -0
- package/dist/journey.js +3 -0
- package/dist/journey.js.map +1 -0
- package/package.json +39 -11
- package/src/composable.test.ts +199 -3
- package/src/composable.ts +178 -9
- package/src/index.ts +16 -0
- package/src/journey.test.ts +135 -0
- package/src/journey.ts +280 -0
- package/src/types.ts +11 -0
package/package.json
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duffcloudservices/telemetry",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Shared Azure Application Insights telemetry for DCS Vue sites — one App Insights config (with the bfcache-safe unload fix) and a useTelemetry composable.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
|
+
"//exports": [
|
|
8
|
+
"WORKSPACE consumers resolve SOURCE; PUBLISHED consumers resolve dist (see publishConfig",
|
|
9
|
+
"below, which pnpm substitutes into the packed package.json at publish time).",
|
|
10
|
+
"",
|
|
11
|
+
"WHY. dist/ is git-ignored build output, so on any clean checkout it does not exist until",
|
|
12
|
+
"`pnpm run bootstrap` has run. An exports map that points only at ./dist/* therefore makes",
|
|
13
|
+
"every subpath UNRESOLVABLE on a fresh tree -- the failure is not a type error, it is vite",
|
|
14
|
+
"refusing the import outright: 'Failed to resolve import",
|
|
15
|
+
"\"@duffcloudservices/telemetry/journey\" from \"src/composables/useTelemetry.ts\"'. That is",
|
|
16
|
+
"what put `Portal / Admin vitest` red on main (board C-470): the Test Gate job builds the",
|
|
17
|
+
"contracts package but not this one, so 8 portal spec files died at import. Pointing the",
|
|
18
|
+
"workspace at src/ removes the ordering requirement instead of adding one more build step",
|
|
19
|
+
"for a future job to forget. It also matches what web/ already did unilaterally via a vite",
|
|
20
|
+
"alias + tsconfig path to packages/telemetry/src -- this generalises that to every consumer.",
|
|
21
|
+
"",
|
|
22
|
+
"SAFE because every consumer of the workspace copy (portal, web) is a vite/vitest app that",
|
|
23
|
+
"compiles TS anyway, and the SDK + vue stay peer deps resolved from the consumer either way.",
|
|
24
|
+
"The npm tarball is unaffected: publishConfig restores the dist-only map, which is verified",
|
|
25
|
+
"by `pnpm pack` inspection, and prepublishOnly still builds dist first."
|
|
26
|
+
],
|
|
7
27
|
"exports": {
|
|
8
28
|
".": {
|
|
9
29
|
"types": "./dist/index.d.ts",
|
|
@@ -12,6 +32,10 @@
|
|
|
12
32
|
"./config": {
|
|
13
33
|
"types": "./dist/config.d.ts",
|
|
14
34
|
"import": "./dist/config.js"
|
|
35
|
+
},
|
|
36
|
+
"./journey": {
|
|
37
|
+
"types": "./dist/journey.d.ts",
|
|
38
|
+
"import": "./dist/journey.js"
|
|
15
39
|
}
|
|
16
40
|
},
|
|
17
41
|
"main": "./dist/index.js",
|
|
@@ -21,15 +45,6 @@
|
|
|
21
45
|
"dist",
|
|
22
46
|
"src"
|
|
23
47
|
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"build": "tsup",
|
|
26
|
-
"dev": "tsup --watch",
|
|
27
|
-
"test": "vitest run",
|
|
28
|
-
"test:watch": "vitest",
|
|
29
|
-
"type-check": "tsc --noEmit",
|
|
30
|
-
"lint": "eslint src --ext .ts",
|
|
31
|
-
"prepublishOnly": "pnpm run build"
|
|
32
|
-
},
|
|
33
48
|
"peerDependencies": {
|
|
34
49
|
"@microsoft/applicationinsights-web": "^3.3.0",
|
|
35
50
|
"vue": "^3.4.0"
|
|
@@ -37,9 +52,13 @@
|
|
|
37
52
|
"devDependencies": {
|
|
38
53
|
"@microsoft/applicationinsights-web": "^3.3.10",
|
|
39
54
|
"@types/node": "^22.0.0",
|
|
55
|
+
"@vitest/eslint-plugin": "^1.3.4",
|
|
56
|
+
"eslint": "^9.31.0",
|
|
57
|
+
"jiti": "^2.4.2",
|
|
40
58
|
"jsdom": "^26.0.0",
|
|
41
59
|
"tsup": "^8.0.0",
|
|
42
60
|
"typescript": "~5.8.0",
|
|
61
|
+
"typescript-eslint": "^8.48.1",
|
|
43
62
|
"vitest": "^3.2.3",
|
|
44
63
|
"vue": "^3.5.18"
|
|
45
64
|
},
|
|
@@ -66,5 +85,14 @@
|
|
|
66
85
|
},
|
|
67
86
|
"publishConfig": {
|
|
68
87
|
"access": "public"
|
|
88
|
+
},
|
|
89
|
+
"scripts": {
|
|
90
|
+
"build": "tsup",
|
|
91
|
+
"dev": "tsup --watch",
|
|
92
|
+
"test": "vitest run",
|
|
93
|
+
"test:watch": "vitest",
|
|
94
|
+
"type-check": "tsc --noEmit",
|
|
95
|
+
"lint": "eslint .",
|
|
96
|
+
"lint:fix": "eslint . --fix"
|
|
69
97
|
}
|
|
70
|
-
}
|
|
98
|
+
}
|
package/src/composable.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
|
|
3
3
|
// Mock the App Insights web SDK so we can assert what the composable passes to
|
|
4
|
-
//
|
|
4
|
+
// the SDK (config + track* calls) without loading the real SDK or sending telemetry.
|
|
5
5
|
//
|
|
6
6
|
// NOTE: the `ApplicationInsights` mock implementation must PERSIST across the
|
|
7
7
|
// per-test `vi.resetModules()` — the composable's module-level singleton is
|
|
@@ -11,6 +11,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
11
11
|
// ApplicationInsights()` would yield a bare object). Mirrors web/'s test.
|
|
12
12
|
const trackEventMock = vi.fn()
|
|
13
13
|
const trackPageViewMock = vi.fn()
|
|
14
|
+
const startTrackPageMock = vi.fn()
|
|
15
|
+
const stopTrackPageMock = vi.fn()
|
|
14
16
|
const loadAppInsightsMock = vi.fn()
|
|
15
17
|
const addTelemetryInitializerMock = vi.fn()
|
|
16
18
|
const constructorConfigs: unknown[] = []
|
|
@@ -28,8 +30,8 @@ vi.mock('@microsoft/applicationinsights-web', () => {
|
|
|
28
30
|
trackDependencyData: vi.fn(),
|
|
29
31
|
trackMetric: vi.fn(),
|
|
30
32
|
flush: vi.fn(),
|
|
31
|
-
startTrackPage:
|
|
32
|
-
stopTrackPage:
|
|
33
|
+
startTrackPage: startTrackPageMock,
|
|
34
|
+
stopTrackPage: stopTrackPageMock,
|
|
33
35
|
}
|
|
34
36
|
}),
|
|
35
37
|
}
|
|
@@ -48,6 +50,8 @@ describe('useTelemetry', () => {
|
|
|
48
50
|
beforeEach(() => {
|
|
49
51
|
trackEventMock.mockReset()
|
|
50
52
|
trackPageViewMock.mockReset()
|
|
53
|
+
startTrackPageMock.mockReset()
|
|
54
|
+
stopTrackPageMock.mockReset()
|
|
51
55
|
loadAppInsightsMock.mockReset()
|
|
52
56
|
addTelemetryInitializerMock.mockReset()
|
|
53
57
|
warnSpy.mockClear()
|
|
@@ -131,4 +135,196 @@ describe('useTelemetry', () => {
|
|
|
131
135
|
t.trackEvent({ name: 'ping' })
|
|
132
136
|
expect(trackEventMock.mock.calls[0][0].properties.app_name).toBe('dcs-site')
|
|
133
137
|
})
|
|
138
|
+
// ── C-288 / C-291: page-view double-count guard ──────────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// The App Insights SDK's enableAutoRouteTracking already emits one pageView per
|
|
141
|
+
// navigation. A consumer that also calls trackPageView() on route change counts
|
|
142
|
+
// every page twice — measured on the live Just Posh site as 509 duplicates out of
|
|
143
|
+
// 971 human page views (52%), which the portal then reported to the owner.
|
|
144
|
+
|
|
145
|
+
it('skips trackPageView when the SDK is already auto-tracking routes', async () => {
|
|
146
|
+
const { useTelemetry } = await import('./composable')
|
|
147
|
+
const t = useTelemetry({
|
|
148
|
+
connectionString: CONNECTION_STRING,
|
|
149
|
+
enableAutoRouteTracking: true,
|
|
150
|
+
})
|
|
151
|
+
t.initialize()
|
|
152
|
+
|
|
153
|
+
t.trackPageView()
|
|
154
|
+
t.trackPageView({ name: 'Services' })
|
|
155
|
+
|
|
156
|
+
expect(trackPageViewMock).not.toHaveBeenCalled()
|
|
157
|
+
expect(
|
|
158
|
+
warnSpy.mock.calls.some((c) => String(c[0]).includes('double-counting')),
|
|
159
|
+
).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('warns about double-counting only once, not per navigation', async () => {
|
|
163
|
+
const { useTelemetry } = await import('./composable')
|
|
164
|
+
const t = useTelemetry({
|
|
165
|
+
connectionString: CONNECTION_STRING,
|
|
166
|
+
enableAutoRouteTracking: true,
|
|
167
|
+
})
|
|
168
|
+
t.initialize()
|
|
169
|
+
|
|
170
|
+
t.trackPageView()
|
|
171
|
+
t.trackPageView()
|
|
172
|
+
t.trackPageView()
|
|
173
|
+
|
|
174
|
+
expect(
|
|
175
|
+
warnSpy.mock.calls.filter((c) => String(c[0]).includes('double-counting')),
|
|
176
|
+
).toHaveLength(1)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('defaults to auto-route tracking, so an unconfigured consumer cannot double-count', async () => {
|
|
180
|
+
const { useTelemetry } = await import('./composable')
|
|
181
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
182
|
+
t.initialize()
|
|
183
|
+
|
|
184
|
+
t.trackPageView()
|
|
185
|
+
|
|
186
|
+
expect(trackPageViewMock).not.toHaveBeenCalled()
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('sends trackPageView when the consumer owns page views (auto-route off)', async () => {
|
|
190
|
+
const { useTelemetry } = await import('./composable')
|
|
191
|
+
const t = useTelemetry({
|
|
192
|
+
connectionString: CONNECTION_STRING,
|
|
193
|
+
enableAutoRouteTracking: false,
|
|
194
|
+
})
|
|
195
|
+
t.initialize()
|
|
196
|
+
|
|
197
|
+
t.trackPageView({ name: 'Services', uri: '/services' })
|
|
198
|
+
|
|
199
|
+
expect(trackPageViewMock).toHaveBeenCalledTimes(1)
|
|
200
|
+
expect(trackPageViewMock.mock.calls[0][0].name).toBe('Services')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('honours an explicit force override for a verified routing edge case', async () => {
|
|
204
|
+
const { useTelemetry } = await import('./composable')
|
|
205
|
+
const t = useTelemetry({
|
|
206
|
+
connectionString: CONNECTION_STRING,
|
|
207
|
+
enableAutoRouteTracking: true,
|
|
208
|
+
})
|
|
209
|
+
t.initialize()
|
|
210
|
+
|
|
211
|
+
t.trackPageView({ name: 'Hash route', force: true })
|
|
212
|
+
|
|
213
|
+
expect(trackPageViewMock).toHaveBeenCalledTimes(1)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// ── The same guard on the OTHER door (lead-journey-telemetry Phase 4) ────────
|
|
217
|
+
//
|
|
218
|
+
// stopTrackPage() emits a page view exactly as trackPageView() does, so a guard
|
|
219
|
+
// that covered only trackPageView left the C-288 double-count fully reachable.
|
|
220
|
+
// web/ walked through it: manual router timing + the `true` default, which live
|
|
221
|
+
// KQL caught writing THREE AppPageViews rows for one /contact navigation.
|
|
222
|
+
|
|
223
|
+
it('skips the manual page-timing pair when the SDK is auto-tracking routes', async () => {
|
|
224
|
+
const { useTelemetry } = await import('./composable')
|
|
225
|
+
const t = useTelemetry({
|
|
226
|
+
connectionString: CONNECTION_STRING,
|
|
227
|
+
enableAutoRouteTracking: true,
|
|
228
|
+
})
|
|
229
|
+
t.initialize()
|
|
230
|
+
|
|
231
|
+
t.startTrackPage('/contact')
|
|
232
|
+
t.stopTrackPage('/contact', 'https://example.com/contact')
|
|
233
|
+
|
|
234
|
+
expect(startTrackPageMock).not.toHaveBeenCalled()
|
|
235
|
+
expect(stopTrackPageMock).not.toHaveBeenCalled()
|
|
236
|
+
expect(
|
|
237
|
+
warnSpy.mock.calls.some((c) => String(c[0]).includes('double-counting')),
|
|
238
|
+
).toBe(true)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('sends the manual page-timing pair when the consumer owns page views', async () => {
|
|
242
|
+
const { useTelemetry } = await import('./composable')
|
|
243
|
+
const t = useTelemetry({
|
|
244
|
+
connectionString: CONNECTION_STRING,
|
|
245
|
+
enableAutoRouteTracking: false,
|
|
246
|
+
})
|
|
247
|
+
t.initialize()
|
|
248
|
+
|
|
249
|
+
t.startTrackPage('/contact')
|
|
250
|
+
t.stopTrackPage('/contact', 'https://example.com/contact', { routeName: 'contact' })
|
|
251
|
+
|
|
252
|
+
expect(startTrackPageMock).toHaveBeenCalledTimes(1)
|
|
253
|
+
expect(startTrackPageMock.mock.calls[0][0]).toBe('/contact')
|
|
254
|
+
expect(stopTrackPageMock).toHaveBeenCalledTimes(1)
|
|
255
|
+
expect(stopTrackPageMock.mock.calls[0][0]).toBe('/contact')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('skips both halves of the pair, never just the stop', async () => {
|
|
259
|
+
// Skipping only stopTrackPage would leave an unterminated page-timing entry
|
|
260
|
+
// inside the SDK for every navigation.
|
|
261
|
+
const { useTelemetry } = await import('./composable')
|
|
262
|
+
const t = useTelemetry({
|
|
263
|
+
connectionString: CONNECTION_STRING,
|
|
264
|
+
enableAutoRouteTracking: true,
|
|
265
|
+
})
|
|
266
|
+
t.initialize()
|
|
267
|
+
|
|
268
|
+
t.startTrackPage('/a')
|
|
269
|
+
t.stopTrackPage('/a')
|
|
270
|
+
t.startTrackPage('/b')
|
|
271
|
+
t.stopTrackPage('/b')
|
|
272
|
+
|
|
273
|
+
expect(startTrackPageMock).not.toHaveBeenCalled()
|
|
274
|
+
expect(stopTrackPageMock).not.toHaveBeenCalled()
|
|
275
|
+
// …and still only one warning for the whole session.
|
|
276
|
+
expect(
|
|
277
|
+
warnSpy.mock.calls.filter((c) => String(c[0]).includes('double-counting')),
|
|
278
|
+
).toHaveLength(1)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------
|
|
282
|
+
// C-326 — conversion capture handshake.
|
|
283
|
+
//
|
|
284
|
+
// The cms package auto-installs a delegated conversion listener that buffers
|
|
285
|
+
// clicks until a telemetry transport exists. This composable IS a transport,
|
|
286
|
+
// and it must say so on its own — the fleet's whole problem was that wiring
|
|
287
|
+
// this up was somebody's job and nobody did it.
|
|
288
|
+
// ---------------------------------------------------------------------
|
|
289
|
+
describe('conversion capture handshake', () => {
|
|
290
|
+
const conversionWindow = () =>
|
|
291
|
+
window as Window & { __dcsConversionAttach?: (sink: unknown) => number }
|
|
292
|
+
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
delete conversionWindow().__dcsConversionAttach
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('hands App Insights to the cms conversion tracker on initialize()', async () => {
|
|
298
|
+
const attach = vi.fn().mockReturnValue(1)
|
|
299
|
+
conversionWindow().__dcsConversionAttach = attach
|
|
300
|
+
|
|
301
|
+
const { useTelemetry } = await import('./composable')
|
|
302
|
+
useTelemetry({ connectionString: CONNECTION_STRING }).initialize()
|
|
303
|
+
|
|
304
|
+
expect(attach).toHaveBeenCalledTimes(1)
|
|
305
|
+
|
|
306
|
+
// And the thing handed over must actually reach the SDK.
|
|
307
|
+
const sink = attach.mock.calls[0][0] as (e: { name: string }) => void
|
|
308
|
+
sink({ name: 'site_interaction' })
|
|
309
|
+
expect(trackEventMock).toHaveBeenCalledTimes(1)
|
|
310
|
+
expect(trackEventMock.mock.calls[0][0].name).toBe('site_interaction')
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('is silent on a site with no cms conversion tracker', async () => {
|
|
314
|
+
const { useTelemetry } = await import('./composable')
|
|
315
|
+
const ok = useTelemetry({ connectionString: CONNECTION_STRING }).initialize()
|
|
316
|
+
|
|
317
|
+
expect(ok).toBe(true)
|
|
318
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('a throwing handshake never fails initialization', async () => {
|
|
322
|
+
conversionWindow().__dcsConversionAttach = () => {
|
|
323
|
+
throw new Error('cms is on fire')
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { useTelemetry } = await import('./composable')
|
|
327
|
+
expect(useTelemetry({ connectionString: CONNECTION_STRING }).initialize()).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
134
330
|
})
|
package/src/composable.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { ApplicationInsights } from '@microsoft/applicationinsights-web'
|
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
|
|
4
4
|
import { DEFAULT_CLOUD_ROLE, createTelemetryConfig } from './config'
|
|
5
|
+
import { captureJourneyFirstTouch, setJourneyIdentityResolver } from './journey'
|
|
5
6
|
import type {
|
|
6
7
|
TelemetryDependency,
|
|
7
8
|
TelemetryEvent,
|
|
@@ -34,6 +35,97 @@ const initializationError = ref<string | null>(null)
|
|
|
34
35
|
// no-op'ing every telemetry call; the loud warning makes that mode visible.
|
|
35
36
|
let warnedDisabled = false
|
|
36
37
|
let warnedNotInitialized = false
|
|
38
|
+
let warnedDoubleCount = false
|
|
39
|
+
|
|
40
|
+
// Whether the live instance was created with the SDK's own SPA route tracking on.
|
|
41
|
+
// When it is, the SDK already emits one pageView per navigation, so a consumer that
|
|
42
|
+
// ALSO calls trackPageView() on route change counts every page twice.
|
|
43
|
+
//
|
|
44
|
+
// This is not hypothetical (C-288, 2026-07-25): the Just Posh site ran both at once
|
|
45
|
+
// and 509 of its 971 human page views last month — 52% — were duplicates. It was the
|
|
46
|
+
// largest single correction in either owner report, and the portal dashboard was
|
|
47
|
+
// showing the uncorrected number to the customer. See
|
|
48
|
+
// .docs/analysis/owner-reports/2026-07-justposh.md §7.
|
|
49
|
+
let autoRouteTrackingActive = false
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hand this App Insights instance to the cms conversion tracker, if one is capturing.
|
|
53
|
+
*
|
|
54
|
+
* THE CONTRACT, AND WHY IT IS A GLOBAL. `@duffcloudservices/cms` auto-installs a delegated
|
|
55
|
+
* conversion listener (C-326) that starts capturing `tel:`/`sms:`/`mailto:`/booking/form
|
|
56
|
+
* interactions before any telemetry SDK exists, buffering them until a transport shows up.
|
|
57
|
+
* This package is a transport. Importing cms from here to say so would couple two packages
|
|
58
|
+
* that are versioned and published independently — and would break the one property the
|
|
59
|
+
* capture layer exists to have, which is that it never depends on a telemetry SDK.
|
|
60
|
+
*
|
|
61
|
+
* So the handshake is a single global function that cms publishes and anyone may call:
|
|
62
|
+
*
|
|
63
|
+
* ```js
|
|
64
|
+
* window.__dcsConversionAttach?.((event) => appInsights.trackEvent(event))
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* A site with a bespoke telemetry boot (Bryan's is a hand-rolled copy of this composable)
|
|
68
|
+
* adds exactly that line. Consumers of this package get it for free, here.
|
|
69
|
+
*
|
|
70
|
+
* Failing silently is correct: on a site without cms, or an older cms, the global is simply
|
|
71
|
+
* absent and there is nothing to drain.
|
|
72
|
+
*/
|
|
73
|
+
function attachConversionCapture(sink: (event: TelemetryEvent) => void): void {
|
|
74
|
+
try {
|
|
75
|
+
const w =
|
|
76
|
+
typeof window !== 'undefined'
|
|
77
|
+
? (window as Window & {
|
|
78
|
+
__dcsConversionAttach?: (s: (event: TelemetryEvent) => void) => number
|
|
79
|
+
})
|
|
80
|
+
: undefined
|
|
81
|
+
w?.__dcsConversionAttach?.(sink)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.warn('Failed to attach conversion capture to App Insights:', error)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the visitor/session ids off a live App Insights instance.
|
|
89
|
+
*
|
|
90
|
+
* The SDK's `context` shape is internal-ish and has moved between major
|
|
91
|
+
* versions, so this reads it structurally and returns undefined on any
|
|
92
|
+
* mismatch — {@link getJourneyContext} then falls back to the `ai_user` /
|
|
93
|
+
* `ai_session` cookies. Never throws.
|
|
94
|
+
*/
|
|
95
|
+
function readSdkIdentity(
|
|
96
|
+
instance: ApplicationInsights | null,
|
|
97
|
+
): { visitorId?: string; sessionId?: string } | undefined {
|
|
98
|
+
if (!instance) return undefined
|
|
99
|
+
try {
|
|
100
|
+
const context = (
|
|
101
|
+
instance as unknown as {
|
|
102
|
+
context?: {
|
|
103
|
+
user?: { id?: unknown }
|
|
104
|
+
getSessionId?: () => unknown
|
|
105
|
+
sessionManager?: { automaticSession?: { id?: unknown } }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
).context
|
|
109
|
+
if (!context) return undefined
|
|
110
|
+
|
|
111
|
+
const visitorId =
|
|
112
|
+
typeof context.user?.id === 'string' ? context.user.id : undefined
|
|
113
|
+
|
|
114
|
+
let sessionId: string | undefined
|
|
115
|
+
const fromGetter =
|
|
116
|
+
typeof context.getSessionId === 'function' ? context.getSessionId() : undefined
|
|
117
|
+
if (typeof fromGetter === 'string') {
|
|
118
|
+
sessionId = fromGetter
|
|
119
|
+
} else if (typeof context.sessionManager?.automaticSession?.id === 'string') {
|
|
120
|
+
sessionId = context.sessionManager.automaticSession.id
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!visitorId && !sessionId) return undefined
|
|
124
|
+
return { visitorId, sessionId }
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
37
129
|
|
|
38
130
|
function resolveAppVersion(explicit?: string): string {
|
|
39
131
|
if (explicit) return explicit
|
|
@@ -113,6 +205,14 @@ export function useTelemetry(options: TelemetryOptions = {}) {
|
|
|
113
205
|
|
|
114
206
|
const initialize = (): boolean => {
|
|
115
207
|
try {
|
|
208
|
+
// BEFORE the enabled check, and before anything can navigate. The SPA
|
|
209
|
+
// router destroys document.referrer on the first route change, so the
|
|
210
|
+
// landing referrer/utm has to be snapshotted here or it is gone. It is
|
|
211
|
+
// deliberately not conditional on telemetry being configured: a site with
|
|
212
|
+
// no connection string still benefits from first-touch attribution on its
|
|
213
|
+
// stored form submissions.
|
|
214
|
+
captureJourneyFirstTouch()
|
|
215
|
+
|
|
116
216
|
if (isInitialized.value && appInsights) {
|
|
117
217
|
return true
|
|
118
218
|
}
|
|
@@ -133,16 +233,19 @@ export function useTelemetry(options: TelemetryOptions = {}) {
|
|
|
133
233
|
return false
|
|
134
234
|
}
|
|
135
235
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
overrides: options.overrides,
|
|
144
|
-
}),
|
|
236
|
+
const resolvedConfig = createTelemetryConfig({
|
|
237
|
+
connectionString: options.connectionString,
|
|
238
|
+
instrumentationKey: options.instrumentationKey,
|
|
239
|
+
enableAutoRouteTracking: options.enableAutoRouteTracking,
|
|
240
|
+
dev: options.dev,
|
|
241
|
+
disablePageUnloadEvents: options.disablePageUnloadEvents,
|
|
242
|
+
overrides: options.overrides,
|
|
145
243
|
})
|
|
244
|
+
// Remember whether the SDK is counting page views for us, so trackPageView()
|
|
245
|
+
// can refuse to count them a second time.
|
|
246
|
+
autoRouteTrackingActive = resolvedConfig.enableAutoRouteTracking !== false
|
|
247
|
+
|
|
248
|
+
appInsights = new ApplicationInsights({ config: resolvedConfig })
|
|
146
249
|
|
|
147
250
|
appInsights.loadAppInsights()
|
|
148
251
|
appInsights.addTelemetryInitializer((envelope) => {
|
|
@@ -155,9 +258,19 @@ export function useTelemetry(options: TelemetryOptions = {}) {
|
|
|
155
258
|
return true
|
|
156
259
|
})
|
|
157
260
|
|
|
261
|
+
// Prefer the live SDK context over cookie parsing for the journey join
|
|
262
|
+
// key: the instance is authoritative, and it stays correct even if the
|
|
263
|
+
// cookies are blocked or renamed by a future SDK version.
|
|
264
|
+
setJourneyIdentityResolver(() => readSdkIdentity(appInsights))
|
|
265
|
+
|
|
158
266
|
isInitialized.value = true
|
|
159
267
|
initializationError.value = null
|
|
160
268
|
console.log('Application Insights initialized successfully')
|
|
269
|
+
|
|
270
|
+
// Drain any conversion clicks captured before this transport existed, and stay
|
|
271
|
+
// attached for the rest of the session. See attachConversionCapture().
|
|
272
|
+
attachConversionCapture(trackEvent)
|
|
273
|
+
|
|
161
274
|
return true
|
|
162
275
|
} catch (error) {
|
|
163
276
|
const errorMessage =
|
|
@@ -191,12 +304,39 @@ export function useTelemetry(options: TelemetryOptions = {}) {
|
|
|
191
304
|
}
|
|
192
305
|
}
|
|
193
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Send a page view.
|
|
309
|
+
*
|
|
310
|
+
* REFUSES TO SEND when the SDK's own `enableAutoRouteTracking` is active, because
|
|
311
|
+
* the SDK has already emitted a pageView for this navigation and sending another
|
|
312
|
+
* double-counts the page. Pass `{ force: true }` only when you have verified that
|
|
313
|
+
* auto-route tracking genuinely does not fire for your routing setup — and expect
|
|
314
|
+
* to justify it, because a page-view count that is silently 2x is the defect this
|
|
315
|
+
* guard exists to prevent (C-288: 52% of one site's page views were duplicates).
|
|
316
|
+
*
|
|
317
|
+
* If you want manual page views, turn the SDK's tracking off:
|
|
318
|
+
* `useTelemetry({ enableAutoRouteTracking: false })`. One tracker, one count.
|
|
319
|
+
*/
|
|
194
320
|
const trackPageView = (pageView?: TelemetryPageView) => {
|
|
195
321
|
if (!appInsights || !isInitialized.value) {
|
|
196
322
|
console.warn('Application Insights not initialized, skipping page view tracking')
|
|
197
323
|
return
|
|
198
324
|
}
|
|
199
325
|
|
|
326
|
+
if (autoRouteTrackingActive && !pageView?.force) {
|
|
327
|
+
if (!warnedDoubleCount) {
|
|
328
|
+
warnedDoubleCount = true
|
|
329
|
+
console.warn(
|
|
330
|
+
'[telemetry] trackPageView() SKIPPED to prevent double-counting: this ' +
|
|
331
|
+
'instance was initialized with enableAutoRouteTracking, so the App ' +
|
|
332
|
+
'Insights SDK already reports one page view per navigation. Pass ' +
|
|
333
|
+
'{ enableAutoRouteTracking: false } to useTelemetry() if you want to ' +
|
|
334
|
+
'report page views manually instead.',
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
200
340
|
try {
|
|
201
341
|
// Attach the full document.referrer (not just hostname) so we can
|
|
202
342
|
// attribute traffic sources in App Insights pageViews.
|
|
@@ -310,8 +450,36 @@ export function useTelemetry(options: TelemetryOptions = {}) {
|
|
|
310
450
|
}
|
|
311
451
|
}
|
|
312
452
|
|
|
453
|
+
/**
|
|
454
|
+
* The same guard `trackPageView` applies, on the other door into the page-view
|
|
455
|
+
* pipeline. `stopTrackPage` emits a page view just as surely as `trackPageView`
|
|
456
|
+
* does, so leaving it unguarded left the C-288 double-count fully reachable —
|
|
457
|
+
* and `web/` walked straight through it: the marketing site called the manual
|
|
458
|
+
* pair from its router while `enableAutoRouteTracking` kept its `true` default,
|
|
459
|
+
* and live KQL (2026-07-31) caught one /contact navigation writing THREE
|
|
460
|
+
* AppPageViews rows in 700ms. A guard on one entry point is not a guard.
|
|
461
|
+
*
|
|
462
|
+
* Both halves of the pair check this so they stay balanced: skipping only the
|
|
463
|
+
* stop would leave an unterminated page-timing entry inside the SDK.
|
|
464
|
+
*/
|
|
465
|
+
const manualPageTrackingIsRedundant = (): boolean => {
|
|
466
|
+
if (!autoRouteTrackingActive) return false
|
|
467
|
+
if (!warnedDoubleCount) {
|
|
468
|
+
warnedDoubleCount = true
|
|
469
|
+
console.warn(
|
|
470
|
+
'[telemetry] startTrackPage()/stopTrackPage() SKIPPED to prevent ' +
|
|
471
|
+
'double-counting: this instance was initialized with ' +
|
|
472
|
+
'enableAutoRouteTracking, so the App Insights SDK already reports a ' +
|
|
473
|
+
'page view per navigation. Pass { enableAutoRouteTracking: false } to ' +
|
|
474
|
+
'useTelemetry() if you want to time and report page views manually.',
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
return true
|
|
478
|
+
}
|
|
479
|
+
|
|
313
480
|
const startTrackPage = (name?: string) => {
|
|
314
481
|
if (!appInsights || !isInitialized.value) return
|
|
482
|
+
if (manualPageTrackingIsRedundant()) return
|
|
315
483
|
|
|
316
484
|
try {
|
|
317
485
|
appInsights.startTrackPage(name)
|
|
@@ -327,6 +495,7 @@ export function useTelemetry(options: TelemetryOptions = {}) {
|
|
|
327
495
|
measurements?: Record<string, number>,
|
|
328
496
|
) => {
|
|
329
497
|
if (!appInsights || !isInitialized.value) return
|
|
498
|
+
if (manualPageTrackingIsRedundant()) return
|
|
330
499
|
|
|
331
500
|
try {
|
|
332
501
|
appInsights.stopTrackPage(name, url, buildEventProperties(properties), measurements)
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* `disablePageUnloadEvents: ['unload']` bfcache/Lighthouse fix baked into the
|
|
9
9
|
* default. Pure (no SDK import); also available SDK-free at
|
|
10
10
|
* `@duffcloudservices/telemetry/config` for lazy/inert telemetry loaders.
|
|
11
|
+
* - {@link getJourneyContext} — the visitor-journey join key attached to form
|
|
12
|
+
* submissions. Pure (no SDK import); also available SDK-free at
|
|
13
|
+
* `@duffcloudservices/telemetry/journey` for consumers with their own
|
|
14
|
+
* telemetry boot.
|
|
11
15
|
*/
|
|
12
16
|
export { useTelemetry, type TelemetryApi } from './composable'
|
|
13
17
|
export {
|
|
@@ -15,6 +19,18 @@ export {
|
|
|
15
19
|
DEFAULT_CLOUD_ROLE,
|
|
16
20
|
DEFAULT_DISABLE_PAGE_UNLOAD_EVENTS,
|
|
17
21
|
} from './config'
|
|
22
|
+
export {
|
|
23
|
+
captureJourneyFirstTouch,
|
|
24
|
+
getJourneyContext,
|
|
25
|
+
setJourneyIdentityResolver,
|
|
26
|
+
toJourneyTelemetryPayload,
|
|
27
|
+
JOURNEY_FIELD_MAX_LENGTH,
|
|
28
|
+
JOURNEY_FIRST_TOUCH_KEY,
|
|
29
|
+
type JourneyContext,
|
|
30
|
+
type JourneyIdentityResolver,
|
|
31
|
+
type JourneyTelemetryPayload,
|
|
32
|
+
type JourneyUtm,
|
|
33
|
+
} from './journey'
|
|
18
34
|
export type {
|
|
19
35
|
ApplicationInsightsConfig,
|
|
20
36
|
TelemetryConfigOptions,
|