Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/components/web-vitals.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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}` }
);
Expand Down
122 changes: 81 additions & 41 deletions context/app-insight-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand Down
65 changes: 65 additions & 0 deletions context/app-insights-web-vitals-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
45 changes: 45 additions & 0 deletions context/app-insights-web-vitals-buffer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading