fix: localize downstream key trend timestamps (#432)

* fix: localize downstream key trend timestamps

* fix: keep downstream trend metadata on empty responses

* fix: cursor paginate downstream key trend reads

* fix: normalize empty downstream trend timezones

* fix: localize windowed downstream trend buckets
This commit is contained in:
Cita
2026-04-06 08:46:40 +08:00
committed by GitHub
parent 3e8d69bed6
commit 287c5aafd6
8 changed files with 800 additions and 89 deletions
@@ -41,11 +41,11 @@ describe('downstream api keys routes', () => {
});
it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => {
const routesModule = await import('./downstreamApiKeys.js') as Record<string, any>;
const trendServiceModule = await import('../../services/downstreamApiKeyTrendService.js') as Record<string, any>;
expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function');
expect(typeof trendServiceModule.buildBucketTsExpressionForDialect).toBe('function');
const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600);
const expression = trendServiceModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600);
const rendered = new PgDialect().sqlToQuery(expression).sql;
expect(rendered).toContain('date_trunc');
@@ -787,6 +787,71 @@ describe('downstream api keys routes', () => {
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true);
});
it('groups all-range trend buckets by local day boundaries', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values({
name: 'local-day-key',
key: 'sk-local-day-key-001',
enabled: true,
description: 'local day trend',
usedCost: 0,
usedRequests: 0,
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 100,
estimatedCost: 0.01,
createdAt: '2026-04-05T23:30:00.000Z',
},
{
downstreamApiKeyId: inserted.id,
status: 'failed',
totalTokens: 200,
estimatedCost: 0.02,
createdAt: '2026-04-06T01:00:00.000Z',
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 300,
estimatedCost: 0.03,
createdAt: '2026-04-06T16:30:00.000Z',
},
]).run();
const trendRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/trend?range=all&timeZone=${encodeURIComponent('Asia/Shanghai')}`,
});
expect(trendRes.statusCode).toBe(200);
expect(trendRes.json()).toMatchObject({
success: true,
range: 'all',
timeZone: 'Asia/Shanghai',
buckets: [
{
startUtc: '2026-04-05T16:00:00.000Z',
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
totalTokens: 300,
totalCost: 0.03,
},
{
startUtc: '2026-04-06T16:00:00.000Z',
totalRequests: 1,
successRequests: 1,
failedRequests: 0,
totalTokens: 300,
totalCost: 0.03,
},
],
});
});
it('rejects unknown route ids and site ids in downstream policy payloads', async () => {
const createRes = await app.inject({
method: 'POST',
+28 -71
View File
@@ -1,5 +1,5 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { and, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import { insertAndGetById } from '../../db/insertHelpers.js';
import {
@@ -9,8 +9,15 @@ import {
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import type { DownstreamExcludedCredentialRef } from '../../services/downstreamPolicyTypes.js';
import {
readDownstreamApiKeyTrendBuckets,
resolveDownstreamTrendBucketSeconds,
resolveDownstreamTrendRangeSinceUtc,
resolveDownstreamTrendTimeZone,
type DownstreamKeyTrendRange,
} from '../../services/downstreamApiKeyTrendService.js';
import {
parseDownstreamApiKeyBatchPayload,
parseDownstreamApiKeyPayload,
@@ -78,7 +85,7 @@ function normalizeBatchIds(raw: unknown): number[] {
return ids;
}
type DownstreamKeyRange = '24h' | '7d' | 'all';
type DownstreamKeyRange = DownstreamKeyTrendRange;
type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled';
function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange {
@@ -124,37 +131,7 @@ function normalizeTagMatchMode(raw: unknown): 'any' | 'all' {
}
function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null {
const nowTs = Date.now();
if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000));
if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000));
return null;
}
function resolveBucketSeconds(range: DownstreamKeyRange): number {
return range === 'all' ? 86400 : 3600;
}
export function buildBucketTsExpressionForDialect(
dialect: 'sqlite' | 'mysql' | 'postgres',
createdAtColumn: SQLWrapper,
bucketSeconds: number,
) {
if (dialect === 'mysql') {
return sql<number>`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`;
}
if (dialect === 'postgres') {
const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`;
if (bucketSeconds === 86400) {
return sql<number>`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`;
}
return sql<number>`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`;
}
// sqlite
return sql<number>`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
}
function resolveBucketTsExpression(bucketSeconds: number) {
return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds);
return resolveDownstreamTrendRangeSinceUtc(range);
}
async function validatePolicyReferences(input: {
@@ -447,7 +424,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return { success: true, item, usage: { last24h, last7d, all } };
});
app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => {
app.get<{ Params: { id: string }; Querystring: { range?: string; timeZone?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
@@ -461,49 +438,29 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] };
return {
success: true,
range,
item: { id: item.id, name: item.name },
bucketSeconds: resolveDownstreamTrendBucketSeconds(range),
timeZone: resolveDownstreamTrendTimeZone(request.query?.timeZone),
buckets: [],
};
}
const bucketSeconds = resolveBucketSeconds(range);
const bucketTs = resolveBucketTsExpression(bucketSeconds);
const sinceUtc = resolveRangeSinceUtc(range);
const rows = await db.select({
bucketTs,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(bucketTs)
.orderBy(bucketTs)
.all();
const trend = await readDownstreamApiKeyTrendBuckets({
downstreamApiKeyId: id,
range,
timeZone: request.query?.timeZone,
});
return {
success: true,
range,
item: { id: item.id, name: item.name },
bucketSeconds,
buckets: rows.map((row: any) => {
const tsSeconds = Number(row.bucketTs || 0);
const totalRequests = Number(row.totalRequests || 0);
const successRequests = Number(row.successRequests || 0);
return {
startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null,
totalRequests,
successRequests,
failedRequests: Number(row.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number(row.totalTokens || 0),
totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000,
};
}),
bucketSeconds: trend.bucketSeconds,
timeZone: trend.timeZone,
buckets: trend.buckets,
};
});