feat: add proxy first-byte timeout and log badges (#383)

* add proxy first-byte timeout and log badges

* fix first-byte timeout review feedback
This commit is contained in:
Cita
2026-04-02 10:00:11 +08:00
committed by GitHub
parent bcd758edac
commit 180d17adfc
43 changed files with 4049 additions and 91 deletions
+49
View File
@@ -4,6 +4,7 @@ const {
hasProxyLogBillingDetailsColumnMock,
hasProxyLogClientColumnsMock,
hasProxyLogDownstreamApiKeyIdColumnMock,
hasProxyLogStreamTimingColumnsMock,
dbInsertMock,
dbInsertValuesMock,
dbInsertRunMock,
@@ -12,6 +13,7 @@ const {
hasProxyLogBillingDetailsColumnMock: vi.fn(),
hasProxyLogClientColumnsMock: vi.fn(),
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
hasProxyLogStreamTimingColumnsMock: vi.fn(),
dbInsertMock: vi.fn(),
dbInsertValuesMock: vi.fn(),
dbInsertRunMock: vi.fn(),
@@ -24,6 +26,8 @@ const {
modelActual: 'model_actual',
status: 'status',
httpStatus: 'http_status',
isStream: 'is_stream',
firstByteLatencyMs: 'first_byte_latency_ms',
latencyMs: 'latency_ms',
promptTokens: 'prompt_tokens',
completionTokens: 'completion_tokens',
@@ -50,6 +54,7 @@ vi.mock('../db/index.js', () => ({
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args),
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
hasProxyLogStreamTimingColumns: (...args: unknown[]) => hasProxyLogStreamTimingColumnsMock(...args),
}));
import { insertProxyLog, parseProxyLogBillingDetails, withProxyLogSelectFields } from './proxyLogStore.js';
@@ -59,12 +64,14 @@ describe('proxyLogStore', () => {
hasProxyLogBillingDetailsColumnMock.mockReset();
hasProxyLogClientColumnsMock.mockReset();
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
hasProxyLogStreamTimingColumnsMock.mockReset();
dbInsertMock.mockReset();
dbInsertValuesMock.mockReset();
dbInsertRunMock.mockReset();
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
hasProxyLogClientColumnsMock.mockResolvedValue(false);
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
hasProxyLogStreamTimingColumnsMock.mockResolvedValue(false);
dbInsertMock.mockReturnValue({
values: (...args: unknown[]) => dbInsertValuesMock(...args),
@@ -89,6 +96,23 @@ describe('proxyLogStore', () => {
expect(runner.mock.calls[1][0].fields.billingDetails).toBeUndefined();
});
it('retries proxy log selects without stream timing fields when the columns are missing', async () => {
hasProxyLogStreamTimingColumnsMock.mockResolvedValue(true);
const runner = vi.fn()
.mockRejectedValueOnce(new Error('column proxy_logs.first_byte_latency_ms does not exist'))
.mockResolvedValueOnce([{ id: 1 }]);
await expect(withProxyLogSelectFields(runner)).resolves.toEqual([{ id: 1 }]);
expect(runner).toHaveBeenCalledTimes(2);
expect(runner.mock.calls[0][0].includeStreamTimingFields).toBe(true);
expect(runner.mock.calls[0][0].fields.isStream).toBe('is_stream');
expect(runner.mock.calls[0][0].fields.firstByteLatencyMs).toBe('first_byte_latency_ms');
expect(runner.mock.calls[1][0].includeStreamTimingFields).toBe(false);
expect(runner.mock.calls[1][0].fields.isStream).toBeUndefined();
expect(runner.mock.calls[1][0].fields.firstByteLatencyMs).toBeUndefined();
});
it('accepts parsed billing details objects for helper-level callers', () => {
expect(parseProxyLogBillingDetails({
source: 'pricing',
@@ -220,4 +244,29 @@ describe('proxyLogStore', () => {
expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined();
});
it('retries proxy log inserts without stream timing fields when those columns are missing', async () => {
hasProxyLogStreamTimingColumnsMock.mockResolvedValue(true);
dbInsertRunMock
.mockRejectedValueOnce(new Error('column proxy_logs.is_stream does not exist'))
.mockResolvedValueOnce(undefined);
await insertProxyLog({
modelRequested: 'gpt-5',
isStream: true,
firstByteLatencyMs: 42,
});
expect(dbInsertValuesMock).toHaveBeenCalledTimes(2);
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
modelRequested: 'gpt-5',
isStream: true,
firstByteLatencyMs: 42,
});
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
modelRequested: 'gpt-5',
});
expect(dbInsertValuesMock.mock.calls[1][0].isStream).toBeUndefined();
expect(dbInsertValuesMock.mock.calls[1][0].firstByteLatencyMs).toBeUndefined();
});
});
+68 -1
View File
@@ -4,6 +4,7 @@ import {
hasProxyLogBillingDetailsColumn,
hasProxyLogClientColumns,
hasProxyLogDownstreamApiKeyIdColumn,
hasProxyLogStreamTimingColumns,
} from '../db/index.js';
export type ProxyLogInsertInput = {
@@ -15,6 +16,8 @@ export type ProxyLogInsertInput = {
modelActual?: string | null;
status?: string | null;
httpStatus?: number | null;
isStream?: boolean | null;
firstByteLatencyMs?: number | null;
latencyMs?: number | null;
promptTokens?: number | null;
completionTokens?: number | null;
@@ -61,12 +64,21 @@ function buildProxyLogClientSelectFields() {
};
}
function buildProxyLogStreamTimingSelectFields() {
return {
isStream: schema.proxyLogs.isStream,
firstByteLatencyMs: schema.proxyLogs.firstByteLatencyMs,
};
}
function buildProxyLogSelectFields(options?: {
includeBillingDetails?: boolean;
includeClientFields?: boolean;
includeStreamTimingFields?: boolean;
}) {
return {
...buildProxyLogCoreSelectFields(),
...(options?.includeStreamTimingFields ? buildProxyLogStreamTimingSelectFields() : {}),
...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}),
...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}),
};
@@ -81,31 +93,37 @@ export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogSelectFields>;
export type ResolvedProxyLogSelectFields = {
includeBillingDetails: boolean;
includeClientFields: boolean;
includeStreamTimingFields: boolean;
fields: ProxyLogSelectFields;
};
export async function resolveProxyLogSelectFields(options?: {
includeBillingDetails?: boolean;
includeClientFields?: boolean;
includeStreamTimingFields?: boolean;
}) {
const includeBillingDetails = options?.includeBillingDetails === true
&& await hasProxyLogBillingDetailsColumn();
const includeClientFields = options?.includeClientFields !== false
&& await hasProxyLogClientColumns();
const includeStreamTimingFields = options?.includeStreamTimingFields !== false
&& await hasProxyLogStreamTimingColumns();
return {
includeBillingDetails,
includeClientFields,
includeStreamTimingFields,
fields: buildProxyLogSelectFields({
includeBillingDetails,
includeClientFields,
includeStreamTimingFields,
}),
};
}
export async function withProxyLogSelectFields<T>(
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
options?: { includeBillingDetails?: boolean; includeClientFields?: boolean },
options?: { includeBillingDetails?: boolean; includeClientFields?: boolean; includeStreamTimingFields?: boolean },
): Promise<T> {
let selection = await resolveProxyLogSelectFields(options);
@@ -117,9 +135,11 @@ export async function withProxyLogSelectFields<T>(
selection = {
includeBillingDetails: false,
includeClientFields: selection.includeClientFields,
includeStreamTimingFields: selection.includeStreamTimingFields,
fields: buildProxyLogSelectFields({
includeBillingDetails: false,
includeClientFields: selection.includeClientFields,
includeStreamTimingFields: selection.includeStreamTimingFields,
}),
};
continue;
@@ -129,9 +149,25 @@ export async function withProxyLogSelectFields<T>(
selection = {
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: false,
includeStreamTimingFields: selection.includeStreamTimingFields,
fields: buildProxyLogSelectFields({
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: false,
includeStreamTimingFields: selection.includeStreamTimingFields,
}),
};
continue;
}
if (selection.includeStreamTimingFields && isMissingProxyLogStreamTimingColumnsError(error)) {
selection = {
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: selection.includeClientFields,
includeStreamTimingFields: false,
fields: buildProxyLogSelectFields({
includeBillingDetails: selection.includeBillingDetails,
includeClientFields: selection.includeClientFields,
includeStreamTimingFields: false,
}),
};
continue;
@@ -204,6 +240,22 @@ export function isMissingProxyLogClientColumnsError(error: unknown): boolean {
);
}
export function isMissingProxyLogStreamTimingColumnsError(error: unknown): boolean {
const lowered = normalizeProxyLogStoreErrorMessage(error);
const hasStreamTimingColumnReference = [
'is_stream',
'first_byte_latency_ms',
].some((columnName) => lowered.includes(columnName));
return hasStreamTimingColumnReference
&& (
lowered.includes('does not exist')
|| lowered.includes('unknown column')
|| lowered.includes('no such column')
|| lowered.includes('has no column named')
);
}
export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void> {
const baseValues = {
routeId: input.routeId ?? null,
@@ -237,14 +289,24 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
].some((value) => value != null && String(value).trim().length > 0);
const includeClientFields = requestedClientFields
&& await hasProxyLogClientColumns();
const requestedStreamTimingFields = input.isStream != null || input.firstByteLatencyMs != null;
const includeStreamTimingFields = requestedStreamTimingFields
&& await hasProxyLogStreamTimingColumns();
let allowBillingDetails = includeBillingDetails;
let allowDownstreamApiKeyId = includeDownstreamApiKeyId;
let allowClientFields = includeClientFields;
let allowStreamTimingFields = includeStreamTimingFields;
while (true) {
const values = {
...baseValues,
...(allowStreamTimingFields
? {
isStream: input.isStream ?? null,
firstByteLatencyMs: input.firstByteLatencyMs ?? null,
}
: {}),
...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
...(allowClientFields
@@ -276,6 +338,11 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
continue;
}
if (allowStreamTimingFields && isMissingProxyLogStreamTimingColumnsError(error)) {
allowStreamTimingFields = false;
continue;
}
throw error;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
];
export const RETRYABLE_TIMEOUT_PATTERNS: RegExp[] = [
/(request timed out|connection timed out|read timeout|\btimed out\b)/i,
/(request timed out|connection timed out|read timeout|first byte timeout|\btimed out\b)/i,
];
const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [
+10 -1
View File
@@ -49,14 +49,23 @@ export interface RecordedSiteApiEndpointFailure extends SiteApiEndpointFailureDi
export class SiteApiEndpointRequestError extends Error {
readonly status: number | null;
readonly rawErrText: string | null;
readonly firstByteLatencyMs: number | null;
constructor(message: string, options?: { status?: number | null; rawErrText?: string | null; cause?: unknown }) {
constructor(message: string, options?: {
status?: number | null;
rawErrText?: string | null;
firstByteLatencyMs?: number | null;
cause?: unknown;
}) {
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
this.name = 'SiteApiEndpointRequestError';
this.status = typeof options?.status === 'number' ? options.status : null;
this.rawErrText = typeof options?.rawErrText === 'string' && options.rawErrText.trim()
? options.rawErrText
: null;
this.firstByteLatencyMs = typeof options?.firstByteLatencyMs === 'number' && Number.isFinite(options.firstByteLatencyMs)
? options.firstByteLatencyMs
: null;
}
}