@duffcloudservices/telemetry 0.1.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.
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@duffcloudservices/telemetry",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "sideEffects": false,
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./config": {
13
+ "types": "./dist/config.d.ts",
14
+ "import": "./dist/config.js"
15
+ }
16
+ },
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
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
+ "peerDependencies": {
34
+ "@microsoft/applicationinsights-web": "^3.3.0",
35
+ "vue": "^3.4.0"
36
+ },
37
+ "devDependencies": {
38
+ "@microsoft/applicationinsights-web": "^3.3.10",
39
+ "@types/node": "^22.0.0",
40
+ "jsdom": "^26.0.0",
41
+ "tsup": "^8.0.0",
42
+ "typescript": "~5.8.0",
43
+ "vitest": "^3.2.3",
44
+ "vue": "^3.5.18"
45
+ },
46
+ "keywords": [
47
+ "vue",
48
+ "telemetry",
49
+ "application-insights",
50
+ "azure-monitor",
51
+ "dcs",
52
+ "duff-cloud-services"
53
+ ],
54
+ "author": "Duff Cloud Services",
55
+ "license": "MIT",
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "https://github.com/NateDuff/dcs"
59
+ },
60
+ "homepage": "https://portal.duffcloudservices.com",
61
+ "bugs": {
62
+ "url": "https://github.com/NateDuff/dcs/issues"
63
+ },
64
+ "engines": {
65
+ "node": ">=18.0.0"
66
+ },
67
+ "publishConfig": {
68
+ "access": "public"
69
+ }
70
+ }
@@ -0,0 +1,134 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
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.
5
+ //
6
+ // NOTE: the `ApplicationInsights` mock implementation must PERSIST across the
7
+ // per-test `vi.resetModules()` — the composable's module-level singleton is
8
+ // re-imported each test, but it re-imports this same mocked module. So we only
9
+ // ever `mockReset()`/`mockClear()` the *leaf* mocks below; we never restore the
10
+ // constructor mock (that would strip its implementation and every `new
11
+ // ApplicationInsights()` would yield a bare object). Mirrors web/'s test.
12
+ const trackEventMock = vi.fn()
13
+ const trackPageViewMock = vi.fn()
14
+ const loadAppInsightsMock = vi.fn()
15
+ const addTelemetryInitializerMock = vi.fn()
16
+ const constructorConfigs: unknown[] = []
17
+
18
+ vi.mock('@microsoft/applicationinsights-web', () => {
19
+ return {
20
+ ApplicationInsights: vi.fn().mockImplementation((arg: { config: unknown }) => {
21
+ constructorConfigs.push(arg.config)
22
+ return {
23
+ loadAppInsights: loadAppInsightsMock,
24
+ addTelemetryInitializer: addTelemetryInitializerMock,
25
+ trackEvent: trackEventMock,
26
+ trackPageView: trackPageViewMock,
27
+ trackException: vi.fn(),
28
+ trackDependencyData: vi.fn(),
29
+ trackMetric: vi.fn(),
30
+ flush: vi.fn(),
31
+ startTrackPage: vi.fn(),
32
+ stopTrackPage: vi.fn(),
33
+ }
34
+ }),
35
+ }
36
+ })
37
+
38
+ // Silence (and let us assert on) console noise without touching the SDK mock.
39
+ // Installed once, cleared per-test — never `restoreAllMocks`.
40
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
41
+ vi.spyOn(console, 'log').mockImplementation(() => {})
42
+ vi.spyOn(console, 'error').mockImplementation(() => {})
43
+
44
+ const CONNECTION_STRING =
45
+ 'InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://example.in.applicationinsights.azure.com/'
46
+
47
+ describe('useTelemetry', () => {
48
+ beforeEach(() => {
49
+ trackEventMock.mockReset()
50
+ trackPageViewMock.mockReset()
51
+ loadAppInsightsMock.mockReset()
52
+ addTelemetryInitializerMock.mockReset()
53
+ warnSpy.mockClear()
54
+ constructorConfigs.length = 0
55
+ // Reset modules so the singleton state inside composable.ts is recreated.
56
+ vi.resetModules()
57
+ })
58
+
59
+ it('warns loudly and no-ops when no connection string / key is provided', async () => {
60
+ const { useTelemetry } = await import('./composable')
61
+ const t = useTelemetry()
62
+
63
+ expect(t.isEnabled.value).toBe(false)
64
+ expect(t.initialize()).toBe(false)
65
+ expect(warnSpy).toHaveBeenCalledWith(
66
+ expect.stringContaining('Application Insights DISABLED'),
67
+ )
68
+
69
+ // Must not reach the SDK.
70
+ t.trackCtaClick('hero-cta', '/contact')
71
+ expect(trackEventMock).not.toHaveBeenCalled()
72
+ })
73
+
74
+ it('initializes synchronously and passes the BP-54 unload fix into the SDK config', async () => {
75
+ const { useTelemetry } = await import('./composable')
76
+ const t = useTelemetry({ connectionString: CONNECTION_STRING, cloudRole: 'dcs-web' })
77
+
78
+ expect(t.initialize()).toBe(true)
79
+ expect(t.isInitialized.value).toBe(true)
80
+ expect(loadAppInsightsMock).toHaveBeenCalledTimes(1)
81
+
82
+ // The config actually handed to `new ApplicationInsights({ config })` must
83
+ // disable the deprecated `unload` event — this is the whole point of the
84
+ // package (the fix reaches the runtime SDK, not just a helper's return).
85
+ expect(constructorConfigs).toHaveLength(1)
86
+ const config = constructorConfigs[0] as { disablePageUnloadEvents?: string[] }
87
+ expect(config.disablePageUnloadEvents).toEqual(['unload'])
88
+ })
89
+
90
+ it('flows consumer options through to event properties (cloudRole → app_name)', async () => {
91
+ const { useTelemetry } = await import('./composable')
92
+ const t = useTelemetry({
93
+ connectionString: CONNECTION_STRING,
94
+ cloudRole: 'dcs-web',
95
+ environment: 'production',
96
+ appVersion: '9.9.9',
97
+ })
98
+ t.initialize()
99
+
100
+ t.trackCtaClick('hero-cta', '/contact')
101
+
102
+ expect(trackEventMock).toHaveBeenCalledTimes(1)
103
+ const call = trackEventMock.mock.calls[0][0]
104
+ expect(call.name).toBe('cta_click')
105
+ expect(call.properties).toMatchObject({
106
+ label: 'hero-cta',
107
+ page: '/contact',
108
+ app_name: 'dcs-web',
109
+ app_version: '9.9.9',
110
+ environment: 'production',
111
+ })
112
+ })
113
+
114
+ it('trackCtaClick falls back to window.location.pathname when page omitted', async () => {
115
+ const { useTelemetry } = await import('./composable')
116
+ const t = useTelemetry({ connectionString: CONNECTION_STRING })
117
+ t.initialize()
118
+
119
+ t.trackCtaClick('footer-cta')
120
+
121
+ expect(trackEventMock).toHaveBeenCalledTimes(1)
122
+ expect(trackEventMock.mock.calls[0][0].properties.page).toBe(
123
+ window.location.pathname,
124
+ )
125
+ })
126
+
127
+ it('defaults cloudRole to dcs-site when unspecified', async () => {
128
+ const { useTelemetry } = await import('./composable')
129
+ const t = useTelemetry({ connectionString: CONNECTION_STRING })
130
+ t.initialize()
131
+ t.trackEvent({ name: 'ping' })
132
+ expect(trackEventMock.mock.calls[0][0].properties.app_name).toBe('dcs-site')
133
+ })
134
+ })
@@ -0,0 +1,356 @@
1
+ import { ApplicationInsights } from '@microsoft/applicationinsights-web'
2
+ import { computed, ref } from 'vue'
3
+
4
+ import { DEFAULT_CLOUD_ROLE, createTelemetryConfig } from './config'
5
+ import type {
6
+ TelemetryDependency,
7
+ TelemetryEvent,
8
+ TelemetryException,
9
+ TelemetryMetric,
10
+ TelemetryOptions,
11
+ TelemetryPageView,
12
+ } from './types'
13
+
14
+ interface TelemetryEnvelope {
15
+ data?: {
16
+ baseData?: {
17
+ properties?: Record<string, string>
18
+ }
19
+ }
20
+ tags?: Record<string, string>
21
+ }
22
+
23
+ // Module-level singleton state: one App Insights instance per app bundle,
24
+ // shared across every `useTelemetry()` call (matches the original per-app
25
+ // composable). The reactive refs let views observe init state.
26
+ let appInsights: ApplicationInsights | null = null
27
+ const isInitialized = ref(false)
28
+ const initializationError = ref<string | null>(null)
29
+
30
+ // One-shot guards so we surface "App Insights is not configured" loudly in
31
+ // DevTools the FIRST time the app tries to use it, but don't spam the console
32
+ // for every subsequent trackEvent / trackPageView call. See web/ site-audit F2
33
+ // — `customEvents = 0` for 7d was a missing connection string silently
34
+ // no-op'ing every telemetry call; the loud warning makes that mode visible.
35
+ let warnedDisabled = false
36
+ let warnedNotInitialized = false
37
+
38
+ function resolveAppVersion(explicit?: string): string {
39
+ if (explicit) return explicit
40
+ const w =
41
+ typeof window !== 'undefined'
42
+ ? (window as Window & { __APP_VERSION__?: string })
43
+ : undefined
44
+ return w?.__APP_VERSION__ ?? 'unknown'
45
+ }
46
+
47
+ /**
48
+ * Azure Application Insights composable for DCS Vue apps.
49
+ *
50
+ * The connection string, cloud role, environment and app version are supplied
51
+ * by the consumer (they read their own `import.meta.env` / build defines and
52
+ * pass plain values in) so this package stays portable and build-tool agnostic.
53
+ * The shared App Insights config — including the `disablePageUnloadEvents:
54
+ * ['unload']` bfcache/Lighthouse fix — comes from {@link createTelemetryConfig}.
55
+ */
56
+ export function useTelemetry(options: TelemetryOptions = {}) {
57
+ const cloudRole = options.cloudRole ?? DEFAULT_CLOUD_ROLE
58
+ const environment = options.environment ?? 'unknown'
59
+
60
+ const isEnabled = computed(() => {
61
+ return !!(options.connectionString || options.instrumentationKey)
62
+ })
63
+
64
+ function buildSharedProperties(): Record<string, string> {
65
+ const appVersion = resolveAppVersion(options.appVersion)
66
+ const referrerHost =
67
+ typeof document !== 'undefined' && document.referrer
68
+ ? (() => {
69
+ try {
70
+ return new URL(document.referrer).hostname
71
+ } catch {
72
+ return 'unknown'
73
+ }
74
+ })()
75
+ : 'direct'
76
+
77
+ return {
78
+ app_version: appVersion,
79
+ app_name: cloudRole,
80
+ app_host: typeof window !== 'undefined' ? window.location.hostname : '',
81
+ page_path: typeof window !== 'undefined' ? window.location.pathname : '',
82
+ referrer_host: referrerHost,
83
+ }
84
+ }
85
+
86
+ function buildEventProperties(
87
+ additional?: Record<string, string>,
88
+ ): Record<string, string> {
89
+ return {
90
+ timestamp: new Date().toISOString(),
91
+ environment,
92
+ ...buildSharedProperties(),
93
+ ...additional,
94
+ }
95
+ }
96
+
97
+ function enrichTelemetryEnvelope(envelope: TelemetryEnvelope) {
98
+ if (!envelope.data) {
99
+ envelope.data = {}
100
+ }
101
+
102
+ envelope.data.baseData = envelope.data.baseData || {}
103
+ envelope.data.baseData.properties = envelope.data.baseData.properties || {}
104
+ Object.assign(envelope.data.baseData.properties, buildSharedProperties())
105
+
106
+ const appVersion = resolveAppVersion(options.appVersion)
107
+ envelope.tags = envelope.tags || {}
108
+ envelope.tags['ai.application.ver'] = appVersion
109
+ envelope.tags['ai.cloud.role'] = cloudRole
110
+ envelope.tags['ai.cloud.roleInstance'] =
111
+ typeof window !== 'undefined' ? window.location.hostname : ''
112
+ }
113
+
114
+ const initialize = (): boolean => {
115
+ try {
116
+ if (isInitialized.value && appInsights) {
117
+ return true
118
+ }
119
+
120
+ if (!isEnabled.value) {
121
+ if (!warnedDisabled) {
122
+ warnedDisabled = true
123
+ // Loud, one-shot warning so this is obvious in DevTools. If you see
124
+ // this in production, the build pipeline is not injecting the App
125
+ // Insights connection string — every track* call below silently
126
+ // no-ops. See web/ site-audit F2.
127
+ console.warn(
128
+ '[telemetry] Application Insights DISABLED: no connection string ' +
129
+ 'or instrumentation key was provided at build time. ' +
130
+ 'All telemetry calls will silently no-op.',
131
+ )
132
+ }
133
+ return false
134
+ }
135
+
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
+ }),
145
+ })
146
+
147
+ appInsights.loadAppInsights()
148
+ appInsights.addTelemetryInitializer((envelope) => {
149
+ try {
150
+ enrichTelemetryEnvelope(envelope as TelemetryEnvelope)
151
+ } catch (error) {
152
+ console.warn('Failed to add web telemetry context:', error)
153
+ }
154
+
155
+ return true
156
+ })
157
+
158
+ isInitialized.value = true
159
+ initializationError.value = null
160
+ console.log('Application Insights initialized successfully')
161
+ return true
162
+ } catch (error) {
163
+ const errorMessage =
164
+ error instanceof Error ? error.message : 'Unknown initialization error'
165
+ initializationError.value = errorMessage
166
+ console.error('Failed to initialize Application Insights:', error)
167
+ return false
168
+ }
169
+ }
170
+
171
+ const trackEvent = (event: TelemetryEvent) => {
172
+ if (!appInsights || !isInitialized.value) {
173
+ if (!warnedNotInitialized) {
174
+ warnedNotInitialized = true
175
+ console.warn(
176
+ `[telemetry] Application Insights not initialized — dropping event "${event.name}" ` +
177
+ '(and silencing further "not initialized" warnings for this session).',
178
+ )
179
+ }
180
+ return
181
+ }
182
+
183
+ try {
184
+ appInsights.trackEvent({
185
+ name: event.name,
186
+ properties: buildEventProperties(event.properties),
187
+ measurements: event.measurements,
188
+ })
189
+ } catch (error) {
190
+ console.error('Failed to track event:', error)
191
+ }
192
+ }
193
+
194
+ const trackPageView = (pageView?: TelemetryPageView) => {
195
+ if (!appInsights || !isInitialized.value) {
196
+ console.warn('Application Insights not initialized, skipping page view tracking')
197
+ return
198
+ }
199
+
200
+ try {
201
+ // Attach the full document.referrer (not just hostname) so we can
202
+ // attribute traffic sources in App Insights pageViews.
203
+ const referrer =
204
+ typeof document !== 'undefined' ? (document.referrer ?? '') : ''
205
+ appInsights.trackPageView({
206
+ name:
207
+ pageView?.name ||
208
+ (typeof document !== 'undefined' ? document.title : undefined),
209
+ uri:
210
+ pageView?.uri ||
211
+ (typeof window !== 'undefined' ? window.location.href : undefined),
212
+ properties: buildEventProperties({
213
+ referrer,
214
+ ...pageView?.properties,
215
+ }),
216
+ measurements: pageView?.measurements,
217
+ })
218
+ } catch (error) {
219
+ console.error('Failed to track page view:', error)
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Convenience helper for primary CTA conversion measurement. Fires a
225
+ * `cta_click` custom event so conversion rate per landing page can be
226
+ * computed in App Insights / KQL.
227
+ */
228
+ const trackCtaClick = (label: string, page?: string) => {
229
+ trackEvent({
230
+ name: 'cta_click',
231
+ properties: {
232
+ label,
233
+ page:
234
+ page ??
235
+ (typeof window !== 'undefined' ? window.location.pathname : ''),
236
+ },
237
+ })
238
+ }
239
+
240
+ const trackException = (exception: TelemetryException) => {
241
+ if (!appInsights || !isInitialized.value) {
242
+ console.warn('Application Insights not initialized, skipping exception tracking')
243
+ return
244
+ }
245
+
246
+ try {
247
+ appInsights.trackException({
248
+ exception: exception.exception,
249
+ properties: buildEventProperties({
250
+ url: typeof window !== 'undefined' ? window.location.href : '',
251
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
252
+ ...exception.properties,
253
+ }),
254
+ measurements: exception.measurements,
255
+ })
256
+ } catch (error) {
257
+ console.error('Failed to track exception:', error)
258
+ }
259
+ }
260
+
261
+ const trackDependency = (dependency: TelemetryDependency) => {
262
+ if (!appInsights || !isInitialized.value) {
263
+ console.warn('Application Insights not initialized, skipping dependency tracking')
264
+ return
265
+ }
266
+
267
+ try {
268
+ appInsights.trackDependencyData({
269
+ id: `dep-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
270
+ name: dependency.name,
271
+ data: dependency.data,
272
+ duration: dependency.duration,
273
+ success: dependency.success,
274
+ responseCode: dependency.resultCode || 0,
275
+ properties: buildEventProperties(dependency.properties),
276
+ measurements: dependency.measurements,
277
+ })
278
+ } catch (error) {
279
+ console.error('Failed to track dependency:', error)
280
+ }
281
+ }
282
+
283
+ const trackMetric = (metric: TelemetryMetric) => {
284
+ if (!appInsights || !isInitialized.value) {
285
+ console.warn('Application Insights not initialized, skipping metric tracking')
286
+ return
287
+ }
288
+
289
+ try {
290
+ appInsights.trackMetric({
291
+ name: metric.name,
292
+ average: metric.average,
293
+ sampleCount: metric.sampleCount,
294
+ min: metric.min,
295
+ max: metric.max,
296
+ properties: buildEventProperties(metric.properties),
297
+ })
298
+ } catch (error) {
299
+ console.error('Failed to track metric:', error)
300
+ }
301
+ }
302
+
303
+ const flush = () => {
304
+ if (!appInsights || !isInitialized.value) return
305
+
306
+ try {
307
+ appInsights.flush()
308
+ } catch (error) {
309
+ console.error('Failed to flush telemetry:', error)
310
+ }
311
+ }
312
+
313
+ const startTrackPage = (name?: string) => {
314
+ if (!appInsights || !isInitialized.value) return
315
+
316
+ try {
317
+ appInsights.startTrackPage(name)
318
+ } catch (error) {
319
+ console.error('Failed to start track page:', error)
320
+ }
321
+ }
322
+
323
+ const stopTrackPage = (
324
+ name?: string,
325
+ url?: string,
326
+ properties?: Record<string, string>,
327
+ measurements?: Record<string, number>,
328
+ ) => {
329
+ if (!appInsights || !isInitialized.value) return
330
+
331
+ try {
332
+ appInsights.stopTrackPage(name, url, buildEventProperties(properties), measurements)
333
+ } catch (error) {
334
+ console.error('Failed to stop track page:', error)
335
+ }
336
+ }
337
+
338
+ return {
339
+ isEnabled,
340
+ isInitialized,
341
+ initializationError,
342
+ initialize,
343
+ flush,
344
+ startTrackPage,
345
+ stopTrackPage,
346
+ trackDependency,
347
+ trackEvent,
348
+ trackException,
349
+ trackMetric,
350
+ trackPageView,
351
+ trackCtaClick,
352
+ }
353
+ }
354
+
355
+ /** The full public API returned by {@link useTelemetry}. */
356
+ export type TelemetryApi = ReturnType<typeof useTelemetry>
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ DEFAULT_DISABLE_PAGE_UNLOAD_EVENTS,
5
+ createTelemetryConfig,
6
+ } from './config'
7
+
8
+ describe('createTelemetryConfig — BP-54 unload fix', () => {
9
+ it('the DEFAULT config disables the deprecated `unload` event', () => {
10
+ const config = createTelemetryConfig()
11
+ expect(config.disablePageUnloadEvents).toEqual(['unload'])
12
+ })
13
+
14
+ it('keeps pagehide / visibilitychange hooked (only `unload` is disabled)', () => {
15
+ const config = createTelemetryConfig({
16
+ connectionString: 'InstrumentationKey=abc',
17
+ })
18
+ const disabled = config.disablePageUnloadEvents ?? []
19
+ expect(disabled).toContain('unload')
20
+ // The supported flush events must NOT be in the disabled list.
21
+ expect(disabled).not.toContain('pagehide')
22
+ expect(disabled).not.toContain('visibilitychange')
23
+ expect(disabled).not.toContain('beforeunload')
24
+ })
25
+
26
+ it('exposes the default as a stable constant', () => {
27
+ expect(DEFAULT_DISABLE_PAGE_UNLOAD_EVENTS).toEqual(['unload'])
28
+ })
29
+
30
+ it('the default array is copied, not shared by reference (no cross-config mutation)', () => {
31
+ const a = createTelemetryConfig()
32
+ const b = createTelemetryConfig()
33
+ expect(a.disablePageUnloadEvents).not.toBe(b.disablePageUnloadEvents)
34
+ expect(a.disablePageUnloadEvents).not.toBe(DEFAULT_DISABLE_PAGE_UNLOAD_EVENTS)
35
+ })
36
+ })
37
+
38
+ describe('createTelemetryConfig — consumer options flow through', () => {
39
+ it('passes the connection string and instrumentation key through', () => {
40
+ const config = createTelemetryConfig({
41
+ connectionString: 'InstrumentationKey=00000000-0000-0000-0000-000000000000',
42
+ instrumentationKey: '00000000-0000-0000-0000-000000000000',
43
+ })
44
+ expect(config.connectionString).toBe(
45
+ 'InstrumentationKey=00000000-0000-0000-0000-000000000000',
46
+ )
47
+ expect(config.instrumentationKey).toBe(
48
+ '00000000-0000-0000-0000-000000000000',
49
+ )
50
+ })
51
+
52
+ it('defaults enableAutoRouteTracking to true but honors an explicit false', () => {
53
+ expect(createTelemetryConfig().enableAutoRouteTracking).toBe(true)
54
+ expect(
55
+ createTelemetryConfig({ enableAutoRouteTracking: false })
56
+ .enableAutoRouteTracking,
57
+ ).toBe(false)
58
+ })
59
+
60
+ it('dev mode lowers the SDK logging levels (1 vs 2)', () => {
61
+ const prod = createTelemetryConfig({ dev: false })
62
+ const dev = createTelemetryConfig({ dev: true })
63
+ expect(prod.loggingLevelConsole).toBe(2)
64
+ expect(prod.loggingLevelTelemetry).toBe(2)
65
+ expect(dev.loggingLevelConsole).toBe(1)
66
+ expect(dev.loggingLevelTelemetry).toBe(1)
67
+ })
68
+
69
+ it('lets a consumer override the disabled unload events', () => {
70
+ const config = createTelemetryConfig({
71
+ disablePageUnloadEvents: ['unload', 'beforeunload'],
72
+ })
73
+ expect(config.disablePageUnloadEvents).toEqual(['unload', 'beforeunload'])
74
+ })
75
+
76
+ it('applies `overrides` last, over the DCS defaults', () => {
77
+ const config = createTelemetryConfig({
78
+ overrides: {
79
+ samplingPercentage: 25,
80
+ correlationHeaderDomains: ['example.com'],
81
+ },
82
+ })
83
+ expect(config.samplingPercentage).toBe(25)
84
+ expect(config.correlationHeaderDomains).toEqual(['example.com'])
85
+ // Untouched defaults survive.
86
+ expect(config.disablePageUnloadEvents).toEqual(['unload'])
87
+ expect(config.distributedTracingMode).toBe(2)
88
+ })
89
+
90
+ it('overrides can NEVER re-enable the deprecated `unload` listener (BP-54 guard)', () => {
91
+ // A caller fat-fingering the unload policy via `overrides` must not regress the
92
+ // fleet-wide bfcache / Lighthouse fix — the fix is re-asserted after the spread.
93
+ const config = createTelemetryConfig({
94
+ overrides: { disablePageUnloadEvents: [] },
95
+ })
96
+ expect(config.disablePageUnloadEvents).toEqual(['unload'])
97
+ })
98
+
99
+ it('ships the DCS first-party correlation defaults', () => {
100
+ const config = createTelemetryConfig()
101
+ expect(config.correlationHeaderDomains).toContain('*.duffcloudservices.com')
102
+ expect(config.correlationHeaderExcludedDomains).toContain('*.stripe.com')
103
+ expect(config.samplingPercentage).toBe(100)
104
+ })
105
+ })