Skip to content

Commit 23f3996

Browse files
committed
Merge branch 'task/request-log-metadata' into dev
2 parents e4bb8a4 + 540278c commit 23f3996

7 files changed

Lines changed: 119 additions & 4 deletions

File tree

docs-site/src/content/docs/guides/web-dashboard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ The GUI is a thin client over the proxy's management API. Useful endpoints (all
6565
| `PUT /api/codex-auth/auto-switch` | Set the quota threshold for automatic new-session account selection. |
6666
| `PUT /api/codex-auth/failover` | Set how many transient upstream failures trigger future-session failover. |
6767
| `POST /api/codex-auth/login` · `GET /api/codex-auth/login-status` | Add a pool account through the browser login flow. |
68+
| `GET /api/logs?tail=50&provider=…&status=5xx` | Read recent request metadata with optional tail, provider, and status filters. |
6869
| `GET` / `PUT /api/subagent-models` | Read / set the featured subagent models. |
6970
| `POST /api/stop` | Gracefully stop the proxy (and the background service if installed), restore native Codex, then exit. |
7071

gui/src/i18n/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@ export const en = {
115115
"logs.autoRefresh": "Auto-refresh",
116116
"logs.noRequests": "No requests yet.",
117117
"logs.col.time": "Time",
118+
"logs.col.request": "Request",
118119
"logs.col.model": "Model",
119120
"logs.col.provider": "Provider",
120121
"logs.col.status": "Status",
122+
"logs.col.error": "Error",
121123
"logs.col.duration": "Duration",
122124

123125
// add-provider modal

gui/src/i18n/ko.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@ export const ko: Record<TKey, string> = {
115115
"logs.autoRefresh": "자동 새로고침",
116116
"logs.noRequests": "아직 요청이 없습니다.",
117117
"logs.col.time": "시간",
118+
"logs.col.request": "요청",
118119
"logs.col.model": "모델",
119120
"logs.col.provider": "프로바이더",
120121
"logs.col.status": "상태",
122+
"logs.col.error": "오류",
121123
"logs.col.duration": "소요 시간",
122124

123125
// add-provider modal

gui/src/i18n/zh.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@ export const zh: Record<TKey, string> = {
115115
"logs.autoRefresh": "自动刷新",
116116
"logs.noRequests": "暂无请求。",
117117
"logs.col.time": "时间",
118+
"logs.col.request": "请求",
118119
"logs.col.model": "模型",
119120
"logs.col.provider": "提供方",
120121
"logs.col.status": "状态",
122+
"logs.col.error": "错误",
121123
"logs.col.duration": "耗时",
122124

123125
// add-provider modal

gui/src/pages/Logs.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { useEffect, useState } from "react";
22
import { useI18n, LOCALES } from "../i18n";
33

44
interface LogEntry {
5+
requestId?: string;
56
timestamp: number;
67
model: string;
78
provider: string;
89
status: number;
910
durationMs: number;
11+
errorCode?: string;
1012
}
1113

1214
export default function Logs({ apiBase }: { apiBase: string }) {
@@ -49,21 +51,25 @@ export default function Logs({ apiBase }: { apiBase: string }) {
4951
<thead>
5052
<tr>
5153
<th>{t("logs.col.time")}</th>
54+
<th>{t("logs.col.request")}</th>
5255
<th>{t("logs.col.model")}</th>
5356
<th>{t("logs.col.provider")}</th>
5457
<th>{t("logs.col.status")}</th>
58+
<th>{t("logs.col.error")}</th>
5559
<th className="num">{t("logs.col.duration")}</th>
5660
</tr>
5761
</thead>
5862
<tbody>
5963
{[...logs].reverse().map((log, i) => (
60-
<tr key={i}>
64+
<tr key={log.requestId ?? `${log.timestamp}-${i}`}>
6165
<td className="muted mono">{new Date(log.timestamp).toLocaleTimeString(localeTag)}</td>
66+
<td className="muted mono">{log.requestId ?? "-"}</td>
6267
<td className="mono">{log.model}</td>
6368
<td className="muted">{log.provider}</td>
6469
<td>
6570
<span className="mono" style={{ color: statusColor(log.status), fontWeight: 600 }}>{log.status}</span>
6671
</td>
72+
<td className="muted mono">{log.errorCode ?? "-"}</td>
6773
<td className="num">{log.durationMs}ms</td>
6874
</tr>
6975
))}

src/server.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,14 +522,58 @@ async function fetchWithHeaderTimeout(
522522
}
523523
}
524524

525-
const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
525+
export interface RequestLogEntry {
526+
requestId: string;
527+
timestamp: number;
528+
model: string;
529+
provider: string;
530+
status: number;
531+
durationMs: number;
532+
errorCode?: string;
533+
}
534+
535+
const requestLog: RequestLogEntry[] = [];
526536
const MAX_LOG_SIZE = 200;
537+
let requestLogSeq = 0;
527538

528-
function addRequestLog(entry: typeof requestLog[number]) {
539+
function addRequestLog(entry: RequestLogEntry) {
529540
requestLog.push(entry);
530541
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
531542
}
532543

544+
export function nextRequestLogId(timestamp = Date.now()): string {
545+
requestLogSeq = (requestLogSeq % 1_000_000) + 1;
546+
return `ocx-${timestamp.toString(36)}-${requestLogSeq.toString(36)}`;
547+
}
548+
549+
export function requestLogErrorCode(status: number): string | undefined {
550+
if (status >= 200 && status < 400) return undefined;
551+
if (status === 400 || status === 409) return "invalid_request_error";
552+
if (status === 401 || status === 403) return "invalid_api_key";
553+
if (status === 429) return "rate_limit_exceeded";
554+
if (status === 503) return "server_is_overloaded";
555+
if (status >= 500) return "upstream_server_error";
556+
return `http_${status}`;
557+
}
558+
559+
export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] {
560+
let filtered = logs;
561+
const provider = params.get("provider")?.trim();
562+
if (provider) filtered = filtered.filter(entry => entry.provider === provider);
563+
const status = params.get("status")?.trim().toLowerCase();
564+
if (status) {
565+
filtered = /^[1-5]xx$/.test(status)
566+
? filtered.filter(entry => Math.floor(entry.status / 100) === Number(status[0]))
567+
: filtered.filter(entry => String(entry.status) === status);
568+
}
569+
const tailRaw = params.get("tail")?.trim();
570+
if (tailRaw) {
571+
const tail = Number.parseInt(tailRaw, 10);
572+
if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE));
573+
}
574+
return filtered;
575+
}
576+
533577
/**
534578
* Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
535579
* directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
@@ -941,7 +985,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
941985
}
942986

943987
if (url.pathname === "/api/logs" && req.method === "GET") {
944-
return jsonResponse(requestLog);
988+
return jsonResponse(filterRequestLogs(requestLog, url.searchParams));
945989
}
946990

947991
if (url.pathname === "/api/providers" && req.method === "GET") {
@@ -1228,14 +1272,18 @@ export function startServer(port?: number) {
12281272
return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
12291273
}
12301274
const start = Date.now();
1275+
const requestId = nextRequestLogId(start);
12311276
const logCtx = { model: "unknown", provider: "unknown" };
12321277
const response = await handleResponses(req, config, logCtx);
1278+
const errorCode = requestLogErrorCode(response.status);
12331279
addRequestLog({
1280+
requestId,
12341281
timestamp: start,
12351282
model: logCtx.model,
12361283
provider: logCtx.provider,
12371284
status: response.status,
12381285
durationMs: Date.now() - start,
1286+
...(errorCode ? { errorCode } : {}),
12391287
});
12401288
return response;
12411289
}

tests/request-log.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
filterRequestLogs,
4+
nextRequestLogId,
5+
requestLogErrorCode,
6+
type RequestLogEntry,
7+
} from "../src/server";
8+
9+
function log(overrides: Partial<RequestLogEntry>): RequestLogEntry {
10+
return {
11+
requestId: "ocx-test",
12+
timestamp: 1,
13+
model: "gpt-test",
14+
provider: "openai",
15+
status: 200,
16+
durationMs: 10,
17+
...overrides,
18+
};
19+
}
20+
21+
describe("request log metadata", () => {
22+
test("generates compact request ids", () => {
23+
expect(nextRequestLogId(1_700_000_000_000)).toMatch(/^ocx-[a-z0-9]+-[a-z0-9]+$/);
24+
expect(nextRequestLogId(1_700_000_000_000)).not.toBe(nextRequestLogId(1_700_000_000_000));
25+
});
26+
27+
test("classifies status codes without reading response bodies", () => {
28+
expect(requestLogErrorCode(200)).toBeUndefined();
29+
expect(requestLogErrorCode(400)).toBe("invalid_request_error");
30+
expect(requestLogErrorCode(401)).toBe("invalid_api_key");
31+
expect(requestLogErrorCode(429)).toBe("rate_limit_exceeded");
32+
expect(requestLogErrorCode(503)).toBe("server_is_overloaded");
33+
expect(requestLogErrorCode(502)).toBe("upstream_server_error");
34+
expect(requestLogErrorCode(404)).toBe("http_404");
35+
expect(requestLogErrorCode(418)).toBe("http_418");
36+
});
37+
38+
test("filters logs by provider, status, and tail", () => {
39+
const logs = [
40+
log({ requestId: "a", provider: "openai", status: 200 }),
41+
log({ requestId: "b", provider: "umans", status: 429 }),
42+
log({ requestId: "c", provider: "umans", status: 502 }),
43+
log({ requestId: "d", provider: "opencode-go", status: 500 }),
44+
];
45+
46+
expect(filterRequestLogs(logs, new URLSearchParams("provider=umans")).map(entry => entry.requestId)).toEqual(["b", "c"]);
47+
expect(filterRequestLogs(logs, new URLSearchParams("status=5xx")).map(entry => entry.requestId)).toEqual(["c", "d"]);
48+
expect(filterRequestLogs(logs, new URLSearchParams("status=429")).map(entry => entry.requestId)).toEqual(["b"]);
49+
expect(filterRequestLogs(logs, new URLSearchParams("tail=2")).map(entry => entry.requestId)).toEqual(["c", "d"]);
50+
51+
const combined = filterRequestLogs(logs, new URLSearchParams("provider=umans&status=5xx&tail=1"));
52+
expect(combined.map(entry => entry.requestId)).toEqual(["c"]);
53+
});
54+
});

0 commit comments

Comments
 (0)