Skip to content

Commit 5ee000c

Browse files
committed
fix(bot): fall back when analytics doubles are unavailable
1 parent 552a6aa commit 5ee000c

2 files changed

Lines changed: 152 additions & 12 deletions

File tree

src/lib/edge/__tests__/admin-bot-analytics.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,100 @@ describe("admin bot analytics handlers", () => {
329329
pointCount: 1,
330330
});
331331
});
332+
333+
it("falls back to blob-only Analytics Engine queries when double columns are unavailable", async () => {
334+
vi.spyOn(Date, "now").mockReturnValue(1_800_000_000_000);
335+
const encrypted = await import("@/lib/edge/secret-encryption").then(
336+
({ encryptBotAnalyticsSecret }) =>
337+
encryptBotAnalyticsSecret(
338+
{ MAIN_SECRET: "main-secret" },
339+
"cf_reader_token",
340+
),
341+
);
342+
const configSelect = statement({
343+
first: row({
344+
apiTokenEncrypted: encrypted,
345+
apiTokenHint: "••••oken",
346+
configured: true,
347+
}),
348+
});
349+
const siteSelect = statement({
350+
all: [{ id: "site-1", name: "Blog", domain: "example.test" }],
351+
});
352+
const env = createEnv([configSelect, siteSelect]);
353+
const fallbackBody = [
354+
JSON.stringify({
355+
timestamp: "2026-07-03 10:00:00",
356+
siteId: "site-1",
357+
kind: "pageview",
358+
confidence: "medium",
359+
reasons: "hosting_asn",
360+
ip: "203.0.113.8",
361+
userAgent: "Mozilla/5.0",
362+
origin: "https://example.test",
363+
hostname: "example.test",
364+
pathname: "/post",
365+
country: "JP",
366+
region: "Tokyo",
367+
city: "Tokyo",
368+
continent: "AS",
369+
colo: "NRT",
370+
asnText: "16509",
371+
asOrganization: "Amazon.com, Inc.",
372+
verifiedBotCategory: "",
373+
rayId: "ray-1",
374+
traceId: "trace-1",
375+
metadataJson: "{}",
376+
}),
377+
"",
378+
].join("\n");
379+
const fetchMock = vi
380+
.spyOn(globalThis, "fetch")
381+
.mockResolvedValueOnce(
382+
new Response(
383+
JSON.stringify({
384+
errors: [
385+
{
386+
message:
387+
'Input was invalid: unable to find type of column: "double1"',
388+
},
389+
],
390+
}),
391+
{ status: 422 },
392+
),
393+
)
394+
.mockResolvedValueOnce(new Response(fallbackBody, { status: 200 }));
395+
396+
const response = await handleBotAnalyticsAdmin(
397+
request("/api/private/admin/bot-analytics?minutes=60&limit=10"),
398+
env,
399+
new URL(
400+
"https://app.test/api/private/admin/bot-analytics?minutes=60&limit=10",
401+
),
402+
);
403+
const body = await jsonOf(response);
404+
405+
expect(response.status).toBe(200);
406+
expect(fetchMock).toHaveBeenCalledTimes(2);
407+
const firstSql = String(
408+
(fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.body || "",
409+
);
410+
const fallbackSql = String(
411+
(fetchMock.mock.calls[1]?.[1] as RequestInit | undefined)?.body || "",
412+
);
413+
expect(firstSql).toContain("double1 AS receivedAt");
414+
expect(fallbackSql).not.toContain("double1");
415+
expect(fallbackSql).toContain("ORDER BY timestamp DESC");
416+
expect(body.events[0]).toMatchObject({
417+
siteName: "Blog",
418+
asn: 16509,
419+
receivedAt: Date.UTC(2026, 6, 3, 10, 0, 0),
420+
});
421+
expect(body.summary).toMatchObject({
422+
total: 1,
423+
mediumConfidence: 1,
424+
uniqueAsns: 1,
425+
uniqueCountries: 1,
426+
});
427+
});
332428
});

