feat(analytics): 本地开发默认不查 Cloudflare Analytics

This commit is contained in:
Jeff Gu committed 2026-06-10 11:27:39 +08:00
1 parent 315e995656
commit 6cdca4a93b
3 files changed
+178 -45

No files matched your search

+24 -2
View File
@@ -4,6 +4,7 @@ import { z } from "zod";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
import {
AnalyticsQueryUpstreamError,
AnalyticsQueryValidationError,
queryUsageOverview,
queryUsageTrend,
@@ -13,6 +14,28 @@ import {
} from "../analytics/query";
const rangeSchema = z.enum(["24h", "7d", "30d", "90d"]).optional();
type AnalyticsErrorStatus = 400 | 401 | 403 | 404 | 429 | 500 | 502 | 504;
const toAnalyticsErrorStatus = (error: unknown): AnalyticsErrorStatus => {
if (error instanceof AnalyticsQueryValidationError) {
return 400;
}
if (error instanceof AnalyticsQueryUpstreamError) {
switch (error.statusCode) {
case 401:
case 403:
case 404:
case 429:
case 504:
return error.statusCode;
default:
return 502;
}
}
return 500;
};
const toErrorResponse = (
c: Context<HonoCustomType>,
@@ -20,8 +43,7 @@ const toErrorResponse = (
fallbackMessage: string
) => {
const message = error instanceof Error ? error.message : fallbackMessage;
const status = error instanceof AnalyticsQueryValidationError ? 400 : 500;
return c.text(message, status);
return c.text(message, toAnalyticsErrorStatus(error));
};
export class AnalyticsOverviewEndpoint extends OpenAPIRoute {
+153 -43
View File
@@ -61,7 +61,25 @@ type UsageLogQueryParams = {
export class AnalyticsQueryValidationError extends Error {}
export class AnalyticsQueryUpstreamError extends Error {
readonly statusCode: number;
constructor(message: string, statusCode = 502) {
super(message);
this.name = "AnalyticsQueryUpstreamError";
this.statusCode = statusCode;
}
}
export class AnalyticsQueryTimeoutError extends AnalyticsQueryUpstreamError {
constructor(timeoutMs: number) {
super(`Analytics query timed out after ${timeoutMs}ms`, 504);
this.name = "AnalyticsQueryTimeoutError";
}
}
const USAGE_LOG_PAGE_SIZE = 50;
const ANALYTICS_QUERY_TIMEOUT_MS = 10_000;
const RANGE_CONFIG: Record<AnalyticsRange, RangeConfig> = {
"24h": {
@@ -202,12 +220,6 @@ const USAGE_LOG_NUMERIC_COLUMNS = [
DOUBLE_FIELDS.cacheCost,
];
const ALL_PROBED_COLUMNS = [
...LEGACY_BLOB_COLUMNS,
...EXTENDED_LOG_COLUMNS,
...USAGE_LOG_NUMERIC_COLUMNS,
];
const DATASET_COLUMN_SUPPORT_CACHE_TTL_MS = 60_000;
const DATASET_COLUMN_SUPPORT_CACHE = new Map<string, {
expiresAt: number;
@@ -282,6 +294,26 @@ const getDatasetName = (c: Context<HonoCustomType>): string => {
return sanitizeDatasetName(c.env.USAGE_ANALYTICS_DATASET);
};
const isTruthyConfigValue = (value: string | undefined): boolean => {
return value === "1" || value?.toLowerCase() === "true";
};
const isFalseyConfigValue = (value: string | undefined): boolean => {
return value === "0" || value?.toLowerCase() === "false";
};
const isAnalyticsQueryDisabled = (c: Context<HonoCustomType>): boolean => {
if (isFalseyConfigValue(c.env.DISABLE_ANALYTICS_QUERIES)) {
return false;
}
if (isTruthyConfigValue(c.env.DISABLE_ANALYTICS_QUERIES)) {
return true;
}
return Boolean(c.env.FRONTEND_DEV_SERVER_URL);
};
const formatSqlDateTime = (date: Date): string => {
return date.toISOString().slice(0, 19).replace("T", " ");
};
@@ -420,17 +452,37 @@ const runAnalyticsQueryRaw = async <T extends Record<string, unknown>>(
}
const api = `https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/analytics_engine/sql`;
const response = await fetch(api, {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ANALYTICS_QUERY_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(api, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${c.env.CF_API_TOKEN}`,
"Content-Type": "text/plain;charset=UTF-8",
},
body: query,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted || (error instanceof Error && error.name === "AbortError")) {
throw new AnalyticsQueryTimeoutError(ANALYTICS_QUERY_TIMEOUT_MS);
}
throw error;
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `Analytics query failed with ${response.status}`);
throw new AnalyticsQueryUpstreamError(
errorText || `Analytics query failed with ${response.status}`,
response.status >= 500 ? 502 : response.status
);
}
const json = await response.json() as {
@@ -453,31 +505,6 @@ const runAnalyticsQuery = async <T extends Record<string, unknown>>(
return result.data;
};
const probeColumnAvailability = async (
c: Context<HonoCustomType>,
dataset: string,
columnName: string,
whereClause?: string
): Promise<boolean> => {
try {
await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
${columnName}
FROM ${dataset}
${whereClause ? `WHERE ${whereClause}` : ""}
LIMIT 1
`.trim());
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("unable to find type of column")) {
return false;
}
throw error;
}
};
const getDatasetColumnSupport = async (
c: Context<HonoCustomType>,
dataset: string,
@@ -491,19 +518,25 @@ const getDatasetColumnSupport = async (
const pending = (async () => {
const availableColumns = new Set<string>();
const results = await Promise.all(
ALL_PROBED_COLUMNS.map(async (columnName) => ({
columnName,
supported: await probeColumnAvailability(c, dataset, columnName, whereClause),
}))
);
results.forEach((result) => {
if (result.supported) {
availableColumns.add(result.columnName);
const result = await runAnalyticsQueryRaw<Record<string, unknown>>(c, `
SELECT
*
FROM ${dataset}
${whereClause ? `WHERE ${whereClause}` : ""}
LIMIT 1
`.trim());
result.meta?.forEach((column) => {
if (column.name) {
availableColumns.add(column.name);
}
});
result.data.forEach((row) => {
Object.keys(row).forEach((columnName) => availableColumns.add(columnName));
});
return { availableColumns };
})();
@@ -626,11 +659,66 @@ const buildUsageLogEmptyResponse = (
items: [],
});
const buildEmptyOverviewResponse = (range: AnalyticsRange) => ({
range,
totals: {
requests: 0,
successes: 0,
failures: 0,
successRate: 0,
totalCost: 0,
totalTokens: 0,
promptTokens: 0,
completionTokens: 0,
avgLatencyMs: 0,
},
});
const buildEmptyTrendResponse = (
range: AnalyticsRange,
config: RangeConfig
) => {
const trendWindow = buildTrendWindow(range, config);
return {
range,
bucket: config.bucketLabel,
points: trendWindow.bucketTimestamps.map((bucketTimestamp) => ({
timestamp: new Date(bucketTimestamp * 1000).toISOString(),
requests: 0,
successes: 0,
failures: 0,
successRate: 0,
totalCost: 0,
})),
};
};
const buildEmptyBreakdownResponse = (
range: AnalyticsRange,
dimension: AnalyticsBreakdownDimension
) => ({
range,
dimension,
items: [],
});
const buildEmptyEventsResponse = (range: AnalyticsRange) => ({
range,
sampled: true,
compatibilityWarning: undefined,
items: [],
});
export const queryUsageOverview = async (
c: Context<HonoCustomType>,
requestedRange?: string
) => {
const { range, config } = getRangeConfig(requestedRange);
if (isAnalyticsQueryDisabled(c)) {
return buildEmptyOverviewResponse(range);
}
const dataset = getDatasetName(c);
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
const normalizedTotalCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.totalCost);
@@ -672,6 +760,10 @@ export const queryUsageTrend = async (
requestedRange?: string
) => {
const { range, config } = getRangeConfig(requestedRange);
if (isAnalyticsQueryDisabled(c)) {
return buildEmptyTrendResponse(range, config);
}
const dataset = getDatasetName(c);
const trendWindow = buildTrendWindow(range, config);
const columnSupport = await getDatasetColumnSupport(c, dataset, trendWindow.whereClause);
@@ -722,6 +814,10 @@ export const queryUsageBreakdown = async (
const dimension = (requestedDimension && requestedDimension in BREAKDOWN_FIELDS
? requestedDimension
: "token") as AnalyticsBreakdownDimension;
if (isAnalyticsQueryDisabled(c)) {
return buildEmptyBreakdownResponse(range, dimension);
}
const dataset = getDatasetName(c);
const dimensionField = BREAKDOWN_FIELDS[dimension];
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
@@ -773,6 +869,10 @@ export const queryUsageEvents = async (
requestedLimit?: string
) => {
const { range, config } = getRangeConfig(requestedRange);
if (isAnalyticsQueryDisabled(c)) {
return buildEmptyEventsResponse(range);
}
const dataset = getDatasetName(c);
const lang = c.get('lang') || 'zh-CN';
const limit = Math.min(Math.max(Number(requestedLimit || 40) || 40, 1), 100);
@@ -878,7 +978,6 @@ export const queryUsageLogRecords = async (
c: Context<HonoCustomType>,
params: UsageLogQueryParams
) => {
const dataset = getDatasetName(c);
const lang = c.get('lang') || 'zh-CN';
const timeWindow = buildCustomTimeWindow(params.start, params.end, lang);
const requestedPage = Math.min(Math.max(Number(params.page || 1) || 1, 1), 1000);
@@ -887,6 +986,17 @@ export const queryUsageLogRecords = async (
: "token") as UsageLogFilterDimension;
const keyword = params.keyword?.trim();
const result = params.result === "success" || params.result === "failure" ? params.result : "all";
if (isAnalyticsQueryDisabled(c)) {
return buildUsageLogEmptyResponse(
timeWindow,
dimension,
keyword || "",
result,
undefined
);
}
const dataset = getDatasetName(c);
const baseCountRows = await runAnalyticsQuery<Record<string, unknown>>(c, `
SELECT
count() AS total
Vendored
+1
View File
@@ -22,6 +22,7 @@ type CloudflareBindings = {
FRONTEND_DEV_SERVER_URL?: string;
USAGE_ANALYTICS?: AnalyticsEngineDataset;
USAGE_ANALYTICS_DATASET?: string;
DISABLE_ANALYTICS_QUERIES?: string;
}
type HonoCustomType = {