@duffcloudservices/telemetry 0.1.0 → 0.2.0

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.
@@ -0,0 +1,135 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ captureJourneyFirstTouch,
5
+ getJourneyContext,
6
+ JOURNEY_FIELD_MAX_LENGTH,
7
+ JOURNEY_FIRST_TOUCH_KEY,
8
+ setJourneyIdentityResolver,
9
+ toJourneyTelemetryPayload,
10
+ } from './journey'
11
+
12
+ function clearCookies(): void {
13
+ for (const part of document.cookie.split(';')) {
14
+ const name = part.split('=')[0]?.trim()
15
+ if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
16
+ }
17
+ }
18
+
19
+ function navigateTo(url: string): void {
20
+ window.history.replaceState({}, '', url)
21
+ }
22
+
23
+ describe('journey', () => {
24
+ beforeEach(() => {
25
+ sessionStorage.clear()
26
+ clearCookies()
27
+ setJourneyIdentityResolver(null)
28
+ navigateTo('/')
29
+ })
30
+
31
+ afterEach(() => {
32
+ setJourneyIdentityResolver(null)
33
+ })
34
+
35
+ it('captures referrer, landing path and utm once per session', () => {
36
+ Object.defineProperty(document, 'referrer', {
37
+ value: 'https://www.google.com/',
38
+ configurable: true,
39
+ })
40
+ navigateTo('/compare/booksy-alternative?utm_source=google&utm_medium=organic')
41
+
42
+ captureJourneyFirstTouch()
43
+
44
+ const snapshot = JSON.parse(sessionStorage.getItem(JOURNEY_FIRST_TOUCH_KEY) ?? '{}')
45
+ expect(snapshot.referrer).toBe('https://www.google.com/')
46
+ expect(snapshot.landingPath).toBe(
47
+ '/compare/booksy-alternative?utm_source=google&utm_medium=organic',
48
+ )
49
+ expect(snapshot.utm).toEqual({ source: 'google', medium: 'organic' })
50
+ })
51
+
52
+ it('never overwrites the first-touch snapshot on a later page', () => {
53
+ Object.defineProperty(document, 'referrer', {
54
+ value: 'https://www.google.com/',
55
+ configurable: true,
56
+ })
57
+ navigateTo('/compare/booksy-alternative')
58
+ captureJourneyFirstTouch()
59
+
60
+ // The SPA navigates; document.referrer is gone by the time the form renders.
61
+ Object.defineProperty(document, 'referrer', { value: '', configurable: true })
62
+ navigateTo('/contact?plan=Professional')
63
+ captureJourneyFirstTouch()
64
+
65
+ const context = getJourneyContext()
66
+ expect(context.referrer).toBe('https://www.google.com/')
67
+ expect(context.landingPath).toBe('/compare/booksy-alternative')
68
+ // ...while the submit-time page is the contact page, not the landing page.
69
+ expect(context.pagePath).toBe('/contact')
70
+ })
71
+
72
+ it('falls back to the ai_user / ai_session cookies for identity', () => {
73
+ document.cookie = 'ai_user=oA7p7yUdBDbu0bXCR6WACW|2026-07-30T12:54:01.000Z; path=/'
74
+ document.cookie = 'ai_session=Yb3kZ|2026-07-30T12:54:01.000Z|2026-07-30T12:55:47.000Z; path=/'
75
+
76
+ const context = getJourneyContext()
77
+ expect(context.visitorId).toBe('oA7p7yUdBDbu0bXCR6WACW')
78
+ expect(context.sessionId).toBe('Yb3kZ')
79
+ })
80
+
81
+ it('prefers the live SDK identity over the cookies', () => {
82
+ document.cookie = 'ai_user=cookie-user|2026-07-30T12:54:01.000Z; path=/'
83
+ setJourneyIdentityResolver(() => ({ visitorId: 'sdk-user', sessionId: 'sdk-session' }))
84
+
85
+ const context = getJourneyContext()
86
+ expect(context.visitorId).toBe('sdk-user')
87
+ expect(context.sessionId).toBe('sdk-session')
88
+ })
89
+
90
+ it('survives a throwing identity resolver', () => {
91
+ document.cookie = 'ai_user=cookie-user|2026-07-30T12:54:01.000Z; path=/'
92
+ setJourneyIdentityResolver(() => {
93
+ throw new Error('SDK context unavailable')
94
+ })
95
+
96
+ expect(() => getJourneyContext()).not.toThrow()
97
+ expect(getJourneyContext().visitorId).toBe('cookie-user')
98
+ })
99
+
100
+ it('clamps every field to the server-side maximum', () => {
101
+ const long = 'x'.repeat(JOURNEY_FIELD_MAX_LENGTH + 50)
102
+ document.cookie = `ai_user=${long}|2026-07-30T12:54:01.000Z; path=/`
103
+
104
+ const context = getJourneyContext()
105
+ expect(context.visitorId).toHaveLength(JOURNEY_FIELD_MAX_LENGTH)
106
+ })
107
+
108
+ it('omits the payload entirely when there is nothing to report', () => {
109
+ Object.defineProperty(document, 'referrer', { value: '', configurable: true })
110
+ navigateTo('/')
111
+ sessionStorage.clear()
112
+
113
+ // A bare "/" landing with no referrer, no utm and no cookies still reports
114
+ // the submit-time page, which is legitimately useful.
115
+ const payload = toJourneyTelemetryPayload()
116
+ expect(payload?.pagePath).toBe('/')
117
+ expect(payload?.visitorId).toBeUndefined()
118
+
119
+ // ...but an empty context flattens to undefined so the caller omits the field.
120
+ expect(toJourneyTelemetryPayload({})).toBeUndefined()
121
+ })
122
+
123
+ it('flattens utm into the contract shape', () => {
124
+ const payload = toJourneyTelemetryPayload({
125
+ visitorId: 'v1',
126
+ utm: { source: 'google', medium: 'cpc', campaign: 'summer' },
127
+ })
128
+ expect(payload).toEqual({
129
+ visitorId: 'v1',
130
+ utmSource: 'google',
131
+ utmMedium: 'cpc',
132
+ utmCampaign: 'summer',
133
+ })
134
+ })
135
+ })
package/src/journey.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Visitor-journey context for form submissions.
3
+ *
4
+ * A submitted form row and the site's Application Insights telemetry describe
5
+ * the same visit, but nothing joins them: the row knows the message, the
6
+ * telemetry knows the path that produced it. `getJourneyContext()` returns the
7
+ * join key — the ids the SDK is already stamping on every event, plus the
8
+ * first-touch attribution the SPA throws away.
9
+ *
10
+ * WHY FIRST-TOUCH IS CAPTURED AT SESSION START, NOT AT SUBMIT. `document.referrer`
11
+ * is only populated for the document the browser actually loaded. The moment a
12
+ * client-side router takes over, it is gone — so a visitor who lands from Google
13
+ * on a comparison page and clicks through to /contact submits with an empty
14
+ * referrer. Capture-at-submit is not "less accurate" here; it is structurally
15
+ * always empty for the multi-page journeys worth attributing. So
16
+ * `captureJourneyFirstTouch()` snapshots the referrer, landing path, and utm_*
17
+ * parameters once per session into `sessionStorage`, and every later read serves
18
+ * that snapshot.
19
+ *
20
+ * This module deliberately imports nothing. It must stay usable by a consumer
21
+ * that has no App Insights SDK loaded (the portal runs its own hand-rolled
22
+ * telemetry boot), and it must never be the reason a form submission fails —
23
+ * every entry point swallows its own errors and degrades to an empty result.
24
+ */
25
+
26
+ /** First-touch UTM parameters parsed from the landing URL. */
27
+ export interface JourneyUtm {
28
+ source?: string
29
+ medium?: string
30
+ campaign?: string
31
+ term?: string
32
+ content?: string
33
+ }
34
+
35
+ /** The join key attached to an outbound form submission. */
36
+ export interface JourneyContext {
37
+ /** App Insights user id — the visitor, across sessions. */
38
+ visitorId?: string
39
+ /** App Insights session id — this visit. */
40
+ sessionId?: string
41
+ /** Route path the form was submitted from. */
42
+ pagePath?: string
43
+ /** Full URL the form was submitted from. */
44
+ pageUrl?: string
45
+ /** First-touch `document.referrer` for this session. */
46
+ referrer?: string
47
+ /** First-touch landing path (pathname + query) for this session. */
48
+ landingPath?: string
49
+ /** First-touch UTM parameters, when the landing URL carried any. */
50
+ utm?: JourneyUtm
51
+ }
52
+
53
+ /** Resolves visitor/session ids from a live telemetry SDK instance. */
54
+ export type JourneyIdentityResolver = () =>
55
+ | { visitorId?: string; sessionId?: string }
56
+ | undefined
57
+
58
+ /** sessionStorage key holding this session's first-touch snapshot. */
59
+ export const JOURNEY_FIRST_TOUCH_KEY = 'dcs:journey:first-touch'
60
+
61
+ /**
62
+ * Per-field clamp. Mirrors the server's `journey.FieldMaxLength` so a value that
63
+ * survives here is never silently truncated on the way into storage.
64
+ */
65
+ export const JOURNEY_FIELD_MAX_LENGTH = 512
66
+
67
+ interface FirstTouchSnapshot {
68
+ referrer?: string
69
+ landingPath?: string
70
+ utm?: JourneyUtm
71
+ }
72
+
73
+ let identityResolver: JourneyIdentityResolver | null = null
74
+
75
+ /**
76
+ * Register the live-SDK source for visitor/session ids.
77
+ *
78
+ * The composable calls this after `loadAppInsights()`. Without it — or when the
79
+ * resolver returns nothing — {@link getJourneyContext} falls back to parsing the
80
+ * `ai_user` / `ai_session` cookies the SDK writes, which is why the portal (with
81
+ * its own SDK boot and no call to this function) still reports usable ids.
82
+ */
83
+ export function setJourneyIdentityResolver(
84
+ resolver: JourneyIdentityResolver | null,
85
+ ): void {
86
+ identityResolver = resolver
87
+ }
88
+
89
+ function clamp(value: string | undefined | null): string | undefined {
90
+ if (typeof value !== 'string') return undefined
91
+ const trimmed = value.trim()
92
+ if (!trimmed) return undefined
93
+ return trimmed.length > JOURNEY_FIELD_MAX_LENGTH
94
+ ? trimmed.slice(0, JOURNEY_FIELD_MAX_LENGTH)
95
+ : trimmed
96
+ }
97
+
98
+ /**
99
+ * Read a cookie value, URL-decoded. Returns undefined when absent.
100
+ */
101
+ function readCookie(name: string): string | undefined {
102
+ if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
103
+ return undefined
104
+ }
105
+ const prefix = `${name}=`
106
+ for (const part of document.cookie.split(';')) {
107
+ const entry = part.trim()
108
+ if (!entry.startsWith(prefix)) continue
109
+ const raw = entry.slice(prefix.length)
110
+ try {
111
+ return decodeURIComponent(raw)
112
+ } catch {
113
+ return raw
114
+ }
115
+ }
116
+ return undefined
117
+ }
118
+
119
+ /**
120
+ * The App Insights cookies are pipe-delimited composites:
121
+ * `ai_user=<userId>|<acquisitionDate>` and
122
+ * `ai_session=<sessionId>|<acquisitionDate>|<renewalDate>`. Only the leading
123
+ * segment is the id.
124
+ */
125
+ function firstCookieSegment(name: string): string | undefined {
126
+ const value = readCookie(name)
127
+ if (!value) return undefined
128
+ return clamp(value.split('|')[0])
129
+ }
130
+
131
+ function parseUtm(params: URLSearchParams): JourneyUtm | undefined {
132
+ const utm: JourneyUtm = {}
133
+ const source = clamp(params.get('utm_source'))
134
+ const medium = clamp(params.get('utm_medium'))
135
+ const campaign = clamp(params.get('utm_campaign'))
136
+ const term = clamp(params.get('utm_term'))
137
+ const content = clamp(params.get('utm_content'))
138
+ if (source) utm.source = source
139
+ if (medium) utm.medium = medium
140
+ if (campaign) utm.campaign = campaign
141
+ if (term) utm.term = term
142
+ if (content) utm.content = content
143
+ return Object.keys(utm).length > 0 ? utm : undefined
144
+ }
145
+
146
+ function readFirstTouch(): FirstTouchSnapshot | undefined {
147
+ if (typeof sessionStorage === 'undefined') return undefined
148
+ try {
149
+ const raw = sessionStorage.getItem(JOURNEY_FIRST_TOUCH_KEY)
150
+ if (!raw) return undefined
151
+ const parsed = JSON.parse(raw) as FirstTouchSnapshot
152
+ return parsed && typeof parsed === 'object' ? parsed : undefined
153
+ } catch {
154
+ return undefined
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Snapshot this session's first-touch context, once.
160
+ *
161
+ * Idempotent and safe to call from anywhere: a snapshot already in
162
+ * sessionStorage always wins, so a later call on a deep page can never overwrite
163
+ * the real landing page with the current one. Called by the composable's
164
+ * `initialize()` (as early in the app's life as telemetry exists) and lazily by
165
+ * {@link getJourneyContext} so a consumer that never initializes the shared SDK
166
+ * still records *something* rather than nothing.
167
+ */
168
+ export function captureJourneyFirstTouch(): void {
169
+ try {
170
+ if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
171
+ return
172
+ }
173
+ if (sessionStorage.getItem(JOURNEY_FIRST_TOUCH_KEY)) return
174
+
175
+ const snapshot: FirstTouchSnapshot = {}
176
+ const referrer =
177
+ typeof document !== 'undefined' ? clamp(document.referrer) : undefined
178
+ if (referrer) snapshot.referrer = referrer
179
+
180
+ const landingPath = clamp(
181
+ `${window.location.pathname}${window.location.search}`,
182
+ )
183
+ if (landingPath) snapshot.landingPath = landingPath
184
+
185
+ const utm = parseUtm(new URLSearchParams(window.location.search))
186
+ if (utm) snapshot.utm = utm
187
+
188
+ sessionStorage.setItem(JOURNEY_FIRST_TOUCH_KEY, JSON.stringify(snapshot))
189
+ } catch {
190
+ // sessionStorage can throw under blocked-storage policies. Journey capture
191
+ // is an enrichment, never a requirement — stay silent and degrade.
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Build the journey context to attach to an outbound form submission.
197
+ *
198
+ * NEVER THROWS. Returns `{}` when there is nothing to report (SSR, blocked
199
+ * storage, telemetry-dark site), and callers omit the field entirely in that
200
+ * case — a telemetry failure must not cost a lead.
201
+ */
202
+ export function getJourneyContext(): JourneyContext {
203
+ try {
204
+ if (typeof window === 'undefined') return {}
205
+
206
+ captureJourneyFirstTouch()
207
+
208
+ const context: JourneyContext = {}
209
+
210
+ const identity = (() => {
211
+ try {
212
+ return identityResolver?.()
213
+ } catch {
214
+ return undefined
215
+ }
216
+ })()
217
+
218
+ const visitorId = clamp(identity?.visitorId) ?? firstCookieSegment('ai_user')
219
+ const sessionId =
220
+ clamp(identity?.sessionId) ?? firstCookieSegment('ai_session')
221
+ if (visitorId) context.visitorId = visitorId
222
+ if (sessionId) context.sessionId = sessionId
223
+
224
+ const pagePath = clamp(window.location.pathname)
225
+ const pageUrl = clamp(window.location.href)
226
+ if (pagePath) context.pagePath = pagePath
227
+ if (pageUrl) context.pageUrl = pageUrl
228
+
229
+ const firstTouch = readFirstTouch()
230
+ const referrer = clamp(firstTouch?.referrer)
231
+ const landingPath = clamp(firstTouch?.landingPath)
232
+ if (referrer) context.referrer = referrer
233
+ if (landingPath) context.landingPath = landingPath
234
+ if (firstTouch?.utm && Object.keys(firstTouch.utm).length > 0) {
235
+ context.utm = firstTouch.utm
236
+ }
237
+
238
+ return context
239
+ } catch {
240
+ return {}
241
+ }
242
+ }
243
+
244
+ /** The wire shape the DCS submission contracts accept (`SubmissionTelemetry`). */
245
+ export interface JourneyTelemetryPayload {
246
+ visitorId?: string
247
+ sessionId?: string
248
+ pagePath?: string
249
+ pageUrl?: string
250
+ referrer?: string
251
+ landingPath?: string
252
+ utmSource?: string
253
+ utmMedium?: string
254
+ utmCampaign?: string
255
+ utmTerm?: string
256
+ utmContent?: string
257
+ }
258
+
259
+ /**
260
+ * Flatten a {@link JourneyContext} into the contract's `telemetry` object, or
261
+ * return `undefined` when there is nothing worth sending so the caller can omit
262
+ * the field rather than post an empty object.
263
+ */
264
+ export function toJourneyTelemetryPayload(
265
+ context: JourneyContext = getJourneyContext(),
266
+ ): JourneyTelemetryPayload | undefined {
267
+ const payload: JourneyTelemetryPayload = {}
268
+ if (context.visitorId) payload.visitorId = context.visitorId
269
+ if (context.sessionId) payload.sessionId = context.sessionId
270
+ if (context.pagePath) payload.pagePath = context.pagePath
271
+ if (context.pageUrl) payload.pageUrl = context.pageUrl
272
+ if (context.referrer) payload.referrer = context.referrer
273
+ if (context.landingPath) payload.landingPath = context.landingPath
274
+ if (context.utm?.source) payload.utmSource = context.utm.source
275
+ if (context.utm?.medium) payload.utmMedium = context.utm.medium
276
+ if (context.utm?.campaign) payload.utmCampaign = context.utm.campaign
277
+ if (context.utm?.term) payload.utmTerm = context.utm.term
278
+ if (context.utm?.content) payload.utmContent = context.utm.content
279
+ return Object.keys(payload).length > 0 ? payload : undefined
280
+ }
package/src/types.ts CHANGED
@@ -18,6 +18,17 @@ export interface TelemetryPageView {
18
18
  uri?: string
19
19
  properties?: Record<string, string>
20
20
  measurements?: Record<string, number>
21
+ /**
22
+ * Send this page view even though the SDK's `enableAutoRouteTracking` is active.
23
+ *
24
+ * Off by default, and it should stay off: with auto-route tracking on, the SDK
25
+ * already emits one pageView per navigation, so a manual one double-counts the
26
+ * page. C-288 measured that exact configuration producing 52% duplicate page views
27
+ * on a live customer site, which the portal then reported to the owner. Set this
28
+ * only when you have verified auto-route tracking does not fire for your routing
29
+ * setup; prefer `useTelemetry({ enableAutoRouteTracking: false })` instead.
30
+ */
31
+ force?: boolean
21
32
  }
22
33
 
23
34
  export interface TelemetryException {