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..56925fbbb1 --- /dev/null +++ b/context/app-insights-web-vitals-buffer.test.ts @@ -0,0 +1,65 @@ +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" } + ); + }); + + 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 + }); +}); 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; +}