mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
feat(call_logs): persist per-call error family and expose analytics breakdown (issue #10670) (#10679)
Merged — validated together with a batch of related maxmad64bis PRs in one combined worktree (typecheck:core clean, complexity/cognitive-complexity/file-size/changelog gates green, focused tests passing). Thanks for the contribution!
This commit is contained in:
1
changelog.d/features/10670-call-logs-error-type.md
Normal file
1
changelog.d/features/10670-call-logs-error-type.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(call_logs):** persist the per-call error family in `call_logs.error_type` and expose a failure breakdown (`errorBreakdown`) in the usage analytics endpoint, reusing the existing production classifier ([#10670](https://github.com/diegosouzapw/OmniRoute/issues/10670))
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
getWeeklyPatternRows,
|
||||
getPresetCostModelRows,
|
||||
} from "@/lib/db/usageAnalytics";
|
||||
import { getFallbackStats } from "@/lib/db/callLogStats";
|
||||
import { getFallbackStats, getErrorTypeBreakdown } from "@/lib/db/callLogStats";
|
||||
import { buildByProviderRows } from "@/lib/usage/providerDisplayNames";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
|
||||
@@ -481,6 +481,7 @@ export async function GET(request: Request) {
|
||||
const weeklyRows = getWeeklyPatternRows(unifiedSource, unifiedParams) as UsageRows;
|
||||
|
||||
const fallbackRow = getFallbackStats(whereClause, params) as Record<string, unknown>;
|
||||
const errorBreakdown = getErrorTypeBreakdown(whereClause, params);
|
||||
|
||||
const summary = {
|
||||
totalRequests: Number(summaryRow?.totalRequests || 0),
|
||||
@@ -869,6 +870,7 @@ export async function GET(request: Request) {
|
||||
weeklyCounts,
|
||||
dailyByModel,
|
||||
modelNames,
|
||||
errorBreakdown,
|
||||
range,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -239,3 +239,34 @@ export function getFallbackStats(
|
||||
.get(params) as FallbackStatsRow | undefined;
|
||||
return row ?? { total: 0, with_requested: 0, fallback_eligible: 0, fallbacks: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure-family breakdown over `call_logs` for the usage analytics endpoint.
|
||||
* Failures are rows with status >= 400 or a non-empty error summary; successes
|
||||
* are excluded in SQL. Pre-migration rows and failures the classifier does not
|
||||
* recognize (null family) land in the explicit `unclassified` bucket.
|
||||
*
|
||||
* @param whereClause - SQL WHERE clause (may be empty string) using the same
|
||||
* named params as the usage_history queries.
|
||||
* @param params - Named params object (string values).
|
||||
*/
|
||||
export function getErrorTypeBreakdown(
|
||||
whereClause: string,
|
||||
params: Record<string, string>
|
||||
): Array<{ errorType: string; count: number }> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
COALESCE(error_type, 'unclassified') AS errorType,
|
||||
COUNT(*) AS count
|
||||
FROM call_logs
|
||||
${whereClause} ${whereClause ? "AND" : "WHERE"} (status >= 400 OR error_summary IS NOT NULL)
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC, errorType ASC
|
||||
`
|
||||
)
|
||||
.all(params) as Array<{ errorType: string; count: number }>;
|
||||
return rows.map((row) => ({ errorType: String(row.errorType), count: Number(row.count) }));
|
||||
}
|
||||
|
||||
4
src/lib/db/migrations/158_call_logs_error_type.sql
Normal file
4
src/lib/db/migrations/158_call_logs_error_type.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- #10670: per-call error family, set at the single write point in
|
||||
-- src/lib/usage/callLogs.ts from classifyProviderError. NULL for successes
|
||||
-- (analytics filters by failure in SQL, so no need for a sentinel value).
|
||||
ALTER TABLE call_logs ADD COLUMN error_type TEXT DEFAULT NULL;
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
toStoredErrorSummary,
|
||||
protectPipelinePayloads,
|
||||
buildRequestSummary,
|
||||
classifyCallLogError,
|
||||
} from "./callLogs/format";
|
||||
import {
|
||||
clearArtifactReference,
|
||||
@@ -464,12 +465,14 @@ async function saveCallLogOperation(entry: any): Promise<void> {
|
||||
// while reasoning source/char-count are recorded separately for observability.
|
||||
const tokensReasoning = getReasoningTokensOrNull(entry.tokens);
|
||||
const reasoningObservation = resolveReasoningObservation(tokensReasoning, entry.responseBody);
|
||||
const errorType = classifyCallLogError(entry.status, entry.error, entry.provider);
|
||||
const logEntry = {
|
||||
id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(),
|
||||
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString(),
|
||||
method: entry.method || "POST",
|
||||
path: entry.path || "/v1/chat/completions",
|
||||
status: entry.status || 0,
|
||||
errorType,
|
||||
model: entry.model || "-",
|
||||
requestedModel: resolvedRequestedModel,
|
||||
provider: rawProvider,
|
||||
@@ -550,7 +553,7 @@ async function saveCallLogOperation(entry: any): Promise<void> {
|
||||
combo_name, combo_step_id, combo_execution_key, error_summary, detail_state,
|
||||
artifact_relpath, artifact_size_bytes, artifact_sha256,
|
||||
has_request_body, has_response_body, has_pipeline_details, request_summary,
|
||||
correlation_id, model_pinned, session_tag, response_id
|
||||
correlation_id, model_pinned, session_tag, response_id, error_type
|
||||
)
|
||||
VALUES (
|
||||
@id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider,
|
||||
@@ -561,7 +564,7 @@ async function saveCallLogOperation(entry: any): Promise<void> {
|
||||
@comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState,
|
||||
@artifactRelPath, @artifactSizeBytes, @artifactSha256,
|
||||
@hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary,
|
||||
@correlationId, @modelPinned, @sessionTag, @responseId
|
||||
@correlationId, @modelPinned, @sessionTag, @responseId, @errorType
|
||||
)
|
||||
`
|
||||
).run({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RequestPipelinePayloads } from "@omniroute/open-sse/utils/requestLogger.ts";
|
||||
import { classifyProviderError } from "@omniroute/open-sse/services/errorClassifier.ts";
|
||||
import { sanitizePII } from "../../piiSanitizer";
|
||||
import { omitEncryptedReasoningFromLogChunks, protectPayloadForLog } from "../../logPayloads";
|
||||
import type { CallLogDetailState } from "../callLogArtifacts";
|
||||
@@ -124,3 +125,22 @@ export function buildRequestSummary(
|
||||
if (Object.keys(summary).length === 0) return null;
|
||||
return JSON.stringify(summary);
|
||||
}
|
||||
|
||||
// #10670: per-call error family at the single write point. Reuses the
|
||||
// production classifier (chatCore.ts:3974, auth.ts:2598) so the persisted
|
||||
// vocabulary is exactly PROVIDER_ERROR_TYPES. Successes (status < 400 with no
|
||||
// error text) short-circuit to null — the classifier never returns a family
|
||||
// for them anyway, this only skips the call.
|
||||
// Normalization: strings pass through, Error objects yield .message, any other
|
||||
// object yields "" (no caller passes plain objects — verified: 35 callers use
|
||||
// strings and Error only). Deliberate deviation from design §4 ("objet →
|
||||
// JSON.stringify"): a stringified object carries no classifier signal.
|
||||
export function classifyCallLogError(
|
||||
status: number,
|
||||
error: unknown,
|
||||
provider?: string | null
|
||||
): string | null {
|
||||
const errorText = typeof error === "string" ? error : error instanceof Error ? error.message : "";
|
||||
if (status < 400 && errorText.length === 0) return null;
|
||||
return classifyProviderError(status, errorText, provider);
|
||||
}
|
||||
|
||||
192
tests/unit/call-log-error-type.test.ts
Normal file
192
tests/unit/call-log-error-type.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getDbInstance } from "../../src/lib/db/core.ts";
|
||||
import { classifyCallLogError } from "../../src/lib/usage/callLogs/format.ts";
|
||||
import { saveCallLog } from "../../src/lib/usage/callLogs.ts";
|
||||
import { getErrorTypeBreakdown } from "../../src/lib/db/callLogStats.ts";
|
||||
|
||||
test("call_logs table has error_type column", () => {
|
||||
const db = getDbInstance();
|
||||
const columns = db.prepare("PRAGMA table_info(call_logs)").all() as { name: string }[];
|
||||
const colNames = columns.map((c) => c.name);
|
||||
assert.ok(colNames.includes("error_type"), "call_logs should have error_type column");
|
||||
});
|
||||
|
||||
test("classifyCallLogError maps status+body to the provider error family", () => {
|
||||
assert.equal(classifyCallLogError(402, "whatever body", "openai"), "quota_exhausted");
|
||||
assert.equal(classifyCallLogError(500, "Internal Server Error", "openai"), "server_error");
|
||||
assert.equal(classifyCallLogError(429, "rate limit", "openai"), "rate_limited");
|
||||
assert.equal(classifyCallLogError(404, "model not found", "openai"), "model_not_found");
|
||||
assert.equal(classifyCallLogError(401, "bad key", "openai"), "unauthorized");
|
||||
});
|
||||
|
||||
test("classifyCallLogError classifies only failures", () => {
|
||||
assert.equal(classifyCallLogError(200, "", "openai"), null);
|
||||
assert.equal(classifyCallLogError(200, "some body", "openai"), null);
|
||||
assert.equal(classifyCallLogError(0, "boom", "test-provider"), null);
|
||||
});
|
||||
|
||||
test("classifyCallLogError extracts message from Error object", () => {
|
||||
assert.equal(
|
||||
classifyCallLogError(403, new Error("browser_signature_banned"), "openai"),
|
||||
"fingerprint_rejection"
|
||||
);
|
||||
});
|
||||
|
||||
test("classifyCallLogError returns null for unclassifiable provider-403 (api key)", () => {
|
||||
assert.equal(classifyCallLogError(403, "some other 403 body", "openai"), null);
|
||||
});
|
||||
|
||||
test("saveCallLog persists error_type from failure", async () => {
|
||||
const db = getDbInstance();
|
||||
const testId = `test-errtype-${Date.now()}`;
|
||||
|
||||
await saveCallLog({
|
||||
id: testId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 402,
|
||||
error: "exceeded your current quota",
|
||||
model: "test-model",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 10, out: 5 },
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as {
|
||||
error_type: string | null;
|
||||
};
|
||||
assert.equal(row.error_type, "quota_exhausted");
|
||||
|
||||
db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId);
|
||||
});
|
||||
|
||||
test("saveCallLog persists null error_type for success", async () => {
|
||||
const db = getDbInstance();
|
||||
const testId = `test-errtype-ok-${Date.now()}`;
|
||||
|
||||
await saveCallLog({
|
||||
id: testId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model: "test-model",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 10, out: 5 },
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as {
|
||||
error_type: string | null;
|
||||
};
|
||||
assert.equal(row.error_type, null);
|
||||
|
||||
db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId);
|
||||
});
|
||||
|
||||
test("saveCallLog normalizes Error object before classifying", async () => {
|
||||
const db = getDbInstance();
|
||||
const testId = `test-errtype-err-${Date.now()}`;
|
||||
|
||||
await saveCallLog({
|
||||
id: testId,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 403,
|
||||
error: new Error("browser_signature_banned"),
|
||||
model: "test-model",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 10, out: 5 },
|
||||
});
|
||||
|
||||
const row = db.prepare("SELECT error_type FROM call_logs WHERE id = ?").get(testId) as {
|
||||
error_type: string | null;
|
||||
};
|
||||
assert.equal(row.error_type, "fingerprint_rejection");
|
||||
|
||||
db.prepare("DELETE FROM call_logs WHERE id = ?").run(testId);
|
||||
});
|
||||
|
||||
test("getErrorTypeBreakdown groups failures by family, excludes successes", async () => {
|
||||
const db = getDbInstance();
|
||||
const ids = [
|
||||
`test-errbd-q1-${Date.now()}`,
|
||||
`test-errbd-q2-${Date.now()}`,
|
||||
`test-errbd-s5-${Date.now()}`,
|
||||
`test-errbd-403-${Date.now()}`,
|
||||
`test-errbd-ok-${Date.now()}`,
|
||||
];
|
||||
|
||||
await saveCallLog({
|
||||
id: ids[0],
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 402,
|
||||
error: "exceeded your current quota",
|
||||
model: "m",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 1, out: 1 },
|
||||
});
|
||||
await saveCallLog({
|
||||
id: ids[1],
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 402,
|
||||
error: "insufficient balance",
|
||||
model: "m",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 1, out: 1 },
|
||||
});
|
||||
await saveCallLog({
|
||||
id: ids[2],
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 500,
|
||||
error: "Internal Server Error",
|
||||
model: "m",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 1, out: 1 },
|
||||
});
|
||||
await saveCallLog({
|
||||
id: ids[3],
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 403,
|
||||
error: "some other 403 body",
|
||||
model: "m",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 1, out: 1 },
|
||||
});
|
||||
await saveCallLog({
|
||||
id: ids[4],
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
status: 200,
|
||||
model: "m",
|
||||
provider: "test-provider",
|
||||
duration: 100,
|
||||
tokens: { in: 1, out: 1 },
|
||||
});
|
||||
|
||||
const whereClause = `WHERE id IN (${ids.map((_, i) => `@id${i}`).join(", ")})`;
|
||||
const params = Object.fromEntries(ids.map((id, i) => [`id${i}`, id]));
|
||||
const breakdown = getErrorTypeBreakdown(whereClause, params);
|
||||
|
||||
assert.deepEqual(breakdown, [
|
||||
{ errorType: "quota_exhausted", count: 2 },
|
||||
{ errorType: "server_error", count: 1 },
|
||||
{ errorType: "unclassified", count: 1 },
|
||||
]);
|
||||
|
||||
ids.forEach((id) => db.prepare("DELETE FROM call_logs WHERE id = ?").run(id));
|
||||
});
|
||||
|
||||
test("getErrorTypeBreakdown with empty whereClause does not crash", () => {
|
||||
const breakdown = getErrorTypeBreakdown("", {});
|
||||
assert.ok(Array.isArray(breakdown));
|
||||
});
|
||||
Reference in New Issue
Block a user