Skip to content
Open
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
80 changes: 80 additions & 0 deletions src/app/__tests__/reportWebVitals.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
expect(payload.delta).toBe(0.988);
expect(payload.navigationType).toBe("navigate");
});
});
2 changes: 1 addition & 1 deletion src/app/api/internal/vitals/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { sanitizeMetricPayload } from "../route";
import { sanitizeMetricPayload } from "../sanitizeMetricPayload";

describe("sanitizeMetricPayload", () => {
beforeEach(() => {
Expand Down
163 changes: 1 addition & 162 deletions src/app/api/internal/vitals/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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<WebVitalPayload>;

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<WebVitalPayload>).delta)
? toRounded((payload as Partial<WebVitalPayload>).delta as number)
: undefined,
timestamp: isFiniteNumber(payload.timestamp)
? Number(payload.timestamp)
: Date.now(),
} satisfies WebVitalPayload;
}

if (type === "resource-timing") {
const candidate = payload as Partial<ResourceTimingPayload>;
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<LongTaskPayload>;
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);
Expand Down
Loading