src/lib/edge/admin-bot-analytics.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,24 @@ function buildBotAnalyticsSql(input: {
9292
dataset: string;
9393
since: number;
9494
limit: number;
95+
includeDoubles?: boolean;
9596
}) {
9697
const dataset = analyticsDatasetIdentifier(input.dataset);
9798
const sinceSeconds = Math.floor(input.since / 1000);
99+
const doubleSelect = input.includeDoubles
100+
? `,
101+
double1 AS receivedAt,
102+
double2 AS asn,
103+
double3 AS latitude,
104+
double4 AS longitude,
105+
double5 AS botScore,
106+
double6 AS userAgentLength`
107+
: "";
108+
const doubleFilter = input.includeDoubles
109+
? `
110+
AND double1 >= ${input.since}`
111+
: "";
112+
const orderBy = input.includeDoubles ? "double1" : "timestamp";
98113
return `
99114
SELECT
100115
timestamp,
@@ -117,17 +132,10 @@ function buildBotAnalyticsSql(input: {
117132
blob17 AS verifiedBotCategory,
118133
blob18 AS rayId,
119134
blob19 AS traceId,
120-
blob20 AS metadataJson,
121-
double1 AS receivedAt,
122-
double2 AS asn,
123-
double3 AS latitude,
124-
double4 AS longitude,
125-
double5 AS botScore,
126-
double6 AS userAgentLength
135+
blob20 AS metadataJson${doubleSelect}
127136
FROM ${dataset}
128-
WHERE timestamp >= toDateTime(${sinceSeconds})
129-
AND double1 >= ${input.since}
130-
ORDER BY double1 DESC
137+
WHERE timestamp >= toDateTime(${sinceSeconds})${doubleFilter}
138+
ORDER BY ${orderBy} DESC
131139
LIMIT ${input.limit}
132140
FORMAT JSONEachRow
133141
`;
@@ -184,6 +192,16 @@ function parseJsonEachRow(text: string): Record<string, unknown>[] {
184192
.map((line) => JSON.parse(line) as Record<string, unknown>);
185193
}
186194

195+
function parseAnalyticsTimestampMs(value: unknown): number {
196+
const text = String(value || "").trim();
197+
if (!text) return 0;
198+
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(text)
199+
? `${text.replace(" ", "T")}Z`
200+
: text;
201+
const parsed = Date.parse(normalized);
202+
return Number.isFinite(parsed) ? parsed : 0;
203+
}
204+
187205
function normalizeBotRow(
188206
row: Record<string, unknown>,
189207
sites: Map<string, { name: string; domain: string }>,
@@ -195,9 +213,11 @@ function normalizeBotRow(
195213
.map((reason) => reason.trim())
196214
.filter(Boolean);
197215
const botScore = toFiniteNumber(row.botScore, 0);
216+
const receivedAt =
217+
toFiniteNumber(row.receivedAt) || parseAnalyticsTimestampMs(row.timestamp);
198218
return {
199219
timestamp: clampString(String(row.timestamp || ""), 64),
200-
receivedAt: toFiniteNumber(row.receivedAt),
220+
receivedAt,
201221
siteId,
202222
siteName: clampString(site?.name || siteId || "Unknown site", 160),
203223
siteDomain: clampString(site?.domain || "", 255),
@@ -226,6 +246,17 @@ function normalizeBotRow(
226246
};
227247
}
228248

249+
function shouldRetryBotAnalyticsWithoutDoubles(result: {
250+
status: number;
251+
body: string;
252+
}): boolean {
253+
if (result.status !== 422) return false;
254+
const body = result.body.toLowerCase();
255+
return (
256+
body.includes("unable to find type of column") && /double\d+/.test(body)
257+
);
258+
}
259+
229260
function bucketSizeMs(minutes: number): number {
230261
if (minutes <= 60) return 5 * 60 * 1000;
231262
if (minutes <= 360) return 30 * 60 * 1000;
@@ -512,12 +543,25 @@ export async function handleBotAnalyticsAdmin(
512543
dataset: config.dataset,
513544
since: from,
514545
limit,
546+
includeDoubles: true,
515547
});
516-
const result = await queryCloudflareAnalyticsEngine({
548+
let result = await queryCloudflareAnalyticsEngine({
517549
accountId: config.accountId,
518550
token,
519551
sql,
520552
});
553+
if (!result.ok && shouldRetryBotAnalyticsWithoutDoubles(result)) {
554+
result = await queryCloudflareAnalyticsEngine({
555+
accountId: config.accountId,
556+
token,
557+
sql: buildBotAnalyticsSql({
558+
dataset: config.dataset,
559+
since: from,
560+
limit,
561+
includeDoubles: false,
562+
}),
563+
});
564+
}
521565
if (!result.ok) {
522566
return bad(
523567
cloudflareAnalyticsErrorMessage(result),

0 commit comments

Comments
 (0)