From 4ac98d6ade2539b77bea3d71632e73e37abb3e60 Mon Sep 17 00:00:00 2001 From: isaaclombardssw Date: Thu, 23 Jul 2026 16:29:10 +1000 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9A=A1=20Defer=20Application=20Insights?= =?UTF-8?q?=20init=20out=20of=20the=20critical=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Construct and load the @microsoft/applicationinsights-web SDK during idle time (requestIdleCallback with a timeout, setTimeout fallback for Safari) instead of synchronously in the mount effect, and dynamically import() it so it is no longer in the route's initial chunk graph. This keeps its work out of the hydration window (TBT / long tasks). Web-vitals metrics reported before the SDK is ready are buffered and replayed on load via a small shared buffer, so telemetry measured before init (LCP, FCP, TTFB) is not lost. Adds a jest unit test for the buffer/flush ordering. Server-side telemetry (serverExternalPackages) is untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P --- app/components/web-vitals.tsx | 7 +- context/app-insight-client.tsx | 122 ++++++++++++------ .../app-insights-web-vitals-buffer.test.ts | 42 ++++++ context/app-insights-web-vitals-buffer.ts | 45 +++++++ 4 files changed, 172 insertions(+), 44 deletions(-) create mode 100644 context/app-insights-web-vitals-buffer.test.ts create mode 100644 context/app-insights-web-vitals-buffer.ts diff --git a/app/components/web-vitals.tsx b/app/components/web-vitals.tsx index 56a03a4e91..fa4e8736e9 100644 --- a/app/components/web-vitals.tsx +++ b/app/components/web-vitals.tsx @@ -1,11 +1,10 @@ "use client"; -import { useAppInsightsContext } from "@microsoft/applicationinsights-react-js"; +import { trackWebVital } from "@/context/app-insights-web-vitals-buffer"; import { usePathname } from "next/navigation"; import { useReportWebVitals } from "next/web-vitals"; export const WebVitals = () => { - const appInsights = useAppInsightsContext(); const pathname = usePathname(); // Check if Web Vitals tracking is enabled (default: true) @@ -23,7 +22,9 @@ export const WebVitals = () => { case "FID": case "CLS": case "INP": - appInsights?.trackMetric( + // Buffered until App Insights loads (init is deferred), then flushed — + // so metrics measured before init are not lost. + trackWebVital( { name: metric.name, average: metric.value }, { page: `${pathname}` } ); diff --git a/context/app-insight-client.tsx b/context/app-insight-client.tsx index af1c85e028..dfff7e4af6 100644 --- a/context/app-insight-client.tsx +++ b/context/app-insight-client.tsx @@ -4,57 +4,97 @@ import { AppInsightsContext, ReactPlugin, } from "@microsoft/applicationinsights-react-js"; -import { ApplicationInsights } from "@microsoft/applicationinsights-web"; import React, { ReactNode, useEffect, useMemo } from "react"; +import { + flushWebVitals, + resetWebVitalsSink, +} from "./app-insights-web-vitals-buffer"; + +// Run the callback once the page is idle so App Insights init stays out of the +// critical/hydration window. Falls back to a timer where requestIdleCallback is +// unavailable (Safari < 17). +function whenIdle(cb: () => void): () => void { + if (typeof window.requestIdleCallback === "function") { + const id = window.requestIdleCallback(cb, { timeout: 5000 }); + return () => window.cancelIdleCallback(id); + } + const id = window.setTimeout(cb, 1000); + return () => window.clearTimeout(id); +} export function AppInsightsProvider({ children }: { children: ReactNode }) { const reactPlugin = useMemo(() => new ReactPlugin(), []); + useEffect(() => { - // Configuration options with defaults for cost optimization - const clientSamplingPercentageRaw = parseFloat( - process.env.NEXT_PUBLIC_APPINSIGHTS_CLIENT_SAMPLING_PERCENTAGE || "20" - ); - // Validate sampling percentage is between 1 and 100, default to 20 if invalid - const clientSamplingPercentage = - !isNaN(clientSamplingPercentageRaw) && - clientSamplingPercentageRaw >= 1 && - clientSamplingPercentageRaw <= 100 - ? clientSamplingPercentageRaw - : 20; + let cancelled = false; + let appInsights: { unload: () => void } | undefined; + + const init = async () => { + // Configuration options with defaults for cost optimization + const clientSamplingPercentageRaw = parseFloat( + process.env.NEXT_PUBLIC_APPINSIGHTS_CLIENT_SAMPLING_PERCENTAGE || "20" + ); + // Validate sampling percentage is between 1 and 100, default to 20 if invalid + const clientSamplingPercentage = + !isNaN(clientSamplingPercentageRaw) && + clientSamplingPercentageRaw >= 1 && + clientSamplingPercentageRaw <= 100 + ? clientSamplingPercentageRaw + : 20; - const appInsights = new ApplicationInsights({ - config: { - connectionString: process.env.NEXT_PUBLIC_APP_INSIGHT_CONNECTION_STRING, - extensions: [reactPlugin], - samplingPercentage: clientSamplingPercentage, // Apply client-side sampling - autoExceptionInstrumented: true, // Always track exceptions - autoTrackPageVisitTime: true, - enableRequestHeaderTracking: true, - enableResponseHeaderTracking: true, - enableAjaxErrorStatusText: true, - distributedTracingMode: 0, - loggingLevelTelemetry: 1, - loggingLevelConsole: 1, - extensionConfig: { - [reactPlugin.identifier]: {}, + // Dynamic import keeps the ~ES5 SDK out of the route's initial chunk graph. + const { ApplicationInsights } = await import( + "@microsoft/applicationinsights-web" + ); + if (cancelled) return; + + const ai = new ApplicationInsights({ + config: { + connectionString: + process.env.NEXT_PUBLIC_APP_INSIGHT_CONNECTION_STRING, + extensions: [reactPlugin], + samplingPercentage: clientSamplingPercentage, // Apply client-side sampling + autoExceptionInstrumented: true, // Always track exceptions + autoTrackPageVisitTime: true, + enableRequestHeaderTracking: true, + enableResponseHeaderTracking: true, + enableAjaxErrorStatusText: true, + distributedTracingMode: 0, + loggingLevelTelemetry: 1, + loggingLevelConsole: 1, + extensionConfig: { + [reactPlugin.identifier]: {}, + }, + disablePageUnloadEvents: ["unload"], }, - disablePageUnloadEvents: ["unload"], - }, - }); + }); - if (appInsights.config.connectionString) { - appInsights.loadAppInsights(); - // eslint-disable-next-line no-console - console.log("✅ App Insights - Client Side logging is turned on!"); - // eslint-disable-next-line no-console - console.log(` 📊 Client Sampling: ${clientSamplingPercentage}%`); - } else { - // eslint-disable-next-line no-console - console.log("Client side logging is not turned on!"); - } + if (ai.config.connectionString) { + ai.loadAppInsights(); + appInsights = ai; + // Replay any web-vitals measured before the SDK was ready. + flushWebVitals((metric, properties) => + reactPlugin.trackMetric(metric, properties) + ); + // eslint-disable-next-line no-console + console.log("✅ App Insights - Client Side logging is turned on!"); + // eslint-disable-next-line no-console + console.log(` 📊 Client Sampling: ${clientSamplingPercentage}%`); + } else { + // eslint-disable-next-line no-console + console.log("Client side logging is not turned on!"); + } + }; + + const cancelIdle = whenIdle(() => { + void init(); + }); return () => { - appInsights.unload(); + cancelled = true; + cancelIdle(); + resetWebVitalsSink(); + appInsights?.unload(); }; }, [reactPlugin]); diff --git a/context/app-insights-web-vitals-buffer.test.ts b/context/app-insights-web-vitals-buffer.test.ts new file mode 100644 index 0000000000..36b06866b2 --- /dev/null +++ b/context/app-insights-web-vitals-buffer.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { + flushWebVitals, + resetWebVitalsSink, + trackWebVital, +} from "./app-insights-web-vitals-buffer"; + +describe("web-vitals buffer", () => { + beforeEach(() => resetWebVitalsSink()); + + it("buffers metrics reported before flush, replays them in order, then passes through", () => { + const sink = jest.fn(); + + // Reported before the SDK is ready -> buffered, not sent yet. + trackWebVital({ name: "LCP", average: 1 }, { page: "/a" }); + trackWebVital({ name: "CLS", average: 2 }, { page: "/a" }); + expect(sink).not.toHaveBeenCalled(); + + // SDK ready -> buffered metrics replay in order. + flushWebVitals(sink); + expect(sink).toHaveBeenCalledTimes(2); + expect(sink).toHaveBeenNthCalledWith( + 1, + { name: "LCP", average: 1 }, + { page: "/a" } + ); + expect(sink).toHaveBeenNthCalledWith( + 2, + { name: "CLS", average: 2 }, + { page: "/a" } + ); + + // Reported after flush -> straight through, no double-send of the buffer. + trackWebVital({ name: "INP", average: 3 }, { page: "/b" }); + expect(sink).toHaveBeenCalledTimes(3); + expect(sink).toHaveBeenNthCalledWith( + 3, + { name: "INP", average: 3 }, + { page: "/b" } + ); + }); +}); diff --git a/context/app-insights-web-vitals-buffer.ts b/context/app-insights-web-vitals-buffer.ts new file mode 100644 index 0000000000..5d4cd28751 --- /dev/null +++ b/context/app-insights-web-vitals-buffer.ts @@ -0,0 +1,45 @@ +// Buffers web-vitals metrics reported before App Insights finishes loading. +// Init is deferred out of the critical window (see app-insight-client.tsx), so +// early metrics (LCP/FCP/TTFB fired during hydration) would otherwise be lost. +// They queue here and replay on flush once the SDK is ready. + +type WebVitalMetric = { name: string; average: number }; +type WebVitalProperties = { page: string }; + +type MetricSink = ( + metric: WebVitalMetric, + properties: WebVitalProperties +) => void; + +let sink: MetricSink | null = null; +const buffer: Array<{ + metric: WebVitalMetric; + properties: WebVitalProperties; +}> = []; + +export function trackWebVital( + metric: WebVitalMetric, + properties: WebVitalProperties +) { + if (sink) { + sink(metric, properties); + } else { + buffer.push({ metric, properties }); + } +} + +// Called once App Insights has loaded: drains the buffer in order and routes +// all subsequent metrics straight through. +export function flushWebVitals(nextSink: MetricSink) { + sink = nextSink; + for (const { metric, properties } of buffer) { + nextSink(metric, properties); + } + buffer.length = 0; +} + +// Called on provider unmount so metrics buffer again against a fresh SDK +// (e.g. React StrictMode's mount/unmount/mount in dev). +export function resetWebVitalsSink() { + sink = null; +} From 2c398c894b44ea31533830221088ece0c594c1a9 Mon Sep 17 00:00:00 2001 From: isaaclombardssw Date: Thu, 23 Jul 2026 16:35:38 +1000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20Cover=20reset/re-buffer=20pa?= =?UTF-8?q?th=20in=20web-vitals=20buffer=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a case exercising flush -> resetWebVitalsSink -> re-buffer -> flush to a fresh sink, verifying the StrictMode/remount safety property: only post-reset metrics replay, once, and the old sink is never re-fired. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P --- .../app-insights-web-vitals-buffer.test.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/context/app-insights-web-vitals-buffer.test.ts b/context/app-insights-web-vitals-buffer.test.ts index 36b06866b2..56925fbbb1 100644 --- a/context/app-insights-web-vitals-buffer.test.ts +++ b/context/app-insights-web-vitals-buffer.test.ts @@ -39,4 +39,27 @@ describe("web-vitals buffer", () => { { page: "/b" } ); }); + + it("re-buffers after reset and replays to a fresh sink without double-sending the old one (StrictMode/remount)", () => { + const firstSink = jest.fn(); + trackWebVital({ name: "LCP", average: 1 }, { page: "/a" }); + flushWebVitals(firstSink); + expect(firstSink).toHaveBeenCalledTimes(1); + + // Provider unmounts -> sink cleared, later metrics buffer again. + resetWebVitalsSink(); + trackWebVital({ name: "INP", average: 2 }, { page: "/b" }); + expect(firstSink).toHaveBeenCalledTimes(1); // no send to the unloaded sink + + // Fresh SDK on remount -> only the post-reset metric replays, once. + const secondSink = jest.fn(); + flushWebVitals(secondSink); + expect(secondSink).toHaveBeenCalledTimes(1); + expect(secondSink).toHaveBeenNthCalledWith( + 1, + { name: "INP", average: 2 }, + { page: "/b" } + ); + expect(firstSink).toHaveBeenCalledTimes(1); // old sink never re-fired + }); });