@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/package.json CHANGED
@@ -1,9 +1,29 @@
1
1
  {
2
2
  "name": "@duffcloudservices/telemetry",
3
- "version": "0.1.0",
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
+ }
@@ -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
- // it (config + track* calls) without loading the real SDK or sending telemetry.
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: vi.fn(),
32
- stopTrackPage: vi.fn(),
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
- appInsights = new ApplicationInsights({
137
- config: createTelemetryConfig({
138
- connectionString: options.connectionString,
139
- instrumentationKey: options.instrumentationKey,
140
- enableAutoRouteTracking: options.enableAutoRouteTracking,
141
- dev: options.dev,
142
- disablePageUnloadEvents: options.disablePageUnloadEvents,
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,