diff --git a/.changeset/tpr-analytics-query-fix.md b/.changeset/tpr-analytics-query-fix.md new file mode 100644 index 000000000..a770afa9c --- /dev/null +++ b/.changeset/tpr-analytics-query-fix.md @@ -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. diff --git a/packages/cloudflare/src/tpr.ts b/packages/cloudflare/src/tpr.ts index c26d4546e..47455295d 100644 --- a/packages/cloudflare/src/tpr.ts +++ b/packages/cloudflare/src/tpr.ts @@ -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; @@ -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 { +export async function resolveZoneId(domain: string, apiToken: string): Promise { // 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; @@ -588,7 +588,7 @@ async function resolveAccountId(apiToken: string): Promise { * 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, @@ -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 } } } @@ -634,7 +634,7 @@ async function queryTraffic( viewer?: { zones?: Array<{ httpRequestsAdaptiveGroups?: Array<{ - sum: { requests: number }; + count: number; dimensions: { clientRequestPath: string }; }>; }>; @@ -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 @@ -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, diff --git a/tests/tpr-traffic-query.test.ts b/tests/tpr-traffic-query.test.ts new file mode 100644 index 000000000..f841651af --- /dev/null +++ b/tests/tpr-traffic-query.test.ts @@ -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, + }); + }); +});