Fix proxy debug trace truncation previews (#313)

This commit is contained in:
Cita
2026-03-29 15:08:37 +08:00
committed by GitHub
parent 7092e2f129
commit dcfd45ea65
4 changed files with 294 additions and 50 deletions
@@ -150,4 +150,37 @@ describe('proxyDebugTraceStore', () => {
downgradeDecision: true, 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"');
});
}); });
+19 -2
View File
@@ -25,6 +25,24 @@ export type ProxyDebugTraceSession = {
let lastPruneAtMs = 0; let lastPruneAtMs = 0;
const PRUNE_INTERVAL_MS = 5 * 60 * 1000; 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 { function stringifyDebugValue(value: unknown, maxBytes: number): string | null {
if (value == null) return null; if (value == null) return null;
@@ -45,8 +63,7 @@ function stringifyDebugValue(value: unknown, maxBytes: number): string | null {
return text; return text;
} }
const truncated = buffer.subarray(0, Math.max(0, maxBytes)).toString('utf8'); return buildTruncatedDebugPreview(text, maxBytes, buffer.length);
return `${truncated}\n...[truncated ${buffer.length - maxBytes} bytes]`;
} }
function normalizeHeadersValue(value: HeadersLike): Record<string, unknown> | null { function normalizeHeadersValue(value: HeadersLike): Record<string, unknown> | null {
@@ -97,6 +97,15 @@ function buildListResponse(overrides?: Partial<{
describe('ProxyLogs server-driven page', () => { describe('ProxyLogs server-driven page', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
Object.defineProperty(globalThis, 'navigator', {
value: {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
},
configurable: true,
writable: true,
});
apiMock.getSites.mockResolvedValue([ apiMock.getSites.mockResolvedValue([
{ id: 9, name: 'main-site', status: 'active' }, { id: 9, name: 'main-site', status: 'active' },
{ id: 12, name: 'backup-site', status: 'active' }, { id: 12, name: 'backup-site', status: 'active' },
@@ -185,6 +194,7 @@ describe('ProxyLogs server-driven page', () => {
id: 701, id: 701,
requestedModel: 'gpt-4o', requestedModel: 'gpt-4o',
sessionId: 'sess-debug-1', sessionId: 'sess-debug-1',
requestHeadersJson: '{\n "authorization": "Bearer demo"\n}',
}, },
attempts: [], 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(
<MemoryRouter initialEntries={['/logs']}>
<ToastProvider>
<ProxyLogs />
</ToastProvider>
</MemoryRouter>,
);
});
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 () => { it('keeps the model badge sized to the model name in desktop rows', async () => {
let root!: WebTestRenderer; let root!: WebTestRenderer;
+187 -46
View File
@@ -64,6 +64,12 @@ type ProxyDebugTraceDetailState = {
}; };
type ProxyDebugTraceAttempt = ProxyDebugTraceDetail['attempts'][number]; type ProxyDebugTraceAttempt = ProxyDebugTraceDetail['attempts'][number];
type StoredDebugPreviewPayload = {
__metapiTruncated?: boolean;
preview?: string;
originalBytes?: number;
storedBytes?: number;
};
const PAGE_SIZES = [20, 50, 100]; const PAGE_SIZES = [20, 50, 100];
const DEFAULT_PAGE_SIZE = 50; 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%)', 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) { function formatLatency(ms: number) {
if (ms >= 1000) { if (ms >= 1000) {
return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`; return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`;
@@ -465,6 +489,67 @@ function formatProxyDebugTargetSummary(settings: ProxyDebugSettingsState) {
return parts.length > 0 ? parts.join(',') : '不过滤,记录所有命中的新请求'; 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({ function CompactSummaryMetric({
label, label,
value, value,
@@ -921,6 +1006,19 @@ export default function ProxyLogs() {
setSelectedDebugTraceId(traceId); setSelectedDebugTraceId(traceId);
setShowDebugTraceDetailModal(true); 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) { function renderTraceStatusBadge(trace: ProxyDebugTraceListItem) {
const failed = trace.finalStatus === 'failed'; const failed = trace.finalStatus === 'failed';
@@ -932,6 +1030,32 @@ export default function ProxyLogs() {
} }
function renderAttemptDetail(attempt: ProxyDebugTraceAttempt) { 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 ( return (
<details key={attempt.id} style={detailExpandableCardStyle}> <details key={attempt.id} style={detailExpandableCardStyle}>
<summary style={detailExpandableSummaryStyle}> <summary style={detailExpandableSummaryStyle}>
@@ -961,31 +1085,41 @@ export default function ProxyLogs() {
降级原因:{attempt.downgradeReason} 降级原因:{attempt.downgradeReason}
</div> </div>
) : null} ) : null}
<pre style={debugCodeBlockStyle}> <pre style={debugCodeBlockStyle}>{serializedAttempt}</pre>
{`targetUrl: ${attempt.targetUrl} </div>
runtimeExecutor: ${attempt.runtimeExecutor || '-'} </details>
recoverApplied: ${attempt.recoverApplied ? 'true' : 'false'} );
downgradeDecision: ${attempt.downgradeDecision ? 'true' : 'false'} }
downgradeReason: ${attempt.downgradeReason || '-'}
requestHeaders: function renderStoredDebugDetails(title: string, value: unknown, options?: { defaultOpen?: boolean; copyLabel?: string }) {
${attempt.requestHeadersJson || '-'} const normalized = parseStoredDebugPreview(value);
const copyLabel = options?.copyLabel || title;
requestBody: return (
${attempt.requestBodyJson || '-'} <details open={options?.defaultOpen} style={detailExpandableCardStyle}>
<summary style={detailExpandableSummaryStyle}>
responseHeaders: <span>{title}</span>
${attempt.responseHeadersJson || '-'} </summary>
<div style={{ padding: 12, display: 'grid', gap: 10 }}>
responseBody: <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
${attempt.responseBodyJson || '-'} <button
type="button"
rawErrorText: className="btn btn-ghost"
${attempt.rawErrorText || '-'} style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
aria-label={`复制${copyLabel}`}
memoryWrite: onClick={(event) => {
${attempt.memoryWriteJson || '-'}`} event.preventDefault();
</pre> event.stopPropagation();
void handleCopyStoredDebugValue(copyLabel, value);
}}
>
复制当前保存内容
</button>
</div>
{normalized.note ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{normalized.note}</div>
) : null}
<pre style={debugCodeBlockStyle}>{normalized.displayText}</pre>
</div> </div>
</details> </details>
); );
@@ -1012,6 +1146,8 @@ ${attempt.memoryWriteJson || '-'}`}
return <div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无追踪详情。</div>; return <div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无追踪详情。</div>;
} }
const traceDetail = selectedDebugTraceDetail.data.trace;
return ( return (
<div style={{ display: 'grid', gap: 12 }}> <div style={{ display: 'grid', gap: 12 }}>
<div style={{ ...formSectionStyle, gap: 10 }}> <div style={{ ...formSectionStyle, gap: 10 }}>
@@ -1019,48 +1155,53 @@ ${attempt.memoryWriteJson || '-'}`}
<div style={detailInfoGridStyle}> <div style={detailInfoGridStyle}>
<div style={detailInfoItemStyle}> <div style={detailInfoItemStyle}>
<div style={detailInfoLabelStyle}>下游路径</div> <div style={detailInfoLabelStyle}>下游路径</div>
<div style={detailInfoValueStyle}>{selectedDebugTraceDetail.data.trace.downstreamPath || '-'}</div> <div style={detailInfoValueStyle}>{traceDetail.downstreamPath || '-'}</div>
</div> </div>
<div style={detailInfoItemStyle}> <div style={detailInfoItemStyle}>
<div style={detailInfoLabelStyle}>Session</div> <div style={detailInfoLabelStyle}>Session</div>
<div style={detailInfoValueStyle}>{selectedDebugTraceDetail.data.trace.sessionId || '-'}</div> <div style={detailInfoValueStyle}>{traceDetail.sessionId || '-'}</div>
</div> </div>
<div style={detailInfoItemStyle}> <div style={detailInfoItemStyle}>
<div style={detailInfoLabelStyle}>模型</div> <div style={detailInfoLabelStyle}>模型</div>
<div style={detailInfoValueStyle}>{selectedDebugTraceDetail.data.trace.requestedModel || '-'}</div> <div style={detailInfoValueStyle}>{traceDetail.requestedModel || '-'}</div>
</div> </div>
<div style={detailInfoItemStyle}> <div style={detailInfoItemStyle}>
<div style={detailInfoLabelStyle}>最终上游路径</div> <div style={detailInfoLabelStyle}>最终上游路径</div>
<div style={detailInfoValueStyle}>{selectedDebugTraceDetail.data.trace.finalUpstreamPath || '-'}</div> <div style={detailInfoValueStyle}>{traceDetail.finalUpstreamPath || '-'}</div>
</div> </div>
<div style={{ ...detailInfoItemStyle, gridColumn: '1 / -1' }}> <div style={{ ...detailInfoItemStyle, gridColumn: '1 / -1' }}>
<div style={detailInfoLabelStyle}>候选 endpoint</div> <div style={detailInfoLabelStyle}>候选 endpoint</div>
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
aria-label="复制候选 endpoint"
onClick={() => void handleCopyStoredDebugValue('候选 endpoint', traceDetail.endpointCandidatesJson)}
>
复制当前保存内容
</button>
</div>
<code style={{ ...debugCodeBlockStyle, margin: 0, fontSize: 11 }}> <code style={{ ...debugCodeBlockStyle, margin: 0, fontSize: 11 }}>
{selectedDebugTraceDetail.data.trace.endpointCandidatesJson || '-'} {parseStoredDebugPreview(traceDetail.endpointCandidatesJson).displayText}
</code> </code>
</div> </div>
</div> </div>
</div> </div>
</div>
<div style={{ display: 'grid', gap: 10 }}> <div style={{ display: 'grid', gap: 10 }}>
<details open style={detailExpandableCardStyle}> {renderStoredDebugDetails('原始下游请求头', traceDetail.requestHeadersJson, {
<summary style={detailExpandableSummaryStyle}>原始下游请求头</summary> defaultOpen: true,
<div style={{ padding: 12 }}> copyLabel: '原始下游请求头',
<pre style={debugCodeBlockStyle}>{selectedDebugTraceDetail.data.trace.requestHeadersJson || '-'}</pre> })}
</div> {renderStoredDebugDetails('原始下游请求体', traceDetail.requestBodyJson, {
</details> copyLabel: '原始下游请求体',
<details style={detailExpandableCardStyle}> })}
<summary style={detailExpandableSummaryStyle}>原始下游请求体</summary> {renderStoredDebugDetails('最终响应', traceDetail.finalResponseBodyJson, {
<div style={{ padding: 12 }}> copyLabel: '最终响应',
<pre style={debugCodeBlockStyle}>{selectedDebugTraceDetail.data.trace.requestBodyJson || '-'}</pre> })}
</div>
</details>
<details style={detailExpandableCardStyle}>
<summary style={detailExpandableSummaryStyle}>最终响应</summary>
<div style={{ padding: 12 }}>
<pre style={debugCodeBlockStyle}>{selectedDebugTraceDetail.data.trace.finalResponseBodyJson || '-'}</pre>
</div>
</details>
</div> </div>
<div style={{ display: 'grid', gap: 8 }}> <div style={{ display: 'grid', gap: 8 }}>