From fb07790e63e50fddf95320582321a05dec1e1bb3 Mon Sep 17 00:00:00 2001 From: TechWIthTy <37724661+TechWithTy@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:25:57 -0600 Subject: [PATCH 1/2] Fix vitals route helper export --- .../internal/vitals/__tests__/route.test.ts | 2 +- src/app/api/internal/vitals/route.ts | 163 +---------------- .../internal/vitals/sanitizeMetricPayload.ts | 168 ++++++++++++++++++ 3 files changed, 170 insertions(+), 163 deletions(-) create mode 100644 src/app/api/internal/vitals/sanitizeMetricPayload.ts diff --git a/src/app/api/internal/vitals/__tests__/route.test.ts b/src/app/api/internal/vitals/__tests__/route.test.ts index 1f10fef4..e87bf5ed 100644 --- a/src/app/api/internal/vitals/__tests__/route.test.ts +++ b/src/app/api/internal/vitals/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { sanitizeMetricPayload } from "../route"; +import { sanitizeMetricPayload } from "../sanitizeMetricPayload"; describe("sanitizeMetricPayload", () => { beforeEach(() => { diff --git a/src/app/api/internal/vitals/route.ts b/src/app/api/internal/vitals/route.ts index 99bcb14f..6eea5d8d 100644 --- a/src/app/api/internal/vitals/route.ts +++ b/src/app/api/internal/vitals/route.ts @@ -1,167 +1,6 @@ import { NextResponse } from "next/server"; -type MetricType = "web-vital" | "resource-timing" | "long-task"; - -interface BaseMetricPayload { - type: MetricType; - id: string; - page?: string; - timestamp?: number; -} - -interface WebVitalPayload extends BaseMetricPayload { - type: "web-vital"; - name: string; - label: string; - value: number; - navigationType?: string; - rating?: string; - delta?: number; -} - -interface ResourceTimingPayload extends BaseMetricPayload { - type: "resource-timing"; - name: string; - initiatorType?: string; - transferSize?: number; - encodedBodySize?: number; - decodedBodySize?: number; - duration: number; - startTime?: number; - isThirdParty?: boolean; - renderBlockingStatus?: string; -} - -interface LongTaskPayload extends BaseMetricPayload { - type: "long-task"; - name: string; - duration: number; - startTime?: number; - attribution?: string; -} - -type MetricPayload = WebVitalPayload | ResourceTimingPayload | LongTaskPayload; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function toRounded(value: number) { - return Number(value.toFixed(3)); -} - -export function sanitizeMetricPayload(payload: unknown): MetricPayload | null { - if (!isRecord(payload)) { - return null; - } - - const type = payload.type; - - if (type === "web-vital") { - const { id, name, label, value } = payload as Partial; - - if (!id || !name || !label || !isFiniteNumber(value)) { - return null; - } - - return { - type, - id, - name, - label, - value: toRounded(value), - page: typeof payload.page === "string" ? payload.page : undefined, - navigationType: - typeof payload.navigationType === "string" - ? payload.navigationType - : undefined, - rating: typeof payload.rating === "string" ? payload.rating : undefined, - delta: isFiniteNumber((payload as Partial).delta) - ? toRounded((payload as Partial).delta as number) - : undefined, - timestamp: isFiniteNumber(payload.timestamp) - ? Number(payload.timestamp) - : Date.now(), - } satisfies WebVitalPayload; - } - - if (type === "resource-timing") { - const candidate = payload as Partial; - const { id, name, duration } = candidate; - - if (!id || !name || !isFiniteNumber(duration)) { - return null; - } - - return { - type, - id, - name, - duration: Number(duration), - initiatorType: - typeof candidate.initiatorType === "string" - ? candidate.initiatorType - : undefined, - transferSize: isFiniteNumber(candidate.transferSize) - ? Math.round(candidate.transferSize) - : undefined, - encodedBodySize: isFiniteNumber(candidate.encodedBodySize) - ? Math.round(candidate.encodedBodySize) - : undefined, - decodedBodySize: isFiniteNumber(candidate.decodedBodySize) - ? Math.round(candidate.decodedBodySize) - : undefined, - startTime: isFiniteNumber(candidate.startTime) - ? Number(candidate.startTime) - : undefined, - isThirdParty: - typeof candidate.isThirdParty === "boolean" - ? candidate.isThirdParty - : undefined, - renderBlockingStatus: - typeof candidate.renderBlockingStatus === "string" - ? candidate.renderBlockingStatus - : undefined, - page: typeof candidate.page === "string" ? candidate.page : undefined, - timestamp: isFiniteNumber(candidate.timestamp) - ? Number(candidate.timestamp) - : Date.now(), - } satisfies ResourceTimingPayload; - } - - if (type === "long-task") { - const candidate = payload as Partial; - const { id, name, duration } = candidate; - - if (!id || !name || !isFiniteNumber(duration)) { - return null; - } - - return { - type, - id, - name, - duration: Number(duration), - startTime: isFiniteNumber(candidate.startTime) - ? Number(candidate.startTime) - : undefined, - attribution: - typeof candidate.attribution === "string" - ? candidate.attribution - : undefined, - page: typeof candidate.page === "string" ? candidate.page : undefined, - timestamp: isFiniteNumber(candidate.timestamp) - ? Number(candidate.timestamp) - : Date.now(), - } satisfies LongTaskPayload; - } - - return null; -} +import { sanitizeMetricPayload } from "./sanitizeMetricPayload"; export async function POST(request: Request) { const incoming = await request.json().catch(() => undefined); diff --git a/src/app/api/internal/vitals/sanitizeMetricPayload.ts b/src/app/api/internal/vitals/sanitizeMetricPayload.ts new file mode 100644 index 00000000..4f03c902 --- /dev/null +++ b/src/app/api/internal/vitals/sanitizeMetricPayload.ts @@ -0,0 +1,168 @@ +/** + * Supported metric payload types from the client. + */ +export type MetricType = "web-vital" | "resource-timing" | "long-task"; + +interface BaseMetricPayload { + type: MetricType; + id: string; + page?: string; + timestamp?: number; +} + +interface WebVitalPayload extends BaseMetricPayload { + type: "web-vital"; + name: string; + label: string; + value: number; + navigationType?: string; + rating?: string; + delta?: number; +} + +interface ResourceTimingPayload extends BaseMetricPayload { + type: "resource-timing"; + name: string; + initiatorType?: string; + transferSize?: number; + encodedBodySize?: number; + decodedBodySize?: number; + duration: number; + startTime?: number; + isThirdParty?: boolean; + renderBlockingStatus?: string; +} + +interface LongTaskPayload extends BaseMetricPayload { + type: "long-task"; + name: string; + duration: number; + startTime?: number; + attribution?: string; +} + +export type MetricPayload = + | WebVitalPayload + | ResourceTimingPayload + | LongTaskPayload; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function toRounded(value: number) { + return Number(value.toFixed(3)); +} + +export function sanitizeMetricPayload(payload: unknown): MetricPayload | null { + if (!isRecord(payload)) { + return null; + } + + const type = payload.type; + + if (type === "web-vital") { + const { id, name, label, value } = payload as Partial; + + if (!id || !name || !label || !isFiniteNumber(value)) { + return null; + } + + return { + type, + id, + name, + label, + value: toRounded(value), + page: typeof payload.page === "string" ? payload.page : undefined, + navigationType: + typeof payload.navigationType === "string" + ? payload.navigationType + : undefined, + rating: typeof payload.rating === "string" ? payload.rating : undefined, + delta: isFiniteNumber((payload as Partial).delta) + ? toRounded((payload as Partial).delta as number) + : undefined, + timestamp: isFiniteNumber(payload.timestamp) + ? Number(payload.timestamp) + : Date.now(), + } satisfies WebVitalPayload; + } + + if (type === "resource-timing") { + const candidate = payload as Partial; + const { id, name, duration } = candidate; + + if (!id || !name || !isFiniteNumber(duration)) { + return null; + } + + return { + type, + id, + name, + duration: Number(duration), + initiatorType: + typeof candidate.initiatorType === "string" + ? candidate.initiatorType + : undefined, + transferSize: isFiniteNumber(candidate.transferSize) + ? Math.round(candidate.transferSize) + : undefined, + encodedBodySize: isFiniteNumber(candidate.encodedBodySize) + ? Math.round(candidate.encodedBodySize) + : undefined, + decodedBodySize: isFiniteNumber(candidate.decodedBodySize) + ? Math.round(candidate.decodedBodySize) + : undefined, + startTime: isFiniteNumber(candidate.startTime) + ? Number(candidate.startTime) + : undefined, + isThirdParty: + typeof candidate.isThirdParty === "boolean" + ? candidate.isThirdParty + : undefined, + renderBlockingStatus: + typeof candidate.renderBlockingStatus === "string" + ? candidate.renderBlockingStatus + : undefined, + page: typeof candidate.page === "string" ? candidate.page : undefined, + timestamp: isFiniteNumber(candidate.timestamp) + ? Number(candidate.timestamp) + : Date.now(), + } satisfies ResourceTimingPayload; + } + + if (type === "long-task") { + const candidate = payload as Partial; + const { id, name, duration } = candidate; + + if (!id || !name || !isFiniteNumber(duration)) { + return null; + } + + return { + type, + id, + name, + duration: Number(duration), + startTime: isFiniteNumber(candidate.startTime) + ? Number(candidate.startTime) + : undefined, + attribution: + typeof candidate.attribution === "string" + ? candidate.attribution + : undefined, + page: typeof candidate.page === "string" ? candidate.page : undefined, + timestamp: isFiniteNumber(candidate.timestamp) + ? Number(candidate.timestamp) + : Date.now(), + } satisfies LongTaskPayload; + } + + return null; +} From c6fc95f7c8fc3881eeb77195affef0f61671f043 Mon Sep 17 00:00:00 2001 From: TechWIthTy <37724661+TechWithTy@users.noreply.github.com> Date: Sun, 5 Oct 2025 10:36:02 -0600 Subject: [PATCH 2/2] Fix web vitals delta typing and add regression test --- src/app/__tests__/reportWebVitals.test.ts | 80 +++++++++++++++++++++++ src/app/reportWebVitals.ts | 20 ++++-- 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 src/app/__tests__/reportWebVitals.test.ts diff --git a/src/app/__tests__/reportWebVitals.test.ts b/src/app/__tests__/reportWebVitals.test.ts new file mode 100644 index 00000000..eaae9472 --- /dev/null +++ b/src/app/__tests__/reportWebVitals.test.ts @@ -0,0 +1,80 @@ +import type { NextWebVitalsMetric } from "next/app"; + +describe("reportWebVitals", () => { + const originalLocation = window.location; + const originalPerformance = window.performance; + const originalSendBeacon = window.navigator.sendBeacon; + let consoleDebugSpy: jest.SpyInstance; + + beforeEach(() => { + consoleDebugSpy = jest.spyOn(console, "debug").mockImplementation(() => undefined); + + Object.defineProperty(window, "location", { + configurable: true, + value: { pathname: "/pricing" } as Location, + }); + + Object.defineProperty(window, "performance", { + configurable: true, + value: { + getEntriesByType: jest + .fn() + .mockReturnValue([ + { type: "navigate" } as PerformanceNavigationTiming, + ]), + } as Performance, + }); + + Object.defineProperty(window.navigator, "sendBeacon", { + configurable: true, + value: jest.fn().mockReturnValue(true), + }); + }); + + afterEach(() => { + jest.resetModules(); + + consoleDebugSpy.mockRestore(); + + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + + Object.defineProperty(window, "performance", { + configurable: true, + value: originalPerformance, + }); + + Object.defineProperty(window.navigator, "sendBeacon", { + configurable: true, + value: originalSendBeacon, + }); + }); + + it("dispatches a payload with a rounded delta when present", () => { + jest.isolateModules(() => { + const { reportWebVitals } = require("../reportWebVitals") as { + reportWebVitals: (metric: NextWebVitalsMetric) => void; + }; + + const metric = { + id: "v1", + label: "web-vital" as const, + name: "CLS" as const, + startTime: 0, + value: 1.2345, + delta: 0.9876, + } as NextWebVitalsMetric & { delta: number }; + + reportWebVitals(metric); + }); + + const sendBeacon = window.navigator.sendBeacon as jest.Mock; + expect(sendBeacon).toHaveBeenCalledTimes(1); + + const payload = JSON.parse(sendBeacon.mock.calls[0][1] as string) as Record; + expect(payload.delta).toBe(0.988); + expect(payload.navigationType).toBe("navigate"); + }); +}); diff --git a/src/app/reportWebVitals.ts b/src/app/reportWebVitals.ts index 351f7117..1fff09f8 100644 --- a/src/app/reportWebVitals.ts +++ b/src/app/reportWebVitals.ts @@ -11,10 +11,18 @@ if (typeof window !== "undefined" && "performance" in window) { } const vitalsEndpoint = - process.env.NEXT_PUBLIC_VITALS_ENDPOINT && - process.env.NEXT_PUBLIC_VITALS_ENDPOINT.length > 0 - ? process.env.NEXT_PUBLIC_VITALS_ENDPOINT - : DEFAULT_ENDPOINT; + process.env.NEXT_PUBLIC_VITALS_ENDPOINT && + process.env.NEXT_PUBLIC_VITALS_ENDPOINT.length > 0 + ? process.env.NEXT_PUBLIC_VITALS_ENDPOINT + : DEFAULT_ENDPOINT; + +type MetricWithNumericDelta = NextWebVitalsMetric & { delta: number }; + +function metricHasNumericDelta( + metric: NextWebVitalsMetric, +): metric is MetricWithNumericDelta { + return typeof (metric as { delta?: unknown }).delta === "number"; +} function sendMetric(body: string) { if (typeof navigator !== "undefined" && "sendBeacon" in navigator) { @@ -52,7 +60,9 @@ export function reportWebVitals(metric: NextWebVitalsMetric) { page: window.location.pathname, navigationType, rating: "rating" in metric ? metric.rating : undefined, - delta: "delta" in metric ? Number(metric.delta.toFixed(3)) : undefined, + delta: metricHasNumericDelta(metric) + ? Number(metric.delta.toFixed(3)) + : undefined, timestamp: Date.now(), };