@duffcloudservices/telemetry 0.2.0 → 0.3.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 +49 -5
- package/dist/index.d.ts +65 -1
- package/dist/index.js +190 -65
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composable.test.ts +91 -16
- package/src/composable.ts +393 -80
- package/src/index.ts +7 -2
- package/src/lazySdk.test.ts +698 -0
- package/src/syntheticCapture.test.ts +74 -0
- package/src/syntheticCapture.ts +50 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C-601 — the LAZY App Insights SDK path.
|
|
3
|
+
*
|
|
4
|
+
* ## What this file exists to prevent
|
|
5
|
+
*
|
|
6
|
+
* Measured on the bryans-handyman-solutions pilot (2026-08-11): the sites reach the
|
|
7
|
+
* App Insights web SDK through a hand-rolled dynamic `import()`, which their
|
|
8
|
+
* bundler emits as a separate ~192KB raw / ~78KB gzip chunk, off the critical path
|
|
9
|
+
* behind a `requestIdleCallback`. Adopting `@duffcloudservices/telemetry@0.2.1` as
|
|
10
|
+
* published FOLDED that SDK into the site's entry chunk — +192,265B raw (+90.4%),
|
|
11
|
+
* +77,095B gzip (+101.2%) — of which the package's own code was only ~4.8KB. Total
|
|
12
|
+
* transferred JS barely moved; the SDK simply relocated onto the critical path and
|
|
13
|
+
* the sites' deliberate deferral was defeated.
|
|
14
|
+
*
|
|
15
|
+
* The cause was one line: a static, value-level
|
|
16
|
+
* `import { ApplicationInsights } from '@microsoft/applicationinsights-web'` in
|
|
17
|
+
* `composable.ts`. A single character (`import type`) reintroduces it, in a diff
|
|
18
|
+
* that looks like a tidy-up, and nothing in a unit test would notice — the package
|
|
19
|
+
* would keep working perfectly while every adopting site's LCP regressed.
|
|
20
|
+
*
|
|
21
|
+
* So laziness is pinned three ways here, at three different altitudes:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Source** — no source file may statically bind the SDK.
|
|
24
|
+
* 2. **Emit** — the JavaScript TypeScript actually produces must contain no static
|
|
25
|
+
* import of the SDK, and must contain the dynamic one (a positive control: it
|
|
26
|
+
* proves the assertion is looking at a real artifact).
|
|
27
|
+
* 3. **Runtime** — importing the package must not evaluate the SDK module at all;
|
|
28
|
+
* only `initialize()` may.
|
|
29
|
+
*
|
|
30
|
+
* The rest of the file pins the behaviour laziness buys and the behaviour it must
|
|
31
|
+
* not cost: buffered ordering across the load window, one init under concurrent
|
|
32
|
+
* callers, faithful page timing, and no unbounded buffer on a load that never lands.
|
|
33
|
+
*/
|
|
34
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
|
|
37
|
+
import ts from 'typescript'
|
|
38
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
39
|
+
|
|
40
|
+
const SDK_SPECIFIER = '@microsoft/applicationinsights-web'
|
|
41
|
+
const SRC_DIR = join(__dirname)
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// The controllable SDK module.
|
|
45
|
+
//
|
|
46
|
+
// `sdkLoadGate` lets a test hold the dynamic import OPEN for as long as it likes,
|
|
47
|
+
// which is the only way the pre-init window is observable at all — without it the
|
|
48
|
+
// import resolves a microtask later and there is nothing to measure.
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Flipped the instant the SDK module is evaluated. The eval-time guard reads it. */
|
|
52
|
+
let sdkModuleEvaluated = false
|
|
53
|
+
let sdkLoadGate: Promise<void> = Promise.resolve()
|
|
54
|
+
let releaseSdkLoad: () => void = () => {}
|
|
55
|
+
let sdkLoadShouldFail = false
|
|
56
|
+
|
|
57
|
+
/** Every SDK call, in the order the SDK received it — the ordering assertions read this. */
|
|
58
|
+
const sdkCallLog: string[] = []
|
|
59
|
+
const constructedConfigs: unknown[] = []
|
|
60
|
+
|
|
61
|
+
const loadAppInsightsMock = vi.fn()
|
|
62
|
+
const trackEventMock = vi.fn((e: { name: string }) => {
|
|
63
|
+
sdkCallLog.push(`trackEvent:${e.name}`)
|
|
64
|
+
})
|
|
65
|
+
const trackPageViewMock = vi.fn((p: { name?: string }) => {
|
|
66
|
+
sdkCallLog.push(`trackPageView:${p?.name ?? ''}`)
|
|
67
|
+
})
|
|
68
|
+
const trackExceptionMock = vi.fn((e: { exception: Error }) => {
|
|
69
|
+
sdkCallLog.push(`trackException:${e.exception.message}`)
|
|
70
|
+
})
|
|
71
|
+
const trackMetricMock = vi.fn((m: { name: string }) => {
|
|
72
|
+
sdkCallLog.push(`trackMetric:${m.name}`)
|
|
73
|
+
})
|
|
74
|
+
const trackDependencyDataMock = vi.fn((d: { name: string }) => {
|
|
75
|
+
sdkCallLog.push(`trackDependency:${d.name}`)
|
|
76
|
+
})
|
|
77
|
+
const startTrackPageMock = vi.fn()
|
|
78
|
+
const stopTrackPageMock = vi.fn()
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Register the mocked SDK module — `doMock`, deliberately, NOT the hoisted `vi.mock`.
|
|
82
|
+
*
|
|
83
|
+
* A hoisted `vi.mock` factory is invoked ONCE for the whole file and its result is
|
|
84
|
+
* cached across `vi.resetModules()` (`composable.test.ts` documents relying on
|
|
85
|
+
* exactly that). That caching silently destroys every test in this file that needs
|
|
86
|
+
* an observable load window: the gate would be awaited only by the first test to
|
|
87
|
+
* import the SDK, and from then on the "lazy" import would resolve instantly. The
|
|
88
|
+
* specs still passed individually and lied in a suite — buffering, ordering, the
|
|
89
|
+
* buffer bound and the failed-load path all measured a load window that no longer
|
|
90
|
+
* existed. `doMock` re-runs the factory after each `resetModules`, so every test
|
|
91
|
+
* gets a real, independently controllable load.
|
|
92
|
+
*/
|
|
93
|
+
function installSdkMock(): void {
|
|
94
|
+
vi.doMock(SDK_SPECIFIER, async () => {
|
|
95
|
+
sdkModuleEvaluated = true
|
|
96
|
+
await sdkLoadGate
|
|
97
|
+
if (sdkLoadShouldFail) {
|
|
98
|
+
throw new Error('SDK chunk failed to load')
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
ApplicationInsights: vi.fn().mockImplementation((arg: { config: unknown }) => {
|
|
102
|
+
constructedConfigs.push(arg.config)
|
|
103
|
+
return {
|
|
104
|
+
loadAppInsights: loadAppInsightsMock,
|
|
105
|
+
addTelemetryInitializer: vi.fn(),
|
|
106
|
+
trackEvent: trackEventMock,
|
|
107
|
+
trackPageView: trackPageViewMock,
|
|
108
|
+
trackException: trackExceptionMock,
|
|
109
|
+
trackMetric: trackMetricMock,
|
|
110
|
+
trackDependencyData: trackDependencyDataMock,
|
|
111
|
+
startTrackPage: startTrackPageMock,
|
|
112
|
+
stopTrackPage: stopTrackPageMock,
|
|
113
|
+
flush: vi.fn(),
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
121
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
122
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
123
|
+
|
|
124
|
+
const CONNECTION_STRING =
|
|
125
|
+
'InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://example.in.applicationinsights.azure.com/'
|
|
126
|
+
|
|
127
|
+
/** Hold the SDK import open until the test says otherwise. */
|
|
128
|
+
function holdSdkLoad(): void {
|
|
129
|
+
sdkLoadGate = new Promise<void>((resolve) => {
|
|
130
|
+
releaseSdkLoad = resolve
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Yield to the macrotask queue so the dynamic import gets a chance to start. */
|
|
135
|
+
function tick(ms = 0): Promise<void> {
|
|
136
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
sdkModuleEvaluated = false
|
|
141
|
+
sdkLoadShouldFail = false
|
|
142
|
+
sdkLoadGate = Promise.resolve()
|
|
143
|
+
releaseSdkLoad = () => {}
|
|
144
|
+
sdkCallLog.length = 0
|
|
145
|
+
constructedConfigs.length = 0
|
|
146
|
+
loadAppInsightsMock.mockClear()
|
|
147
|
+
trackEventMock.mockClear()
|
|
148
|
+
trackPageViewMock.mockClear()
|
|
149
|
+
trackExceptionMock.mockClear()
|
|
150
|
+
trackMetricMock.mockClear()
|
|
151
|
+
trackDependencyDataMock.mockClear()
|
|
152
|
+
startTrackPageMock.mockClear()
|
|
153
|
+
stopTrackPageMock.mockClear()
|
|
154
|
+
warnSpy.mockClear()
|
|
155
|
+
errorSpy.mockClear()
|
|
156
|
+
// Recreate the composable's module-level singleton state for each test, then
|
|
157
|
+
// re-register the SDK mock so its factory (and therefore the load gate) is fresh.
|
|
158
|
+
vi.resetModules()
|
|
159
|
+
installSdkMock()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ===========================================================================
|
|
163
|
+
// 1 + 2. The SDK must not be reachable statically — in source, or in the emit.
|
|
164
|
+
// ===========================================================================
|
|
165
|
+
|
|
166
|
+
describe('no static SDK import (the C-601 regression guard)', () => {
|
|
167
|
+
const sourceFiles = readdirSync(SRC_DIR)
|
|
168
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
|
169
|
+
.sort()
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Static bindings of the SDK: `import … from 'SDK'`, `export … from 'SDK'`,
|
|
173
|
+
* a bare side-effect `import 'SDK'`, or `require('SDK')`.
|
|
174
|
+
*
|
|
175
|
+
* `import type` / `export type` are allowed — TypeScript erases them, so they
|
|
176
|
+
* cost zero runtime bytes, and the composable needs the `ApplicationInsights`
|
|
177
|
+
* type to describe its own singleton.
|
|
178
|
+
*
|
|
179
|
+
* Deliberately strict about WHERE `type` may appear: only the top-level
|
|
180
|
+
* `import type` / `export type` form passes. Inline `import { type X } from 'SDK'`
|
|
181
|
+
* is flagged even though today's tsconfig would elide it, because whether it is
|
|
182
|
+
* elided depends on compiler settings (`verbatimModuleSyntax`,
|
|
183
|
+
* `importsNotUsedAsValues`) that a future config change could flip underneath us.
|
|
184
|
+
* The cost of the strictness is one keyword; the cost of being wrong is +192KB on
|
|
185
|
+
* every adopting site's entry chunk.
|
|
186
|
+
*/
|
|
187
|
+
const escaped = SDK_SPECIFIER.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&')
|
|
188
|
+
const staticBinding = new RegExp(
|
|
189
|
+
String.raw`(?:^|[\s;])(import|export)\s+(type\s+)?[^;]*?\bfrom\s*['"]${escaped}['"]`,
|
|
190
|
+
'g',
|
|
191
|
+
)
|
|
192
|
+
const bareImport = new RegExp(String.raw`(?:^|[\s;])import\s*['"]${escaped}['"]`, 'g')
|
|
193
|
+
const requireCall = new RegExp(
|
|
194
|
+
String.raw`\brequire\s*\(\s*['"]${escaped}['"]\s*\)`,
|
|
195
|
+
'g',
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Drop comments before scanning.
|
|
200
|
+
*
|
|
201
|
+
* Not cosmetic — this bit for real. `staticBinding` deliberately spans newlines so
|
|
202
|
+
* a multi-line `import {\n ApplicationInsights,\n} from 'SDK'` cannot hide from
|
|
203
|
+
* it, which also means the prose in `composable.ts`'s own header comment (which
|
|
204
|
+
* says the word "import" a few lines above the real `import type` line) matched as
|
|
205
|
+
* a violation. A guard that reds on its own explanatory comment gets deleted, not
|
|
206
|
+
* fixed.
|
|
207
|
+
*/
|
|
208
|
+
function stripComments(source: string): string {
|
|
209
|
+
return source
|
|
210
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
211
|
+
.replace(/(^|[^:])\/\/[^\n]*/gm, '$1')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function staticSdkBindingsIn(rawSource: string): string[] {
|
|
215
|
+
const source = stripComments(rawSource)
|
|
216
|
+
const violations: string[] = []
|
|
217
|
+
for (const match of source.matchAll(staticBinding)) {
|
|
218
|
+
const isTypeOnly = match[2] !== undefined
|
|
219
|
+
if (!isTypeOnly) violations.push(match[0].trim())
|
|
220
|
+
}
|
|
221
|
+
for (const match of source.matchAll(bareImport)) violations.push(match[0].trim())
|
|
222
|
+
for (const match of source.matchAll(requireCall)) violations.push(match[0].trim())
|
|
223
|
+
return violations
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
it('scans a non-trivial set of source files (guards the guard)', () => {
|
|
227
|
+
// A glob that silently matches nothing would make every assertion below vacuous.
|
|
228
|
+
expect(sourceFiles).toContain('composable.ts')
|
|
229
|
+
expect(sourceFiles).toContain('index.ts')
|
|
230
|
+
expect(sourceFiles.length).toBeGreaterThanOrEqual(5)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('detects a static SDK import when one is present (kill-test)', () => {
|
|
234
|
+
// The positive control. If this matcher cannot see a real static import, the
|
|
235
|
+
// per-file assertion below proves nothing at all.
|
|
236
|
+
expect(
|
|
237
|
+
staticSdkBindingsIn(`import { ApplicationInsights } from '${SDK_SPECIFIER}'\n`),
|
|
238
|
+
).toHaveLength(1)
|
|
239
|
+
expect(staticSdkBindingsIn(`import '${SDK_SPECIFIER}'\n`)).toHaveLength(1)
|
|
240
|
+
expect(
|
|
241
|
+
staticSdkBindingsIn(`export { ApplicationInsights } from '${SDK_SPECIFIER}'\n`),
|
|
242
|
+
).toHaveLength(1)
|
|
243
|
+
expect(
|
|
244
|
+
staticSdkBindingsIn(`const m = require('${SDK_SPECIFIER}')\n`),
|
|
245
|
+
).toHaveLength(1)
|
|
246
|
+
// A multi-line value import is the realistic shape of the regression — a
|
|
247
|
+
// formatter reflows the line the moment a second named export is added.
|
|
248
|
+
expect(
|
|
249
|
+
staticSdkBindingsIn(
|
|
250
|
+
`import {\n ApplicationInsights,\n type IConfig,\n} from '${SDK_SPECIFIER}'\n`,
|
|
251
|
+
),
|
|
252
|
+
).toHaveLength(1)
|
|
253
|
+
|
|
254
|
+
// …and must NOT flag the forms that cost no runtime bytes.
|
|
255
|
+
expect(
|
|
256
|
+
staticSdkBindingsIn(`import type { ApplicationInsights } from '${SDK_SPECIFIER}'\n`),
|
|
257
|
+
).toHaveLength(0)
|
|
258
|
+
expect(
|
|
259
|
+
staticSdkBindingsIn(`const m = await import('${SDK_SPECIFIER}')\n`),
|
|
260
|
+
).toHaveLength(0)
|
|
261
|
+
// Prose about importing is not an import.
|
|
262
|
+
expect(
|
|
263
|
+
staticSdkBindingsIn(
|
|
264
|
+
`// A static import of the SDK here would fold it into the entry chunk.\n` +
|
|
265
|
+
`import type { ApplicationInsights } from '${SDK_SPECIFIER}'\n`,
|
|
266
|
+
),
|
|
267
|
+
).toHaveLength(0)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it.each(readdirSync(SRC_DIR).filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts')))(
|
|
271
|
+
'src/%s binds the SDK only as a type',
|
|
272
|
+
(file) => {
|
|
273
|
+
const source = readFileSync(join(SRC_DIR, file), 'utf8')
|
|
274
|
+
expect(staticSdkBindingsIn(source)).toEqual([])
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
it('emits no static SDK import, and does emit the dynamic one', () => {
|
|
279
|
+
// One altitude down from the source scan: this is the JavaScript the bundler
|
|
280
|
+
// consumes. `import type` is gone; `import()` survives and becomes the async
|
|
281
|
+
// chunk boundary.
|
|
282
|
+
const source = readFileSync(join(SRC_DIR, 'composable.ts'), 'utf8')
|
|
283
|
+
const emitted = ts.transpileModule(source, {
|
|
284
|
+
compilerOptions: {
|
|
285
|
+
module: ts.ModuleKind.ESNext,
|
|
286
|
+
target: ts.ScriptTarget.ES2022,
|
|
287
|
+
},
|
|
288
|
+
}).outputText
|
|
289
|
+
|
|
290
|
+
expect(emitted).not.toMatch(new RegExp(String.raw`\bfrom\s*['"]${escaped}['"]`))
|
|
291
|
+
// Positive control: the dynamic import must be there, or the assertion above
|
|
292
|
+
// would also pass on a file that simply never mentions the SDK.
|
|
293
|
+
expect(emitted).toMatch(new RegExp(String.raw`import\(\s*['"]${escaped}['"]`))
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// ===========================================================================
|
|
298
|
+
// 3. Runtime: importing the package must not evaluate the SDK.
|
|
299
|
+
// ===========================================================================
|
|
300
|
+
|
|
301
|
+
describe('module-eval-time laziness', () => {
|
|
302
|
+
it('importing the composable does not evaluate the SDK module', async () => {
|
|
303
|
+
await import('./composable')
|
|
304
|
+
expect(sdkModuleEvaluated).toBe(false)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('importing the package entry point does not evaluate the SDK module', async () => {
|
|
308
|
+
// The entry point re-exports the composable, so this is the shape a consumer
|
|
309
|
+
// actually gets — and the one whose entry chunk the SDK was landing on.
|
|
310
|
+
await import('./index')
|
|
311
|
+
expect(sdkModuleEvaluated).toBe(false)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('only initialize() reaches the SDK (positive control)', async () => {
|
|
315
|
+
// Without this the test above passes on a broken build too — e.g. if the mock
|
|
316
|
+
// were never wired up, `sdkModuleEvaluated` would be false forever.
|
|
317
|
+
const { useTelemetry } = await import('./composable')
|
|
318
|
+
expect(sdkModuleEvaluated).toBe(false)
|
|
319
|
+
|
|
320
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
321
|
+
t.initialize()
|
|
322
|
+
await t.whenReady()
|
|
323
|
+
|
|
324
|
+
expect(sdkModuleEvaluated).toBe(true)
|
|
325
|
+
expect(constructedConfigs).toHaveLength(1)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('a disabled site never loads the SDK chunk at all', async () => {
|
|
329
|
+
const { useTelemetry } = await import('./composable')
|
|
330
|
+
const t = useTelemetry()
|
|
331
|
+
|
|
332
|
+
expect(t.initialize()).toBe(false)
|
|
333
|
+
await tick()
|
|
334
|
+
|
|
335
|
+
expect(sdkModuleEvaluated).toBe(false)
|
|
336
|
+
expect(constructedConfigs).toHaveLength(0)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// ===========================================================================
|
|
341
|
+
// The load window: buffering and ordering.
|
|
342
|
+
// ===========================================================================
|
|
343
|
+
|
|
344
|
+
describe('calls made while the SDK chunk is in flight', () => {
|
|
345
|
+
it('buffers an event fired before the lazy import resolves, then sends it', async () => {
|
|
346
|
+
holdSdkLoad()
|
|
347
|
+
const { useTelemetry } = await import('./composable')
|
|
348
|
+
const t = useTelemetry({
|
|
349
|
+
connectionString: CONNECTION_STRING,
|
|
350
|
+
enableAutoRouteTracking: false,
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
expect(t.initialize()).toBe(true)
|
|
354
|
+
await tick()
|
|
355
|
+
|
|
356
|
+
// The window is genuinely open: the SDK module is entered and blocked.
|
|
357
|
+
expect(sdkModuleEvaluated).toBe(true)
|
|
358
|
+
expect(t.isInitialized.value).toBe(false)
|
|
359
|
+
|
|
360
|
+
t.trackEvent({ name: 'early_event' })
|
|
361
|
+
// Nothing can have been sent — there is no SDK yet.
|
|
362
|
+
expect(trackEventMock).not.toHaveBeenCalled()
|
|
363
|
+
// …and it must not have been written off as "not initialized" either.
|
|
364
|
+
expect(
|
|
365
|
+
warnSpy.mock.calls.some((c) => String(c[0]).includes('not initialized')),
|
|
366
|
+
).toBe(false)
|
|
367
|
+
|
|
368
|
+
releaseSdkLoad()
|
|
369
|
+
await t.whenReady()
|
|
370
|
+
|
|
371
|
+
expect(t.isInitialized.value).toBe(true)
|
|
372
|
+
expect(trackEventMock).toHaveBeenCalledTimes(1)
|
|
373
|
+
expect(trackEventMock.mock.calls[0][0].name).toBe('early_event')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it('stamps buffered events with their CALL time, not the drain time', async () => {
|
|
377
|
+
// The whole value of a buffer is that the event still describes the moment it
|
|
378
|
+
// happened. A payload built at drain time would report the page the visitor had
|
|
379
|
+
// navigated to by then, and a timestamp minutes late on a slow connection.
|
|
380
|
+
holdSdkLoad()
|
|
381
|
+
const { useTelemetry } = await import('./composable')
|
|
382
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
383
|
+
|
|
384
|
+
t.initialize()
|
|
385
|
+
await tick()
|
|
386
|
+
t.trackEvent({ name: 'early_event', properties: { page_at_call: '/landing' } })
|
|
387
|
+
const afterCall = new Date().toISOString()
|
|
388
|
+
|
|
389
|
+
await tick(25)
|
|
390
|
+
releaseSdkLoad()
|
|
391
|
+
await t.whenReady()
|
|
392
|
+
|
|
393
|
+
const properties = trackEventMock.mock.calls[0][0].properties as Record<string, string>
|
|
394
|
+
expect(properties.page_at_call).toBe('/landing')
|
|
395
|
+
expect(properties.timestamp <= afterCall).toBe(true)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('replays buffered calls in the order they were made', async () => {
|
|
399
|
+
holdSdkLoad()
|
|
400
|
+
const { useTelemetry } = await import('./composable')
|
|
401
|
+
const t = useTelemetry({
|
|
402
|
+
connectionString: CONNECTION_STRING,
|
|
403
|
+
enableAutoRouteTracking: false,
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
t.initialize()
|
|
407
|
+
await tick()
|
|
408
|
+
|
|
409
|
+
t.trackEvent({ name: 'first' })
|
|
410
|
+
t.trackPageView({ name: 'Landing' })
|
|
411
|
+
t.trackException({ exception: new Error('boom') })
|
|
412
|
+
t.trackMetric({ name: 'lcp', average: 1200 })
|
|
413
|
+
t.trackDependency({ name: 'api', data: '/api/x', duration: 12, success: true })
|
|
414
|
+
t.trackEvent({ name: 'last' })
|
|
415
|
+
|
|
416
|
+
expect(sdkCallLog).toEqual([])
|
|
417
|
+
|
|
418
|
+
releaseSdkLoad()
|
|
419
|
+
await t.whenReady()
|
|
420
|
+
|
|
421
|
+
expect(sdkCallLog).toEqual([
|
|
422
|
+
'trackEvent:first',
|
|
423
|
+
'trackPageView:Landing',
|
|
424
|
+
'trackException:boom',
|
|
425
|
+
'trackMetric:lcp',
|
|
426
|
+
'trackDependency:api',
|
|
427
|
+
'trackEvent:last',
|
|
428
|
+
])
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('keeps the C-288 double-count guard armed DURING the load', async () => {
|
|
432
|
+
// The guard answers a config question, so it must not depend on load state.
|
|
433
|
+
// If it were checked after the buffer, a site with auto-route tracking on would
|
|
434
|
+
// queue manual page views during the load and replay them into exactly the
|
|
435
|
+
// duplicate the guard exists to prevent.
|
|
436
|
+
holdSdkLoad()
|
|
437
|
+
const { useTelemetry } = await import('./composable')
|
|
438
|
+
const t = useTelemetry({
|
|
439
|
+
connectionString: CONNECTION_STRING,
|
|
440
|
+
enableAutoRouteTracking: true,
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
t.initialize()
|
|
444
|
+
await tick()
|
|
445
|
+
t.trackPageView({ name: 'Landing' })
|
|
446
|
+
|
|
447
|
+
releaseSdkLoad()
|
|
448
|
+
await t.whenReady()
|
|
449
|
+
|
|
450
|
+
expect(trackPageViewMock).not.toHaveBeenCalled()
|
|
451
|
+
expect(
|
|
452
|
+
warnSpy.mock.calls.some((c) => String(c[0]).includes('double-counting')),
|
|
453
|
+
).toBe(true)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('reports a page-timing pair that straddles the load with its REAL duration', async () => {
|
|
457
|
+
// The SDK's own start/stop timer cannot be replayed — restarted at drain time it
|
|
458
|
+
// would report a ~0ms page load. So the pair is timed here and replayed as the
|
|
459
|
+
// equivalent trackPageView, using the SDK's own encoding for a timed page view
|
|
460
|
+
// (`properties.duration` as a string, which `sendPageViewInternal` reads to
|
|
461
|
+
// back-date `startTime`).
|
|
462
|
+
holdSdkLoad()
|
|
463
|
+
const { useTelemetry } = await import('./composable')
|
|
464
|
+
const t = useTelemetry({
|
|
465
|
+
connectionString: CONNECTION_STRING,
|
|
466
|
+
enableAutoRouteTracking: false,
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
t.initialize()
|
|
470
|
+
await tick()
|
|
471
|
+
|
|
472
|
+
t.startTrackPage('/contact')
|
|
473
|
+
await tick(20)
|
|
474
|
+
t.stopTrackPage('/contact', 'https://example.com/contact', { routeName: 'contact' })
|
|
475
|
+
|
|
476
|
+
releaseSdkLoad()
|
|
477
|
+
await t.whenReady()
|
|
478
|
+
|
|
479
|
+
// The SDK's own timer was never started, so neither half may reach it.
|
|
480
|
+
expect(startTrackPageMock).not.toHaveBeenCalled()
|
|
481
|
+
expect(stopTrackPageMock).not.toHaveBeenCalled()
|
|
482
|
+
|
|
483
|
+
expect(trackPageViewMock).toHaveBeenCalledTimes(1)
|
|
484
|
+
const call = trackPageViewMock.mock.calls[0][0] as {
|
|
485
|
+
name?: string
|
|
486
|
+
uri?: string
|
|
487
|
+
properties: Record<string, string>
|
|
488
|
+
}
|
|
489
|
+
expect(call.name).toBe('/contact')
|
|
490
|
+
expect(call.uri).toBe('https://example.com/contact')
|
|
491
|
+
expect(call.properties.routeName).toBe('contact')
|
|
492
|
+
// Real elapsed time, not a fabricated zero.
|
|
493
|
+
expect(Number(call.properties.duration)).toBeGreaterThanOrEqual(10)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('drops a pre-init stopTrackPage with no matching start rather than inventing a timing', async () => {
|
|
497
|
+
holdSdkLoad()
|
|
498
|
+
const { useTelemetry } = await import('./composable')
|
|
499
|
+
const t = useTelemetry({
|
|
500
|
+
connectionString: CONNECTION_STRING,
|
|
501
|
+
enableAutoRouteTracking: false,
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
t.initialize()
|
|
505
|
+
await tick()
|
|
506
|
+
t.stopTrackPage('/orphan', 'https://example.com/orphan')
|
|
507
|
+
|
|
508
|
+
releaseSdkLoad()
|
|
509
|
+
await t.whenReady()
|
|
510
|
+
|
|
511
|
+
expect(trackPageViewMock).not.toHaveBeenCalled()
|
|
512
|
+
expect(stopTrackPageMock).not.toHaveBeenCalled()
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('sends straight through once the SDK has landed', async () => {
|
|
516
|
+
// The buffer must be a load-window mechanism only — a permanent queue would add
|
|
517
|
+
// latency to every event for the rest of the session.
|
|
518
|
+
const { useTelemetry } = await import('./composable')
|
|
519
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
520
|
+
|
|
521
|
+
t.initialize()
|
|
522
|
+
await t.whenReady()
|
|
523
|
+
|
|
524
|
+
t.trackEvent({ name: 'after_load' })
|
|
525
|
+
expect(trackEventMock).toHaveBeenCalledTimes(1)
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
// ===========================================================================
|
|
530
|
+
// Single init under concurrent callers.
|
|
531
|
+
// ===========================================================================
|
|
532
|
+
|
|
533
|
+
describe('single init under concurrent callers', () => {
|
|
534
|
+
it('two initialize() callers share one import and one SDK instance', async () => {
|
|
535
|
+
// Two SDK instances on one connection string double every beacon. The sites
|
|
536
|
+
// guard this with an `initializationPromise` (kduff-homes useTelemetry.ts);
|
|
537
|
+
// this is the packaged equivalent.
|
|
538
|
+
holdSdkLoad()
|
|
539
|
+
const { useTelemetry } = await import('./composable')
|
|
540
|
+
const a = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
541
|
+
const b = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
542
|
+
|
|
543
|
+
expect(a.initialize()).toBe(true)
|
|
544
|
+
expect(b.initialize()).toBe(true)
|
|
545
|
+
await tick()
|
|
546
|
+
|
|
547
|
+
// Both callers are waiting on the SAME in-flight load, not two of them.
|
|
548
|
+
expect(a.whenReady()).toBe(b.whenReady())
|
|
549
|
+
|
|
550
|
+
releaseSdkLoad()
|
|
551
|
+
await Promise.all([a.whenReady(), b.whenReady()])
|
|
552
|
+
|
|
553
|
+
expect(constructedConfigs).toHaveLength(1)
|
|
554
|
+
expect(loadAppInsightsMock).toHaveBeenCalledTimes(1)
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('a repeat initialize() after the load lands is still a no-op', async () => {
|
|
558
|
+
const { useTelemetry } = await import('./composable')
|
|
559
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
560
|
+
|
|
561
|
+
t.initialize()
|
|
562
|
+
await t.whenReady()
|
|
563
|
+
expect(t.initialize()).toBe(true)
|
|
564
|
+
await t.whenReady()
|
|
565
|
+
|
|
566
|
+
expect(constructedConfigs).toHaveLength(1)
|
|
567
|
+
expect(loadAppInsightsMock).toHaveBeenCalledTimes(1)
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('ten concurrent callers still produce exactly one SDK instance', async () => {
|
|
571
|
+
holdSdkLoad()
|
|
572
|
+
const { useTelemetry } = await import('./composable')
|
|
573
|
+
const instances = Array.from({ length: 10 }, () =>
|
|
574
|
+
useTelemetry({ connectionString: CONNECTION_STRING }),
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
for (const t of instances) expect(t.initialize()).toBe(true)
|
|
578
|
+
await tick()
|
|
579
|
+
releaseSdkLoad()
|
|
580
|
+
await Promise.all(instances.map((t) => t.whenReady()))
|
|
581
|
+
|
|
582
|
+
expect(constructedConfigs).toHaveLength(1)
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
// The buffer must not become a leak or a lie.
|
|
588
|
+
// ===========================================================================
|
|
589
|
+
|
|
590
|
+
describe('buffer bounds and failure handling', () => {
|
|
591
|
+
it('never buffers when no load is in flight, so an unconfigured site cannot leak', async () => {
|
|
592
|
+
// A site with no connection string never drains anything. Buffering its calls
|
|
593
|
+
// would grow an array for the whole session on a long-lived SPA — and would
|
|
594
|
+
// then dump stale events into any SDK that later arrived.
|
|
595
|
+
const { useTelemetry } = await import('./composable')
|
|
596
|
+
const disabled = useTelemetry()
|
|
597
|
+
|
|
598
|
+
expect(disabled.initialize()).toBe(false)
|
|
599
|
+
for (let i = 0; i < 200; i += 1) disabled.trackEvent({ name: `dropped_${i}` })
|
|
600
|
+
|
|
601
|
+
expect(
|
|
602
|
+
warnSpy.mock.calls.some((c) => String(c[0]).includes('not initialized')),
|
|
603
|
+
).toBe(true)
|
|
604
|
+
|
|
605
|
+
// Now bring a configured instance up. Nothing from before may appear.
|
|
606
|
+
const enabled = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
607
|
+
enabled.initialize()
|
|
608
|
+
await enabled.whenReady()
|
|
609
|
+
|
|
610
|
+
expect(trackEventMock).not.toHaveBeenCalled()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('bounds the buffer and says so, instead of growing without limit', async () => {
|
|
614
|
+
holdSdkLoad()
|
|
615
|
+
const { useTelemetry } = await import('./composable')
|
|
616
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
617
|
+
|
|
618
|
+
t.initialize()
|
|
619
|
+
await tick()
|
|
620
|
+
for (let i = 0; i < 150; i += 1) t.trackEvent({ name: `event_${i}` })
|
|
621
|
+
|
|
622
|
+
releaseSdkLoad()
|
|
623
|
+
await t.whenReady()
|
|
624
|
+
|
|
625
|
+
expect(trackEventMock).toHaveBeenCalledTimes(100)
|
|
626
|
+
expect(trackEventMock.mock.calls[0][0].name).toBe('event_0')
|
|
627
|
+
expect(trackEventMock.mock.calls[99][0].name).toBe('event_99')
|
|
628
|
+
expect(
|
|
629
|
+
warnSpy.mock.calls.filter((c) => String(c[0]).includes('pre-init buffer full')),
|
|
630
|
+
).toHaveLength(1)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
it('discards the buffer and stops buffering when the SDK chunk fails to load', async () => {
|
|
634
|
+
holdSdkLoad()
|
|
635
|
+
sdkLoadShouldFail = true
|
|
636
|
+
const { useTelemetry } = await import('./composable')
|
|
637
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
638
|
+
|
|
639
|
+
expect(t.initialize()).toBe(true)
|
|
640
|
+
await tick()
|
|
641
|
+
t.trackEvent({ name: 'lost_to_a_failed_chunk' })
|
|
642
|
+
|
|
643
|
+
releaseSdkLoad()
|
|
644
|
+
expect(await t.whenReady()).toBe(false)
|
|
645
|
+
|
|
646
|
+
expect(t.isInitialized.value).toBe(false)
|
|
647
|
+
// The message is asserted as "present and non-empty" rather than by text:
|
|
648
|
+
// vitest substitutes its own message when a module-mock factory throws, so
|
|
649
|
+
// pinning the string would test vitest, not the composable. What matters is that
|
|
650
|
+
// the rejection is CAPTURED into the reactive error instead of escaping as an
|
|
651
|
+
// unhandled rejection.
|
|
652
|
+
expect(t.initializationError.value).toBeTruthy()
|
|
653
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
654
|
+
'Failed to initialize Application Insights:',
|
|
655
|
+
expect.any(Error),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
// And subsequent calls must go back to warn-and-drop rather than buffering for
|
|
659
|
+
// a drain that is never coming.
|
|
660
|
+
t.trackEvent({ name: 'after_failure' })
|
|
661
|
+
expect(
|
|
662
|
+
warnSpy.mock.calls.some((c) => String(c[0]).includes('not initialized')),
|
|
663
|
+
).toBe(true)
|
|
664
|
+
expect(trackEventMock).not.toHaveBeenCalled()
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('whenReady() is false before initialize() is ever called', async () => {
|
|
668
|
+
const { useTelemetry } = await import('./composable')
|
|
669
|
+
expect(await useTelemetry({ connectionString: CONNECTION_STRING }).whenReady()).toBe(
|
|
670
|
+
false,
|
|
671
|
+
)
|
|
672
|
+
expect(sdkModuleEvaluated).toBe(false)
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('declines the SDK entirely during a synthetic capture pass (C-614 still holds)', async () => {
|
|
676
|
+
// The C-614 gate has to keep working on the lazy path: a capture pass must load
|
|
677
|
+
// no SDK chunk at all, not merely fail to send.
|
|
678
|
+
const original = window.location
|
|
679
|
+
Object.defineProperty(window, 'location', {
|
|
680
|
+
configurable: true,
|
|
681
|
+
value: { hostname: 'duffcloudservices.com', pathname: '/', search: '?dcs-hide-ribbon=' },
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
try {
|
|
685
|
+
const { useTelemetry } = await import('./composable')
|
|
686
|
+
const t = useTelemetry({ connectionString: CONNECTION_STRING })
|
|
687
|
+
|
|
688
|
+
expect(t.initialize()).toBe(false)
|
|
689
|
+
await tick()
|
|
690
|
+
|
|
691
|
+
expect(sdkModuleEvaluated).toBe(false)
|
|
692
|
+
expect(constructedConfigs).toHaveLength(0)
|
|
693
|
+
expect(await t.whenReady()).toBe(false)
|
|
694
|
+
} finally {
|
|
695
|
+
Object.defineProperty(window, 'location', { configurable: true, value: original })
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
})
|