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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user