@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.
package/src/composable.ts CHANGED
@@ -1,8 +1,23 @@
1
- import { ApplicationInsights } from '@microsoft/applicationinsights-web'
1
+ // TYPE-ONLY on purpose this is the whole point of C-601's lazy path.
2
+ //
3
+ // A *value* import of the App Insights web SDK here folds the SDK into whatever
4
+ // chunk imports this module, which for a site is the ENTRY chunk. Measured on the
5
+ // bryans-handyman-solutions pilot (2026-08-11): adopting this package as published
6
+ // moved 192,265B raw / 77,920B gzip of SDK off its own async chunk and onto the
7
+ // entry chunk (+90.4% raw / +101.2% gzip), while the package's own code was only
8
+ // +4.8KB of that. Total bytes barely moved — the SDK simply relocated onto the
9
+ // critical path, defeating the sites' deliberate requestIdleCallback deferral.
10
+ //
11
+ // `import type` is erased by tsc/tsup, so it emits no runtime import at all. The
12
+ // SDK is reached exactly once, through the dynamic `import()` in `loadSdk()`
13
+ // below, which every bundler emits as a separate async chunk. Pinned by
14
+ // `lazySdk.test.ts` — reintroducing a value import here turns that spec red.
15
+ import type { ApplicationInsights } from '@microsoft/applicationinsights-web'
2
16
  import { computed, ref } from 'vue'
3
17
 
4
18
  import { DEFAULT_CLOUD_ROLE, createTelemetryConfig } from './config'
5
19
  import { captureJourneyFirstTouch, setJourneyIdentityResolver } from './journey'
