@duffcloudservices/telemetry 0.2.1 → 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.
@@ -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
+ })