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
9 changes: 9 additions & 0 deletions .changeset/tpr-analytics-query-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@vinext/cloudflare": patch
---

Fix TPR's zone-analytics query, and export its traffic-selection helpers

`queryTraffic` asked `httpRequestsAdaptiveGroups` for `orderBy: [sum_requests_DESC]` and `sum { requests }`. Neither exists on that dataset — the API rejects them with `unknown enum value sum_requests_DESC` and `unknown field "requests"` (schema validation, so it fails on every zone regardless of plan). Since `runTPR` treats a failed traffic query as "no traffic data" and skips gracefully, `--experimental-tpr` silently pre-rendered nothing. Corrected to `count_DESC` / `count`.

`queryTraffic`, `filterTrafficPaths`, `selectRoutes` and `resolveZoneId` are now exported from `@vinext/cloudflare/internal/tpr`, so traffic-ranked route selection can be reused with `warmCdnCache` — which is already public, but has no way to decide _which_ paths to warm.
20 changes: 10 additions & 10 deletions packages/cloudflare/src/tpr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ export type TPRResult = {
skipped?: string;
};

type TrafficEntry = {
export type TrafficEntry = {
path: string;
requests: number;
};

type SelectedRoutes = {
export type SelectedRoutes = {
routes: TrafficEntry[];
totalRequests: number;
coveredRequests: number;
Expand Down Expand Up @@ -532,7 +532,7 @@ export function domainCandidates(domain: string): string[] {
}

/** Resolve zone ID from a domain name via the Cloudflare API. */
async function resolveZoneId(domain: string, apiToken: string): Promise<string | null> {
export async function resolveZoneId(domain: string, apiToken: string): Promise<string | null> {
// Try progressively longer domain candidates until one matches a zone.
// This handles all public suffixes without a hardcoded TLD list —
// for simple TLDs (.com, .io) the 2-part candidate hits on the first try;
Expand Down Expand Up @@ -588,7 +588,7 @@ async function resolveAccountId(apiToken: string): Promise<string | null> {
* Query Cloudflare zone analytics for top page paths by request count
* over the given time window.
*/
async function queryTraffic(
export async function queryTraffic(
zoneTag: string,
apiToken: string,
windowHours: number,
Expand All @@ -601,14 +601,14 @@ async function queryTraffic(
zones(filter: { zoneTag: "${zoneTag}" }) {
httpRequestsAdaptiveGroups(
limit: 10000
orderBy: [sum_requests_DESC]
orderBy: [count_DESC]
filter: {
datetime_geq: "${start.toISOString()}"
datetime_lt: "${now.toISOString()}"
requestSource: "eyeball"
}
) {
sum { requests }
count
dimensions { clientRequestPath }
}
}
Expand All @@ -634,7 +634,7 @@ async function queryTraffic(
viewer?: {
zones?: Array<{
httpRequestsAdaptiveGroups?: Array<{
sum: { requests: number };
count: number;
dimensions: { clientRequestPath: string };
}>;
}>;
Expand All @@ -652,13 +652,13 @@ async function queryTraffic(
return filterTrafficPaths(
groups.map((g) => ({
path: g.dimensions.clientRequestPath,
requests: g.sum.requests,
requests: g.count,
})),
);
}

/** Filter out non-page requests (static assets, API routes, internal routes). */
function filterTrafficPaths(entries: TrafficEntry[]): TrafficEntry[] {
export function filterTrafficPaths(entries: TrafficEntry[]): TrafficEntry[] {
return entries.filter((e) => {
if (!e.path.startsWith("/")) return false;
// Static assets
Expand All @@ -680,7 +680,7 @@ function filterTrafficPaths(entries: TrafficEntry[]): TrafficEntry[] {
* Walk the ranked traffic list, accumulating request counts until the
* coverage target is met or the hard cap is reached.
*/
function selectRoutes(
export function selectRoutes(
traffic: TrafficEntry[],
coverageTarget: number,
limit: number,
Expand Down
138 changes: 138 additions & 0 deletions tests/tpr-traffic-query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/**
* Tests for TPR's zone-analytics traffic query and route selection.
*
* The query is the step that decides which pages TPR pre-renders, and a
* malformed one is invisible: runTPR treats a query failure as "no traffic
* data" and skips gracefully, so TPR silently does nothing. These tests pin
* the parts of the GraphQL request the schema actually validates, and the
* shape of the response that is parsed back out.
*/
import { describe, it, expect, vi, afterEach } from "vite-plus/test";
import {
queryTraffic,
filterTrafficPaths,
selectRoutes,
type TrafficEntry,
} from "../packages/cloudflare/src/tpr.js";

/** Capture the outgoing GraphQL query and reply with a canned analytics payload. */
function mockAnalytics(
groups: Array<{ count: number; dimensions: { clientRequestPath: string } }>,
): { calls: string[] } {
const calls: string[] = [];
vi.stubGlobal("fetch", async (_url: string, init?: RequestInit) => {
const body = typeof init?.body === "string" ? init.body : "{}";
calls.push((JSON.parse(body) as { query: string }).query);
return new Response(
JSON.stringify({ data: { viewer: { zones: [{ httpRequestsAdaptiveGroups: groups }] } } }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
return { calls };
}

describe("queryTraffic", () => {
afterEach(() => vi.unstubAllGlobals());

it("requests fields that exist on httpRequestsAdaptiveGroups", async () => {
const { calls } = mockAnalytics([]);
await queryTraffic("zone-tag", "token", 24);

// `httpRequestsAdaptiveGroups` exposes `count`, not `sum { requests }`.
// Asking for the latter fails schema validation outright:
// orderBy: unknown enum value sum_requests_DESC
// unknown field "requests"
// Both are rejected regardless of zone plan, so the query never returns.
expect(calls[0]).toContain("orderBy: [count_DESC]");
// `count` as a selected field, on its own line — asserting the substring
// alone would be satisfied by `count_DESC` in orderBy and prove nothing.
expect(calls[0]).toMatch(/^\s*count\s*$/m);
expect(calls[0]).not.toContain("sum_requests_DESC");
expect(calls[0]).not.toContain("sum { requests }");
});

it("counts only eyeball traffic over the requested window", async () => {
const { calls } = mockAnalytics([]);
await queryTraffic("zone-tag", "token", 24);

// Worker-to-worker and internal subrequests are not pages a visitor asked
// for, so pre-rendering them would spend the budget on the wrong routes.
expect(calls[0]).toContain('requestSource: "eyeball"');
expect(calls[0]).toContain("datetime_geq:");
expect(calls[0]).toContain("datetime_lt:");
});

it("maps the response into traffic entries", async () => {
mockAnalytics([
{ count: 120, dimensions: { clientRequestPath: "/blog" } },
{ count: 30, dimensions: { clientRequestPath: "/about" } },
]);

expect(await queryTraffic("zone-tag", "token", 24)).toEqual([
{ path: "/blog", requests: 120 },
{ path: "/about", requests: 30 },
]);
});

it("surfaces GraphQL errors instead of reporting empty traffic", async () => {
// A silent empty result is indistinguishable from a zone with no traffic,
// which is what made the malformed query undetectable.
vi.stubGlobal(
"fetch",
async () =>
new Response(JSON.stringify({ errors: [{ message: "unknown enum value" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);

await expect(queryTraffic("zone-tag", "token", 24)).rejects.toThrow(/unknown enum value/);
});
});

describe("filterTrafficPaths", () => {
it("keeps pages and drops everything that is not one", () => {
const entries: TrafficEntry[] = [
{ path: "/", requests: 10 },
{ path: "/blog/post", requests: 9 },
{ path: "/_next/static/chunks/main.js", requests: 8 },
{ path: "/styles/app.css", requests: 7 },
{ path: "/logo.svg", requests: 6 },
{ path: "/api/revalidate", requests: 5 },
{ path: "/__vinext/internal", requests: 4 },
{ path: "/blog/post.rsc", requests: 3 },
{ path: "not-a-path", requests: 2 },
];

expect(filterTrafficPaths(entries).map((e) => e.path)).toEqual(["/", "/blog/post"]);
});
});

describe("selectRoutes", () => {
const traffic: TrafficEntry[] = [
{ path: "/a", requests: 70 },
{ path: "/b", requests: 20 },
{ path: "/c", requests: 10 },
];

it("stops once the coverage target is met", () => {
const selected = selectRoutes(traffic, 90, 1000);
expect(selected.routes.map((r) => r.path)).toEqual(["/a", "/b"]);
expect(selected.totalRequests).toBe(100);
expect(selected.coveredRequests).toBe(90);
expect(selected.coveragePercent).toBe(90);
});

it("honours the hard cap before the coverage target", () => {
expect(selectRoutes(traffic, 100, 1).routes.map((r) => r.path)).toEqual(["/a"]);
});

it("returns nothing for a zone with no traffic", () => {
expect(selectRoutes([], 90, 1000)).toEqual({
routes: [],
totalRequests: 0,
coveredRequests: 0,
coveragePercent: 0,
});
});
});