20
+ import { syntheticCaptureDetected } from './syntheticCapture'
6
21
  import type {
7
22
  TelemetryDependency,
8
23
  TelemetryEvent,
@@ -28,6 +43,139 @@ let appInsights: ApplicationInsights | null = null
28
43
  const isInitialized = ref(false)
29
44
  const initializationError = ref<string | null>(null)
30
45
 
46
+ /**
47
+ * The in-flight lazy SDK load, or `null` when none is running.
48
+ *
49
+ * This is the single-init guard AND the "is a load in flight" signal the pre-init
50
+ * buffer keys off. Two concurrent `initialize()` callers — a `main.ts` boot and a
51
+ * component's `onMounted`, say — must produce ONE dynamic import and ONE
52
+ * `new ApplicationInsights(...)`, or the site gets two SDK instances racing the
53
+ * same connection string. Mirrors the guarded initializer the sites already run
54
+ * (kduff-homes `useTelemetry.ts` `initializationPromise`).
55
+ *
56
+ * Non-null also means "buffer, don't drop": see {@link bufferWhileLoading}.
57
+ */
58
+ let initPromise: Promise<boolean> | null = null
59
+
60
+ /** One already-built SDK call, replayed verbatim once the SDK arrives. */
61
+ type PendingSdkCall = (instance: ApplicationInsights) => void
62
+
63
+ /**
64
+ * Calls made after `initialize()` but before the lazy SDK chunk resolves.
65
+ *
66
+ * Without this, laziness would silently cost data: a site that calls
67
+ * `initialize()` and immediately reports a landing pageView or a `cta_click`
68
+ * would have both dropped by the `!isInitialized` guards, because the SDK is
69
+ * still in flight over the network. The site-local composables this package
70
+ * replaces DO drop those calls (bryans/kduff both early-return) — buffering is a
71
+ * strict improvement on the pattern being adopted, not a port of it.
72
+ *
73
+ * Payloads are built at CALL time, so `timestamp`, `page_path` and `referrer_host`
74
+ * describe the moment the event happened, not the moment the chunk landed. Order
75
+ * is preserved.
76
+ */
77
+ const pendingSdkCalls: PendingSdkCall[] = []
78
+
79
+ /**
80
+ * Bound on the buffer. A load that never settles (offline, blocked CDN, an
81
+ * ad-blocker eating the SDK chunk) must not grow an unbounded array for the rest
82
+ * of the session on a long-lived SPA. 100 is far above the handful of events a
83
+ * real pre-init window sees and far below anything that matters for memory.
84
+ */
85
+ const PENDING_CALL_LIMIT = 100
86
+ let warnedBufferFull = false
87
+
88
+ /**
89
+ * Take ownership of a track* call while the SDK chunk is in flight.
90
+ *
91
+ * Returns `true` when this function has handled the call (buffered it, or
92
+ * deliberately dropped it because the buffer is full) and the caller must return.
93
+ * Returns `false` when no load is in flight — the caller then keeps its existing
94
+ * "not initialized" warn-and-drop behaviour, unchanged. That distinction is what
95
+ * stops an unconfigured site (never calls a successful `initialize()`) from
96
+ * accumulating a buffer nobody will ever drain.
97
+ */
98
+ function bufferWhileLoading(call: PendingSdkCall): boolean {
99
+ if (!initPromise) return false
100
+
101
+ if (pendingSdkCalls.length >= PENDING_CALL_LIMIT) {
102
+ if (!warnedBufferFull) {
103
+ warnedBufferFull = true
104
+ console.warn(
105
+ `[telemetry] pre-init buffer full (${PENDING_CALL_LIMIT} calls) — dropping ` +
106
+ 'telemetry until the Application Insights SDK chunk finishes loading. ' +
107
+ 'If you see this, the SDK chunk is failing to load, not merely slow.',
108
+ )
109
+ }
110
+ return true
111
+ }
112
+
113
+ pendingSdkCalls.push(call)
114
+ return true
115
+ }
116
+
117
+ /** Replay everything buffered during the load, in order, then clear. */
118
+ function drainPendingSdkCalls(instance: ApplicationInsights): void {
119
+ const calls = pendingSdkCalls.splice(0, pendingSdkCalls.length)
120
+ for (const call of calls) {
121
+ try {
122
+ call(instance)
123
+ } catch (error) {
124
+ console.error('Failed to replay buffered telemetry:', error)
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Abandon anything buffered — the SDK is never arriving. */
130
+ function discardPendingSdkCalls(): void {
131
+ pendingSdkCalls.length = 0
132
+ }
133
+
134
+ /**
135
+ * Load the App Insights web SDK constructor on demand.
136
+ *
137
+ * The ONE runtime reference to `@microsoft/applicationinsights-web` in this
138
+ * package. Kept as a bare dynamic `import()` (not wrapped in a variable specifier)
139
+ * so bundlers can statically see the dependency and emit it as its own async
140
+ * chunk — which is exactly the shape the sites hand-rolled and the shape this
141
+ * package existed to fold up.
142
+ */
143
+ async function loadSdk(): Promise<typeof ApplicationInsights> {
144
+ const module = await import('@microsoft/applicationinsights-web')
145
+ return module.ApplicationInsights
146
+ }
147
+
148
+ /** Monotonic-ish clock for the pre-init page-timing pair. */
149
+ function nowMs(): number {
150
+ return typeof performance !== 'undefined' && typeof performance.now === 'function'
151
+ ? performance.now()
152
+ : Date.now()
153
+ }
154
+
155
+ /**
156
+ * Pre-init `startTrackPage` timers, keyed by resolved page name.
157
+ *
158
+ * The SDK's own start/stop pair CANNOT be replayed faithfully after the fact: its
159
+ * internal timer would start at REPLAY time, so a page whose load straddled the
160
+ * lazy import would report a ~0ms duration. So a pair that straddles the load is
161
+ * timed here and replayed as the equivalent `trackPageView`.
162
+ *
163
+ * That is not an approximation — it is precisely what the SDK does internally.
164
+ * `AnalyticsPlugin.stopTrackPage` delegates to `_pageTracking.action`, which sets
165
+ * `properties.duration = duration.toString()` and calls `sendPageViewInternal`;
166
+ * `sendPageViewInternal` then derives `startTime` from `properties.duration` when
167
+ * no `startTime` is present (verified in applicationinsights-analytics-js 3.3.10,
168
+ * `dist-es5/JavaScriptSDK/AnalyticsPlugin.js`). Same wire shape, real duration —
169
+ * and, like the SDK, `duration` is left on the custom properties.
170
+ */
171
+ const preInitPageTimings = new Map<string, number>()
172
+
173
+ /** The name the SDK itself would use: the argument, else `document.title`. */
174
+ function resolvePageName(name?: string): string {
175
+ if (typeof name === 'string') return name
176
+ return typeof document !== 'undefined' ? document.title : ''
177
+ }
178
+
31
179
  // One-shot guards so we surface "App Insights is not configured" loudly in
32
180
  // DevTools the FIRST time the app tries to use it, but don't spam the console
33
181
  // for every subsequent trackEvent / trackPageView call. See web/ site-audit F2
@@ -144,6 +292,29 @@ function resolveAppVersion(explicit?: string): string {
144
292
  * pass plain values in) so this package stays portable and build-tool agnostic.
145
293
  * The shared App Insights config — including the `disablePageUnloadEvents:
146
294
  * ['unload']` bfcache/Lighthouse fix — comes from {@link createTelemetryConfig}.
295
+ *
296
+ * ## The SDK loads LAZILY (C-601)
297
+ *
298
+ * `initialize()` still returns `boolean` synchronously — "telemetry is enabled and
299
+ * the SDK load has started" — but the App Insights web SDK itself arrives through
300
+ * a dynamic `import()`, so a bundler emits it as its own async chunk instead of
301
+ * folding ~192KB raw / ~78KB gzip into the consumer's entry chunk. Adopting this
302
+ * package therefore no longer moves the SDK onto the critical path.
303
+ *
304
+ * Three things make that safe:
305
+ *
306
+ * 1. **Buffered, not dropped.** track* calls made between `initialize()` and the
307
+ * chunk landing are queued with their call-time properties and replayed in
308
+ * order (bounded at {@link PENDING_CALL_LIMIT}). The site-local composables this
309
+ * package replaces dropped them.
310
+ * 2. **One init.** Concurrent `initialize()` callers share one `import()` and one
311
+ * `new ApplicationInsights(...)` via {@link initPromise}.
312
+ * 3. **Guards stay synchronous.** `enableAutoRouteTracking` is resolved before the
313
+ * `await`, so the C-288 double-count guard is armed for calls made during the
314
+ * load, not only after it.
315
+ *
316
+ * `whenReady()` is there for the rare caller that must sequence work after the SDK
317
+ * exists. You should not need it to avoid losing events.
147
318
  */
148
319
  export function useTelemetry(options: TelemetryOptions = {}) {
149
320
  const cloudRole = options.cloudRole ?? DEFAULT_CLOUD_ROLE
@@ -213,10 +384,27 @@ export function useTelemetry(options: TelemetryOptions = {}) {
213
384
  // stored form submissions.
214
385
  captureJourneyFirstTouch()
215
386
 
387
+ // C-614: the post-deploy snapshot-capture step walks the LIVE site with
388
+ // real Chromium. Declining BEFORE the SDK is constructed means a capture
389
+ // pass emits no pageView, event or exception at all — every track* call
390
+ // below already no-ops while `isInitialized` is false. Checked after the
391
+ // journey snapshot above so that deliberately unconditional behaviour is
392
+ // left exactly as it was.
393
+ if (syntheticCaptureDetected()) {
394
+ return false
395
+ }
396
+
216
397
  if (isInitialized.value && appInsights) {
217
398
  return true
218
399
  }
219
400
 
401
+ // A load is already in flight (or already finished) — do NOT start a second
402
+ // one. Returning true keeps the caller's contract ("telemetry is on"); the
403
+ // awaitable answer is `whenReady()`.
404
+ if (initPromise) {
405
+ return true
406
+ }
407
+
220
408
  if (!isEnabled.value) {
221
409
  if (!warnedDisabled) {
222
410
  warnedDisabled = true
@@ -243,33 +431,66 @@ export function useTelemetry(options: TelemetryOptions = {}) {
243
431
  })
244
432
  // Remember whether the SDK is counting page views for us, so trackPageView()
245
433
  // can refuse to count them a second time.
434
+ //
435
+ // Resolved SYNCHRONOUSLY, before the lazy import, on purpose: the C-288
436
+ // double-count guard must be armed for the calls that arrive DURING the load,
437
+ // or a site with auto-route tracking on would buffer manual page views and
438
+ // then replay them into the duplicate the guard exists to prevent.
246
439
  autoRouteTrackingActive = resolvedConfig.enableAutoRouteTracking !== false
247
440
 
248
- appInsights = new ApplicationInsights({ config: resolvedConfig })
249
-
250
- appInsights.loadAppInsights()
251
- appInsights.addTelemetryInitializer((envelope) => {
441
+ initPromise = (async (): Promise<boolean> => {
252
442
  try {
253
- enrichTelemetryEnvelope(envelope as TelemetryEnvelope)
254
- } catch (error) {
255
- console.warn('Failed to add web telemetry context:', error)
256
- }
443
+ const ApplicationInsightsCtor = await loadSdk()
257
444
 
258
- return true
259
- })
445
+ const instance = new ApplicationInsightsCtor({ config: resolvedConfig })
446
+ instance.loadAppInsights()
447
+ instance.addTelemetryInitializer((envelope) => {
448
+ try {
449
+ enrichTelemetryEnvelope(envelope as TelemetryEnvelope)
450
+ } catch (error) {
451
+ console.warn('Failed to add web telemetry context:', error)
452
+ }
453
+
454
+ return true
455
+ })
260
456
 
261
- // Prefer the live SDK context over cookie parsing for the journey join
262
- // key: the instance is authoritative, and it stays correct even if the
263
- // cookies are blocked or renamed by a future SDK version.
264
- setJourneyIdentityResolver(() => readSdkIdentity(appInsights))
457
+ // Published LAST, after the SDK is wired: `appInsights` is what every
458
+ // track* guard reads, so assigning it earlier would let a call slip
459
+ // through to a half-configured instance (no telemetry initializer, so no
460
+ // app_version / cloud role on the envelope).
461
+ appInsights = instance
265
462
 
266
- isInitialized.value = true
267
- initializationError.value = null
268
- console.log('Application Insights initialized successfully')
463
+ // Prefer the live SDK context over cookie parsing for the journey join
464
+ // key: the instance is authoritative, and it stays correct even if the
465
+ // cookies are blocked or renamed by a future SDK version.
466
+ setJourneyIdentityResolver(() => readSdkIdentity(appInsights))
269
467
 
270
- // Drain any conversion clicks captured before this transport existed, and stay
271
- // attached for the rest of the session. See attachConversionCapture().
272
- attachConversionCapture(trackEvent)
468
+ isInitialized.value = true
469
+ initializationError.value = null
470
+ console.log('Application Insights initialized successfully')
471
+
472
+ // Anything that fired between initialize() and this moment, in order.
473
+ drainPendingSdkCalls(instance)
474
+
475
+ // Drain any conversion clicks captured before this transport existed, and
476
+ // stay attached for the rest of the session. See attachConversionCapture().
477
+ attachConversionCapture(trackEvent)
478
+
479
+ return true
480
+ } catch (error) {
481
+ const errorMessage =
482
+ error instanceof Error ? error.message : 'Unknown initialization error'
483
+ initializationError.value = errorMessage
484
+ console.error('Failed to initialize Application Insights:', error)
485
+ // The SDK is not coming. Release the buffer rather than holding events
486
+ // for a drain that will never happen, and clear the in-flight marker so
487
+ // subsequent track* calls warn-and-drop instead of buffering forever.
488
+ discardPendingSdkCalls()
489
+ preInitPageTimings.clear()
490
+ initPromise = null
491
+ return false
492
+ }
493
+ })()
273
494
 
274
495
  return true
275
496
  } catch (error) {
@@ -281,8 +502,34 @@ export function useTelemetry(options: TelemetryOptions = {}) {
281
502
  }
282
503
  }
283
504
 
505
+ /**
506
+ * Resolves when the lazy App Insights chunk has finished loading (or failed).
507
+ *
508
+ * ADDITIVE — `initialize()` keeps its synchronous `boolean` return, so every
509
+ * existing caller (`web/src/main.ts`, the site adapters) is untouched. Use this
510
+ * when you genuinely need to sequence work after the SDK exists; you should not
511
+ * need it merely to avoid losing events, because calls made during the load are
512
+ * buffered and replayed.
513
+ *
514
+ * `false` means "no live SDK": telemetry is disabled, a synthetic capture pass
515
+ * declined it, `initialize()` was never called, or the chunk failed to load.
516
+ */
517
+ const whenReady = (): Promise<boolean> => {
518
+ if (isInitialized.value && appInsights) return Promise.resolve(true)
519
+ return initPromise ?? Promise.resolve(false)
520
+ }
521
+
284
522
  const trackEvent = (event: TelemetryEvent) => {
285
523
  if (!appInsights || !isInitialized.value) {
524
+ // Built here, not at drain time, so the timestamp/page_path describe when the
525
+ // event happened rather than when the SDK chunk landed.
526
+ const payload = {
527
+ name: event.name,
528
+ properties: buildEventProperties(event.properties),
529
+ measurements: event.measurements,
530
+ }
531
+ if (bufferWhileLoading((instance) => instance.trackEvent(payload))) return
532
+
286
533
  if (!warnedNotInitialized) {
287
534
  warnedNotInitialized = true
288
535
  console.warn(
@@ -318,11 +565,12 @@ export function useTelemetry(options: TelemetryOptions = {}) {
318
565
  * `useTelemetry({ enableAutoRouteTracking: false })`. One tracker, one count.
319
566
  */
320
567
  const trackPageView = (pageView?: TelemetryPageView) => {
321
- if (!appInsights || !isInitialized.value) {
322
- console.warn('Application Insights not initialized, skipping page view tracking')
323
- return
324
- }
325
-
568
+ // The double-count guard runs FIRST, before the init/buffer split. It is a
569
+ // question about CONFIG (is the SDK already counting routes?), not about load
570
+ // state, and `autoRouteTrackingActive` is resolved synchronously by
571
+ // initialize(). Checking it after the buffer would let manual page views queue
572
+ // during the load and then replay into the duplicate this guard exists to
573
+ // prevent (C-288: 52% of one site's page views).
326
574
  if (autoRouteTrackingActive && !pageView?.force) {
327
575
  if (!warnedDoubleCount) {
328
576
  warnedDoubleCount = true
@@ -337,24 +585,31 @@ export function useTelemetry(options: TelemetryOptions = {}) {
337
585
  return
338
586
  }
339
587
 
588
+ // Attach the full document.referrer (not just hostname) so we can
589
+ // attribute traffic sources in App Insights pageViews.
590
+ const referrer = typeof document !== 'undefined' ? (document.referrer ?? '') : ''
591
+ const payload = {
592
+ name:
593
+ pageView?.name ||
594
+ (typeof document !== 'undefined' ? document.title : undefined),
595
+ uri:
596
+ pageView?.uri ||
597
+ (typeof window !== 'undefined' ? window.location.href : undefined),
598
+ properties: buildEventProperties({
599
+ referrer,
600
+ ...pageView?.properties,
601
+ }),
602
+ measurements: pageView?.measurements,
603
+ }
604
+
605
+ if (!appInsights || !isInitialized.value) {
606
+ if (bufferWhileLoading((instance) => instance.trackPageView(payload))) return
607
+ console.warn('Application Insights not initialized, skipping page view tracking')
608
+ return
609
+ }
610
+
340
611
  try {
341
- // Attach the full document.referrer (not just hostname) so we can
342
- // attribute traffic sources in App Insights pageViews.
343
- const referrer =
344
- typeof document !== 'undefined' ? (document.referrer ?? '') : ''
345
- appInsights.trackPageView({
346
- name:
347
- pageView?.name ||
348
- (typeof document !== 'undefined' ? document.title : undefined),
349
- uri:
350
- pageView?.uri ||
351
- (typeof window !== 'undefined' ? window.location.href : undefined),
352
- properties: buildEventProperties({
353
- referrer,
354
- ...pageView?.properties,
355
- }),
356
- measurements: pageView?.measurements,
357
- })
612
+ appInsights.trackPageView(payload)
358
613
  } catch (error) {
359
614
  console.error('Failed to track page view:', error)
360
615
  }
@@ -378,63 +633,75 @@ export function useTelemetry(options: TelemetryOptions = {}) {
378
633
  }
379
634
 
380
635
  const trackException = (exception: TelemetryException) => {
636
+ const payload = {
637
+ exception: exception.exception,
638
+ properties: buildEventProperties({
639
+ url: typeof window !== 'undefined' ? window.location.href : '',
640
+ userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
641
+ ...exception.properties,
642
+ }),
643
+ measurements: exception.measurements,
644
+ }
645
+
381
646
  if (!appInsights || !isInitialized.value) {
647
+ // Exceptions are the calls that most need the buffer: a boot-time error is
648
+ // exactly the class of failure that fires while the SDK chunk is still in
649
+ // flight, and it is the class you least want silently dropped.
650
+ if (bufferWhileLoading((instance) => instance.trackException(payload))) return
382
651
  console.warn('Application Insights not initialized, skipping exception tracking')
383
652
  return
384
653
  }
385
654
 
386
655
  try {
387
- appInsights.trackException({
388
- exception: exception.exception,
389
- properties: buildEventProperties({
390
- url: typeof window !== 'undefined' ? window.location.href : '',
391
- userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
392
- ...exception.properties,
393
- }),
394
- measurements: exception.measurements,
395
- })
656
+ appInsights.trackException(payload)
396
657
  } catch (error) {
397
658
  console.error('Failed to track exception:', error)
398
659
  }
399
660
  }
400
661
 
401
662
  const trackDependency = (dependency: TelemetryDependency) => {
663
+ const payload = {
664
+ id: `dep-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
665
+ name: dependency.name,
666
+ data: dependency.data,
667
+ duration: dependency.duration,
668
+ success: dependency.success,
669
+ responseCode: dependency.resultCode || 0,
670
+ properties: buildEventProperties(dependency.properties),
671
+ measurements: dependency.measurements,
672
+ }
673
+
402
674
  if (!appInsights || !isInitialized.value) {
675
+ if (bufferWhileLoading((instance) => instance.trackDependencyData(payload))) return
403
676
  console.warn('Application Insights not initialized, skipping dependency tracking')
404
677
  return
405
678
  }
406
679
 
407
680
  try {
408
- appInsights.trackDependencyData({
409
- id: `dep-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
410
- name: dependency.name,
411
- data: dependency.data,
412
- duration: dependency.duration,
413
- success: dependency.success,
414
- responseCode: dependency.resultCode || 0,
415
- properties: buildEventProperties(dependency.properties),
416
- measurements: dependency.measurements,
417
- })
681
+ appInsights.trackDependencyData(payload)
418
682
  } catch (error) {
419
683
  console.error('Failed to track dependency:', error)
420
684
  }
421
685
  }
422
686
 
423
687
  const trackMetric = (metric: TelemetryMetric) => {
688
+ const payload = {
689
+ name: metric.name,
690
+ average: metric.average,
691
+ sampleCount: metric.sampleCount,
692
+ min: metric.min,
693
+ max: metric.max,
694
+ properties: buildEventProperties(metric.properties),
695
+ }
696
+
424
697
  if (!appInsights || !isInitialized.value) {
698
+ if (bufferWhileLoading((instance) => instance.trackMetric(payload))) return
425
699
  console.warn('Application Insights not initialized, skipping metric tracking')
426
700
  return
427
701
  }
428
702
 
429
703
  try {
430
- appInsights.trackMetric({
431
- name: metric.name,
432
- average: metric.average,
433
- sampleCount: metric.sampleCount,
434
- min: metric.min,
435
- max: metric.max,
436
- properties: buildEventProperties(metric.properties),
437
- })
704
+ appInsights.trackMetric(payload)
438
705
  } catch (error) {
439
706
  console.error('Failed to track metric:', error)
440
707
  }
@@ -478,13 +745,25 @@ export function useTelemetry(options: TelemetryOptions = {}) {
478
745
  }
479
746
 
480
747
  const startTrackPage = (name?: string) => {
481
- if (!appInsights || !isInitialized.value) return
748
+ // Redundancy first, for the same reason as trackPageView: it is a config
749
+ // question, answered synchronously, and it must hold for calls made during the
750
+ // lazy load too.
482
751
  if (manualPageTrackingIsRedundant()) return
483
752
 
484
- try {
485
- appInsights.startTrackPage(name)
486
- } catch (error) {
487
- console.error('Failed to start track page:', error)
753
+ if (appInsights && isInitialized.value) {
754
+ try {
755
+ appInsights.startTrackPage(name)
756
+ } catch (error) {
757
+ console.error('Failed to start track page:', error)
758
+ }
759
+ return
760
+ }
761
+
762
+ // Time it ourselves while the SDK chunk is in flight — see preInitPageTimings.
763
+ // Outside a load (initialize() never called, or it failed) this drops silently,
764
+ // exactly as before.
765
+ if (initPromise) {
766
+ preInitPageTimings.set(resolvePageName(name), nowMs())
488
767
  }
489
768
  }
490
769
 
@@ -494,14 +773,47 @@ export function useTelemetry(options: TelemetryOptions = {}) {
494
773
  properties?: Record<string, string>,
495
774
  measurements?: Record<string, number>,
496
775
  ) => {
497
- if (!appInsights || !isInitialized.value) return
498
776
  if (manualPageTrackingIsRedundant()) return
499
777
 
500
- try {
501
- appInsights.stopTrackPage(name, url, buildEventProperties(properties), measurements)
502
- } catch (error) {
503
- console.error('Failed to stop track page:', error)
778
+ if (appInsights && isInitialized.value) {
779
+ try {
780
+ appInsights.stopTrackPage(name, url, buildEventProperties(properties), measurements)
781
+ } catch (error) {
782
+ console.error('Failed to stop track page:', error)
783
+ }
784
+ return
504
785
  }
786
+
787
+ if (!initPromise) return
788
+
789
+ const key = resolvePageName(name)
790
+ const startedAt = preInitPageTimings.get(key)
791
+ // No matching start means there is no timing to report — and inventing one
792
+ // would be worse than dropping it.
793
+ if (startedAt === undefined) return
794
+ preInitPageTimings.delete(key)
795
+
796
+ const duration = Math.max(0, Math.round(nowMs() - startedAt))
797
+ const payload = {
798
+ name: key,
799
+ uri: url ?? (typeof window !== 'undefined' ? window.location.href : undefined),
800
+ // `duration` as a STRING alongside the custom properties is the SDK's OWN
801
+ // encoding for a timed page view — `_pageTracking.action` does
802
+ // `properties.duration = duration.toString()` — and it is what
803
+ // `sendPageViewInternal` reads to back-date `startTime`.
804
+ //
805
+ // The cast is deliberate: `IPageViewTelemetry` declares `properties.duration`
806
+ // as `number`, which its own `stopTrackPage` path does not honour. Matching
807
+ // the runtime the collector actually parses beats matching a type that the
808
+ // emitting SDK contradicts.
809
+ properties: { ...buildEventProperties(properties), duration: String(duration) } as unknown as Record<
810
+ string,
811
+ number
812
+ >,
813
+ measurements,
814
+ }
815
+
816
+ bufferWhileLoading((instance) => instance.trackPageView(payload))
505
817
  }
506
818
 
507
819
  return {
@@ -509,6 +821,7 @@ export function useTelemetry(options: TelemetryOptions = {}) {
509
821
  isInitialized,
510
822
  initializationError,
511
823
  initialize,
824
+ whenReady,
512
825
  flush,
513
826
  startTrackPage,
514
827
  stopTrackPage,
package/src/index.ts CHANGED
@@ -2,8 +2,12 @@
2
2
  * `@duffcloudservices/telemetry` — shared Azure Application Insights telemetry
3
3
  * for DCS Vue sites.
4
4
  *
5
- * - {@link useTelemetry} — the full composable (init + track* API). Statically
6
- * imports the App Insights web SDK, so `initialize()` is synchronous.
5
+ * - {@link useTelemetry} — the full composable (init + track* API). Loads the App
6
+ * Insights web SDK through a dynamic `import()` (C-601), so importing this entry
7
+ * point costs the consumer NO SDK bytes on its entry chunk — the SDK becomes its
8
+ * own async chunk, exactly as the hand-rolled site composables arranged. Calls
9
+ * made while that chunk is in flight are buffered and replayed, so laziness does
10
+ * not cost events.
7
11
  * - {@link createTelemetryConfig} — the shared App Insights config, with the
8
12
  * `disablePageUnloadEvents: ['unload']` bfcache/Lighthouse fix baked into the
9
13
  * default. Pure (no SDK import); also available SDK-free at
@@ -14,6 +18,7 @@
14
18
  * telemetry boot.
15
19
  */
16
20
  export { useTelemetry, type TelemetryApi } from './composable'
21
+ export { syntheticCaptureDetected, SYNTHETIC_CAPTURE_PARAMS } from './syntheticCapture'
17
22
  export {
18
23
  createTelemetryConfig,
19
24
  DEFAULT_CLOUD_ROLE,