@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/README.md +96 -0
- package/dist/chunk-MX6WFWG4.js +60 -0
- package/dist/chunk-MX6WFWG4.js.map +1 -0
- package/dist/config-BPmCpvmt.d.ts +123 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/composable.test.ts +134 -0
- package/src/composable.ts +356 -0
- package/src/config.test.ts +105 -0
- package/src/config.ts +94 -0
- package/src/index.ts +27 -0
- package/src/types.ts +103 -0
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
|
+
})
|