feat(analytics): 本地开发默认不查 Cloudflare Analytics
This commit is contained in:
1 parent
315e995656
commit
6cdca4a93b
3 files changed
+184
-51
No files matched your search
@@ -4,6 +4,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
|
||||||
import {
|
import {
|
||||||
|
AnalyticsQueryUpstreamError,
|
||||||
AnalyticsQueryValidationError,
|
AnalyticsQueryValidationError,
|
||||||
queryUsageOverview,
|
queryUsageOverview,
|
||||||
queryUsageTrend,
|
queryUsageTrend,
|
||||||
@@ -13,6 +14,28 @@ import {
|
|||||||
} from "../analytics/query";
|
} from "../analytics/query";
|
||||||
|
|
||||||
const rangeSchema = z.enum(["24h", "7d", "30d", "90d"]).optional();
|
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 = (
|
const toErrorResponse = (
|
||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
@@ -20,8 +43,7 @@ const toErrorResponse = (
|
|||||||
fallbackMessage: string
|
fallbackMessage: string
|
||||||
) => {
|
) => {
|
||||||
const message = error instanceof Error ? error.message : fallbackMessage;
|
const message = error instanceof Error ? error.message : fallbackMessage;
|
||||||
const status = error instanceof AnalyticsQueryValidationError ? 400 : 500;
|
return c.text(message, toAnalyticsErrorStatus(error));
|
||||||
return c.text(message, status);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AnalyticsOverviewEndpoint extends OpenAPIRoute {
|
export class AnalyticsOverviewEndpoint extends OpenAPIRoute {
|
||||||
|
|||||||
+159
-49
@@ -61,7 +61,25 @@ type UsageLogQueryParams = {
|
|||||||
|
|
||||||
export class AnalyticsQueryValidationError extends Error {}
|
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 USAGE_LOG_PAGE_SIZE = 50;
|
||||||
|
const ANALYTICS_QUERY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const RANGE_CONFIG: Record<AnalyticsRange, RangeConfig> = {
|
const RANGE_CONFIG: Record<AnalyticsRange, RangeConfig> = {
|
||||||
"24h": {
|
"24h": {
|
||||||
@@ -202,12 +220,6 @@ const USAGE_LOG_NUMERIC_COLUMNS = [
|
|||||||
DOUBLE_FIELDS.cacheCost,
|
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_TTL_MS = 60_000;
|
||||||
const DATASET_COLUMN_SUPPORT_CACHE = new Map<string, {
|
const DATASET_COLUMN_SUPPORT_CACHE = new Map<string, {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -282,6 +294,26 @@ const getDatasetName = (c: Context<HonoCustomType>): string => {
|
|||||||
return sanitizeDatasetName(c.env.USAGE_ANALYTICS_DATASET);
|
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 => {
|
const formatSqlDateTime = (date: Date): string => {
|
||||||
return date.toISOString().slice(0, 19).replace("T", " ");
|
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 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();
|
||||||
method: "POST",
|
const timeout = setTimeout(() => controller.abort(), ANALYTICS_QUERY_TIMEOUT_MS);
|
||||||
headers: {
|
let response: Response;
|
||||||
Authorization: `Bearer ${c.env.CF_API_TOKEN}`,
|
|
||||||
},
|
try {
|
||||||
body: query,
|
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) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
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 {
|
const json = await response.json() as {
|
||||||
@@ -453,31 +505,6 @@ const runAnalyticsQuery = async <T extends Record<string, unknown>>(
|
|||||||
return result.data;
|
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 (
|
const getDatasetColumnSupport = async (
|
||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
dataset: string,
|
dataset: string,
|
||||||
@@ -491,19 +518,25 @@ const getDatasetColumnSupport = async (
|
|||||||
|
|
||||||
const pending = (async () => {
|
const pending = (async () => {
|
||||||
const availableColumns = new Set<string>();
|
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) => {
|
const result = await runAnalyticsQueryRaw<Record<string, unknown>>(c, `
|
||||||
if (result.supported) {
|
SELECT
|
||||||
availableColumns.add(result.columnName);
|
*
|
||||||
|
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 };
|
return { availableColumns };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -626,11 +659,66 @@ const buildUsageLogEmptyResponse = (
|
|||||||
items: [],
|
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 (
|
export const queryUsageOverview = async (
|
||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
requestedRange?: string
|
requestedRange?: string
|
||||||
) => {
|
) => {
|
||||||
const { range, config } = getRangeConfig(requestedRange);
|
const { range, config } = getRangeConfig(requestedRange);
|
||||||
|
if (isAnalyticsQueryDisabled(c)) {
|
||||||
|
return buildEmptyOverviewResponse(range);
|
||||||
|
}
|
||||||
|
|
||||||
const dataset = getDatasetName(c);
|
const dataset = getDatasetName(c);
|
||||||
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
|
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
|
||||||
const normalizedTotalCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.totalCost);
|
const normalizedTotalCostExpression = buildNormalizedCostExpression(columnSupport, DOUBLE_FIELDS.totalCost);
|
||||||
@@ -672,6 +760,10 @@ export const queryUsageTrend = async (
|
|||||||
requestedRange?: string
|
requestedRange?: string
|
||||||
) => {
|
) => {
|
||||||
const { range, config } = getRangeConfig(requestedRange);
|
const { range, config } = getRangeConfig(requestedRange);
|
||||||
|
if (isAnalyticsQueryDisabled(c)) {
|
||||||
|
return buildEmptyTrendResponse(range, config);
|
||||||
|
}
|
||||||
|
|
||||||
const dataset = getDatasetName(c);
|
const dataset = getDatasetName(c);
|
||||||
const trendWindow = buildTrendWindow(range, config);
|
const trendWindow = buildTrendWindow(range, config);
|
||||||
const columnSupport = await getDatasetColumnSupport(c, dataset, trendWindow.whereClause);
|
const columnSupport = await getDatasetColumnSupport(c, dataset, trendWindow.whereClause);
|
||||||
@@ -722,6 +814,10 @@ export const queryUsageBreakdown = async (
|
|||||||
const dimension = (requestedDimension && requestedDimension in BREAKDOWN_FIELDS
|
const dimension = (requestedDimension && requestedDimension in BREAKDOWN_FIELDS
|
||||||
? requestedDimension
|
? requestedDimension
|
||||||
: "token") as AnalyticsBreakdownDimension;
|
: "token") as AnalyticsBreakdownDimension;
|
||||||
|
if (isAnalyticsQueryDisabled(c)) {
|
||||||
|
return buildEmptyBreakdownResponse(range, dimension);
|
||||||
|
}
|
||||||
|
|
||||||
const dataset = getDatasetName(c);
|
const dataset = getDatasetName(c);
|
||||||
const dimensionField = BREAKDOWN_FIELDS[dimension];
|
const dimensionField = BREAKDOWN_FIELDS[dimension];
|
||||||
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
|
const columnSupport = await getDatasetColumnSupport(c, dataset, buildRangeWhereClause(config));
|
||||||
@@ -773,6 +869,10 @@ export const queryUsageEvents = async (
|
|||||||
requestedLimit?: string
|
requestedLimit?: string
|
||||||
) => {
|
) => {
|
||||||
const { range, config } = getRangeConfig(requestedRange);
|
const { range, config } = getRangeConfig(requestedRange);
|
||||||
|
if (isAnalyticsQueryDisabled(c)) {
|
||||||
|
return buildEmptyEventsResponse(range);
|
||||||
|
}
|
||||||
|
|
||||||
const dataset = getDatasetName(c);
|
const dataset = getDatasetName(c);
|
||||||
const lang = c.get('lang') || 'zh-CN';
|
const lang = c.get('lang') || 'zh-CN';
|
||||||
const limit = Math.min(Math.max(Number(requestedLimit || 40) || 40, 1), 100);
|
const limit = Math.min(Math.max(Number(requestedLimit || 40) || 40, 1), 100);
|
||||||
@@ -878,7 +978,6 @@ export const queryUsageLogRecords = async (
|
|||||||
c: Context<HonoCustomType>,
|
c: Context<HonoCustomType>,
|
||||||
params: UsageLogQueryParams
|
params: UsageLogQueryParams
|
||||||
) => {
|
) => {
|
||||||
const dataset = getDatasetName(c);
|
|
||||||
const lang = c.get('lang') || 'zh-CN';
|
const lang = c.get('lang') || 'zh-CN';
|
||||||
const timeWindow = buildCustomTimeWindow(params.start, params.end, lang);
|
const timeWindow = buildCustomTimeWindow(params.start, params.end, lang);
|
||||||
const requestedPage = Math.min(Math.max(Number(params.page || 1) || 1, 1), 1000);
|
const requestedPage = Math.min(Math.max(Number(params.page || 1) || 1, 1), 1000);
|
||||||
@@ -887,6 +986,17 @@ export const queryUsageLogRecords = async (
|
|||||||
: "token") as UsageLogFilterDimension;
|
: "token") as UsageLogFilterDimension;
|
||||||
const keyword = params.keyword?.trim();
|
const keyword = params.keyword?.trim();
|
||||||
const result = params.result === "success" || params.result === "failure" ? params.result : "all";
|
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, `
|
const baseCountRows = await runAnalyticsQuery<Record<string, unknown>>(c, `
|
||||||
SELECT
|
SELECT
|
||||||
count() AS total
|
count() AS total
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type CloudflareBindings = {
|
|||||||
FRONTEND_DEV_SERVER_URL?: string;
|
FRONTEND_DEV_SERVER_URL?: string;
|
||||||
USAGE_ANALYTICS?: AnalyticsEngineDataset;
|
USAGE_ANALYTICS?: AnalyticsEngineDataset;
|
||||||
USAGE_ANALYTICS_DATASET?: string;
|
USAGE_ANALYTICS_DATASET?: string;
|
||||||
|
DISABLE_ANALYTICS_QUERIES?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HonoCustomType = {
|
type HonoCustomType = {
|
||||||
|
|||||||
Reference in new issue
Block a user