From dcfd45ea652746016bb39b79c79ec886c5203309 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 29 Mar 2026 15:08:37 +0800 Subject: [PATCH] Fix proxy debug trace truncation previews (#313) --- .../services/proxyDebugTraceStore.test.ts | 33 +++ src/server/services/proxyDebugTraceStore.ts | 21 +- .../pages/ProxyLogs.server-driven.test.tsx | 53 ++++ src/web/pages/ProxyLogs.tsx | 237 ++++++++++++++---- 4 files changed, 294 insertions(+), 50 deletions(-) diff --git a/src/server/services/proxyDebugTraceStore.test.ts b/src/server/services/proxyDebugTraceStore.test.ts index 0df424b..8edadc6 100644 --- a/src/server/services/proxyDebugTraceStore.test.ts +++ b/src/server/services/proxyDebugTraceStore.test.ts @@ -150,4 +150,37 @@ describe('proxyDebugTraceStore', () => { downgradeDecision: true, }); }); + + it('stores truncated debug payload previews as valid JSON text for json-capable databases', async () => { + const trace = await store.createProxyDebugTrace({ + downstreamPath: '/v1/responses', + clientKind: 'codex', + requestedModel: 'gpt-5.4', + requestHeaders: { + authorization: `Bearer ${'x'.repeat(5000)}`, + 'x-client': 'Codex Desktop', + }, + requestBody: { + model: 'gpt-5.4', + input: [{ role: 'user', content: 'hello '.repeat(2000) }], + }, + maxBodyBytes: 1024, + }); + + const detail = await store.getProxyDebugTraceDetail(trace.id); + const headersPayload = JSON.parse(detail?.trace.requestHeadersJson || 'null'); + const bodyPayload = JSON.parse(detail?.trace.requestBodyJson || 'null'); + + expect(headersPayload).toMatchObject({ + __metapiTruncated: true, + }); + expect(typeof headersPayload.preview).toBe('string'); + expect(headersPayload.preview).toContain('authorization'); + + expect(bodyPayload).toMatchObject({ + __metapiTruncated: true, + }); + expect(typeof bodyPayload.preview).toBe('string'); + expect(bodyPayload.preview).toContain('"model": "gpt-5.4"'); + }); }); diff --git a/src/server/services/proxyDebugTraceStore.ts b/src/server/services/proxyDebugTraceStore.ts index f604974..61e31c9 100644 --- a/src/server/services/proxyDebugTraceStore.ts +++ b/src/server/services/proxyDebugTraceStore.ts @@ -25,6 +25,24 @@ export type ProxyDebugTraceSession = { let lastPruneAtMs = 0; const PRUNE_INTERVAL_MS = 5 * 60 * 1000; +type TruncatedDebugPreview = { + __metapiTruncated: true; + preview: string; + originalBytes: number; + storedBytes: number; +}; + +function buildTruncatedDebugPreview(text: string, maxBytes: number, originalBytes: number): string { + const truncated = Buffer.from(text, 'utf8').subarray(0, Math.max(0, maxBytes)).toString('utf8'); + const payload: TruncatedDebugPreview = { + __metapiTruncated: true, + preview: truncated, + originalBytes, + storedBytes: maxBytes, + }; + return JSON.stringify(payload, null, 2); +} + function stringifyDebugValue(value: unknown, maxBytes: number): string | null { if (value == null) return null; @@ -45,8 +63,7 @@ function stringifyDebugValue(value: unknown, maxBytes: number): string | null { return text; } - const truncated = buffer.subarray(0, Math.max(0, maxBytes)).toString('utf8'); - return `${truncated}\n...[truncated ${buffer.length - maxBytes} bytes]`; + return buildTruncatedDebugPreview(text, maxBytes, buffer.length); } function normalizeHeadersValue(value: HeadersLike): Record | null { diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index 9a8075b..f26a7d0 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -97,6 +97,15 @@ function buildListResponse(overrides?: Partial<{ describe('ProxyLogs server-driven page', () => { beforeEach(() => { vi.clearAllMocks(); + Object.defineProperty(globalThis, 'navigator', { + value: { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + }, + configurable: true, + writable: true, + }); apiMock.getSites.mockResolvedValue([ { id: 9, name: 'main-site', status: 'active' }, { id: 12, name: 'backup-site', status: 'active' }, @@ -185,6 +194,7 @@ describe('ProxyLogs server-driven page', () => { id: 701, requestedModel: 'gpt-4o', sessionId: 'sess-debug-1', + requestHeadersJson: '{\n "authorization": "Bearer demo"\n}', }, attempts: [], }); @@ -493,6 +503,49 @@ describe('ProxyLogs server-driven page', () => { } }); + it('copies the saved request headers content from the trace detail modal', async () => { + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const viewDetailButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).trim() === '查看详情' + )); + + await act(async () => { + viewDetailButton.props.onClick(); + }); + await flushMicrotasks(); + + const copyButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && node.props['aria-label'] === '复制原始下游请求头' + )); + + await act(async () => { + copyButton.props.onClick({ stopPropagation: () => undefined, preventDefault: () => undefined }); + }); + await flushMicrotasks(); + + expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith('{\n "authorization": "Bearer demo"\n}'); + } finally { + root?.unmount(); + } + }); + it('keeps the model badge sized to the model name in desktop rows', async () => { let root!: WebTestRenderer; diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 31ebc85..2234675 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -64,6 +64,12 @@ type ProxyDebugTraceDetailState = { }; type ProxyDebugTraceAttempt = ProxyDebugTraceDetail['attempts'][number]; +type StoredDebugPreviewPayload = { + __metapiTruncated?: boolean; + preview?: string; + originalBytes?: number; + storedBytes?: number; +}; const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = 50; @@ -185,6 +191,24 @@ const detailExpandableSummaryStyle: React.CSSProperties = { background: 'color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg) 14%)', }; +async function copyTextToClipboard(text: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); +} + function formatLatency(ms: number) { if (ms >= 1000) { return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`; @@ -465,6 +489,67 @@ function formatProxyDebugTargetSummary(settings: ProxyDebugSettingsState) { return parts.length > 0 ? parts.join(',') : '不过滤,记录所有命中的新请求'; } +function stringifyStoredDebugValue(value: unknown): string | null { + if (value == null) return null; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function parseStoredDebugPreview(value: unknown): { + raw: string | null; + displayText: string; + truncated: boolean; + note: string | null; +} { + const raw = stringifyStoredDebugValue(value); + if (!raw) { + return { + raw: null, + displayText: '-', + truncated: false, + note: null, + }; + } + + try { + const parsed = JSON.parse(raw) as StoredDebugPreviewPayload | string; + if (typeof parsed === 'string') { + return { + raw, + displayText: parsed || '-', + truncated: false, + note: null, + }; + } + + if (parsed && typeof parsed === 'object' && parsed.__metapiTruncated && typeof parsed.preview === 'string') { + const originalBytes = Number(parsed.originalBytes || 0); + const storedBytes = Number(parsed.storedBytes || 0); + return { + raw, + displayText: parsed.preview || '-', + truncated: true, + note: originalBytes > 0 && storedBytes > 0 + ? `内容已截断展示,原始 ${originalBytes} bytes,当前保留 ${storedBytes} bytes。复制按钮会复制当前数据库里保存的内容。` + : '内容已截断展示。复制按钮会复制当前数据库里保存的内容。', + }; + } + } catch { + // Fall through to display the saved raw value directly. + } + + return { + raw, + displayText: raw, + truncated: false, + note: null, + }; +} + function CompactSummaryMetric({ label, value, @@ -921,6 +1006,19 @@ export default function ProxyLogs() { setSelectedDebugTraceId(traceId); setShowDebugTraceDetailModal(true); }, []); + const handleCopyStoredDebugValue = useCallback(async (label: string, value: unknown) => { + const normalized = parseStoredDebugPreview(value); + if (!normalized.raw) { + toast.error(`${label}为空,无法复制`); + return; + } + try { + await copyTextToClipboard(normalized.raw); + toast.success(`已复制${label}`); + } catch (error: any) { + toast.error(error?.message || `复制${label}失败`); + } + }, [toast]); function renderTraceStatusBadge(trace: ProxyDebugTraceListItem) { const failed = trace.finalStatus === 'failed'; @@ -932,6 +1030,32 @@ export default function ProxyLogs() { } function renderAttemptDetail(attempt: ProxyDebugTraceAttempt) { + const serializedAttempt = [ + `targetUrl: ${attempt.targetUrl}`, + `runtimeExecutor: ${attempt.runtimeExecutor || '-'}`, + `recoverApplied: ${attempt.recoverApplied ? 'true' : 'false'}`, + `downgradeDecision: ${attempt.downgradeDecision ? 'true' : 'false'}`, + `downgradeReason: ${attempt.downgradeReason || '-'}`, + '', + 'requestHeaders:', + stringifyStoredDebugValue(attempt.requestHeadersJson) || '-', + '', + 'requestBody:', + stringifyStoredDebugValue(attempt.requestBodyJson) || '-', + '', + 'responseHeaders:', + stringifyStoredDebugValue(attempt.responseHeadersJson) || '-', + '', + 'responseBody:', + stringifyStoredDebugValue(attempt.responseBodyJson) || '-', + '', + 'rawErrorText:', + attempt.rawErrorText || '-', + '', + 'memoryWrite:', + stringifyStoredDebugValue(attempt.memoryWriteJson) || '-', + ].join('\n'); + return (
@@ -961,31 +1085,41 @@ export default function ProxyLogs() { 降级原因:{attempt.downgradeReason} ) : null} -
-{`targetUrl: ${attempt.targetUrl}
-runtimeExecutor: ${attempt.runtimeExecutor || '-'}
-recoverApplied: ${attempt.recoverApplied ? 'true' : 'false'}
-downgradeDecision: ${attempt.downgradeDecision ? 'true' : 'false'}
-downgradeReason: ${attempt.downgradeReason || '-'}
+          
{serializedAttempt}
+ +
+ ); + } -requestHeaders: -${attempt.requestHeadersJson || '-'} + function renderStoredDebugDetails(title: string, value: unknown, options?: { defaultOpen?: boolean; copyLabel?: string }) { + const normalized = parseStoredDebugPreview(value); + const copyLabel = options?.copyLabel || title; -requestBody: -${attempt.requestBodyJson || '-'} - -responseHeaders: -${attempt.responseHeadersJson || '-'} - -responseBody: -${attempt.responseBodyJson || '-'} - -rawErrorText: -${attempt.rawErrorText || '-'} - -memoryWrite: -${attempt.memoryWriteJson || '-'}`} - + return ( +
+ + {title} + +
+
+ +
+ {normalized.note ? ( +
{normalized.note}
+ ) : null} +
{normalized.displayText}
); @@ -1012,6 +1146,8 @@ ${attempt.memoryWriteJson || '-'}`} return
暂无追踪详情。
; } + const traceDetail = selectedDebugTraceDetail.data.trace; + return (
@@ -1019,48 +1155,53 @@ ${attempt.memoryWriteJson || '-'}`}
下游路径
-
{selectedDebugTraceDetail.data.trace.downstreamPath || '-'}
+
{traceDetail.downstreamPath || '-'}
Session
-
{selectedDebugTraceDetail.data.trace.sessionId || '-'}
+
{traceDetail.sessionId || '-'}
模型
-
{selectedDebugTraceDetail.data.trace.requestedModel || '-'}
+
{traceDetail.requestedModel || '-'}
最终上游路径
-
{selectedDebugTraceDetail.data.trace.finalUpstreamPath || '-'}
+
{traceDetail.finalUpstreamPath || '-'}
候选 endpoint
- - {selectedDebugTraceDetail.data.trace.endpointCandidatesJson || '-'} - +
+
+ +
+ + {parseStoredDebugPreview(traceDetail.endpointCandidatesJson).displayText} + +
-
- 原始下游请求头 -
-
{selectedDebugTraceDetail.data.trace.requestHeadersJson || '-'}
-
-
-
- 原始下游请求体 -
-
{selectedDebugTraceDetail.data.trace.requestBodyJson || '-'}
-
-
-
- 最终响应 -
-
{selectedDebugTraceDetail.data.trace.finalResponseBodyJson || '-'}
-
-
+ {renderStoredDebugDetails('原始下游请求头', traceDetail.requestHeadersJson, { + defaultOpen: true, + copyLabel: '原始下游请求头', + })} + {renderStoredDebugDetails('原始下游请求体', traceDetail.requestBodyJson, { + copyLabel: '原始下游请求体', + })} + {renderStoredDebugDetails('最终响应', traceDetail.finalResponseBodyJson, { + copyLabel: '最终响应', + })}