mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-09-01 14:52:19 +08:00
Update router configuration and provider handling
This commit is contained in:
@@ -33,5 +33,5 @@ Request body / response body logging is governed by three low-level config optio
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `requestLogBodyCapture` | `all` | Whether to record request and response bodies: `all` records all, `errors` records bodies only for failed requests, `none` records no bodies. |
|
||||
| `requestLogMaxBodyBytes` | `52428800` (50 MiB) | Maximum bytes for a single request or response body; anything larger is truncated. The hard cap is also 50 MiB. |
|
||||
| `requestLogMaxBodyBytes` | `52428800` (50 MiB) | In-memory body capture and preview budget for a single request or response body. File-backed raw trace bodies can exceed this limit; the full body is stored in sidecar storage and loaded on demand from the Logs page. |
|
||||
| `requestLogSuccessSampleRate` | `1` | Sampling rate for successful requests, between `0` and `1`. `1` records all, `0.1` records roughly one in ten. |
|
||||
|
||||
@@ -33,5 +33,5 @@ lead: 本页是日志与观测相关开关和面板能力的配置参考:在
|
||||
| 配置项 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `requestLogBodyCapture` | `all` | 控制是否记录请求体和响应体:`all` 记录全部,`errors` 只记录失败请求的 body,`none` 不记录任何 body。 |
|
||||
| `requestLogMaxBodyBytes` | `52428800`(50 MiB) | 单个请求体或响应体的最大字节数,超出部分会被截断;硬上限同样是 50 MiB。 |
|
||||
| `requestLogMaxBodyBytes` | `52428800`(50 MiB) | 单个请求体或响应体的内存捕获与预览预算。基于文件的 raw trace body 可以超过该限制;完整 body 会保存到 sidecar 存储,并在日志页按需加载。 |
|
||||
| `requestLogSuccessSampleRate` | `1` | 成功请求的采样率,取 `0` 到 `1` 之间,`1` 表示全部记录,`0.1` 表示记录约十分之一。 |
|
||||
|
||||
@@ -27,6 +27,7 @@ export const PROXY_CA_CERT_FILE = path.join(CERTDIR, "ca.pem");
|
||||
export const PROXY_CA_CERT_DER_FILE = path.join(CERTDIR, "ca.cer");
|
||||
export const PROXY_CA_KEY_FILE = path.join(CERTDIR, "key.pem");
|
||||
export const REQUEST_LOGS_DB_FILE = path.join(DATADIR, "request-logs.sqlite");
|
||||
export const REQUEST_LOG_BODIES_DIR = path.join(DATADIR, "request-log-bodies");
|
||||
export const CONTEXT_ARCHIVE_DB_FILE = path.join(DATADIR, "context-archive.sqlite");
|
||||
export const RAW_TRACE_SPOOL_DIR = path.join(DATADIR, "raw-trace-spool");
|
||||
export const USAGE_DB_FILE = path.join(DATADIR, "usage.sqlite");
|
||||
|
||||
@@ -1935,10 +1935,12 @@ export type ProxyCertificateInstallResult = {
|
||||
export type ProxyNetworkCaptureState = "complete" | "error" | "pending";
|
||||
|
||||
export type ProxyNetworkBody = {
|
||||
bodyRef?: string;
|
||||
contentType?: string;
|
||||
decodedFrom?: string;
|
||||
encoding: "base64" | "utf8";
|
||||
error?: string;
|
||||
preview?: boolean;
|
||||
sizeBytes: number;
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
@@ -1993,6 +1995,28 @@ export type RequestLogDetailRequest = {
|
||||
|
||||
export type RequestLogBody = ProxyNetworkBody;
|
||||
|
||||
export type RequestLogBodySide = "request" | "response";
|
||||
|
||||
export type RequestLogBodyChunkRequest = {
|
||||
id: number;
|
||||
length?: number;
|
||||
offset?: number;
|
||||
side: RequestLogBodySide;
|
||||
};
|
||||
|
||||
export type RequestLogBodyChunk = {
|
||||
bodyRef?: string;
|
||||
contentType?: string;
|
||||
encoding: "base64" | "utf8";
|
||||
eof: boolean;
|
||||
length: number;
|
||||
nextOffset?: number;
|
||||
offset: number;
|
||||
sizeBytes: number;
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
export type RequestLogRetryAttempt = {
|
||||
attempt: number;
|
||||
delayMs: number;
|
||||
|
||||
@@ -21,6 +21,7 @@ export const IPC_CHANNELS = {
|
||||
appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures",
|
||||
appGetProxyStatus: "ccr:app:get-proxy-status",
|
||||
appGetRequestLogDetail: "ccr:app:get-request-log-detail",
|
||||
appGetRequestLogBodyChunk: "ccr:app:get-request-log-body-chunk",
|
||||
appGetRequestLogs: "ccr:app:get-request-logs",
|
||||
appGetUpdateStatus: "ccr:app:get-update-status",
|
||||
appGetUsageStats: "ccr:app:get-usage-stats",
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
suppressRequestLogRawTraceBodies,
|
||||
type RequestLogEnqueueResult
|
||||
} from "@ccr/core/observability/request-log-runtime";
|
||||
import { resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits";
|
||||
import { rawTraceMaxPartBytes, resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits";
|
||||
import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value";
|
||||
import { formatError, parseJsonObject, readHeader, readRequestBody, sendJson } from "@ccr/core/gateway/http/io";
|
||||
import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor";
|
||||
@@ -1415,7 +1415,7 @@ export function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string
|
||||
return {
|
||||
deleteLocalAfterUpload: false,
|
||||
enabled,
|
||||
maxPartBytes: maxBodyBytes,
|
||||
maxPartBytes: rawTraceMaxPartBytes,
|
||||
mode: "wire_raw",
|
||||
spoolDir: RAW_TRACE_SPOOL_DIR,
|
||||
sync: {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Raw traces are produced outside the request-log runtime, so keep an
|
||||
// independent hard ceiling as defense in depth if configuration validation is
|
||||
// bypassed or its public range grows in the future.
|
||||
// Keep the public body-capture setting bounded for in-memory previews and
|
||||
// admission accounting. Raw trace sources can write larger parts because the
|
||||
// request-log store moves them into sidecar files instead of keeping them
|
||||
// inline in SQLite.
|
||||
export const rawTraceHardMaxBodyBytes = 50 * 1024 * 1024;
|
||||
export const maxRequestLogBodyBytes = rawTraceHardMaxBodyBytes;
|
||||
export const defaultRequestLogBodyBytes = rawTraceHardMaxBodyBytes;
|
||||
export const rawTraceMaxPartBytes = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export function resolveRawTraceBodyLimit(value: number | undefined): number {
|
||||
const configured = value ?? defaultRequestLogBodyBytes;
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
AgentAnalysisTracePayloadFullResult,
|
||||
AgentAnalysisTracePayloadRequest,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogBodyChunk,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogEntry,
|
||||
RequestLogListFilter,
|
||||
RequestLogPage
|
||||
@@ -309,6 +311,10 @@ export class RequestLogRuntime {
|
||||
return await this.query<RequestLogEntry | undefined>("getDetail", [request]);
|
||||
}
|
||||
|
||||
async getBodyChunk(request: RequestLogBodyChunkRequest): Promise<RequestLogBodyChunk | undefined> {
|
||||
return await this.query<RequestLogBodyChunk | undefined>("getBodyChunk", [request]);
|
||||
}
|
||||
|
||||
async analyze(filter?: AgentAnalysisFilter): Promise<AgentAnalysisSnapshot> {
|
||||
return await this.query<AgentAnalysisSnapshot>("analyze", [filter]);
|
||||
}
|
||||
@@ -1027,12 +1033,17 @@ function constrainRawTraceFiles(
|
||||
export function suppressRequestLogRawTraceBodies(
|
||||
input: RequestLogRawTraceUpdateInput
|
||||
): RequestLogRawTraceUpdateInput {
|
||||
const {
|
||||
requestBodyRef: _requestBodyRef,
|
||||
responseBodyRef: _responseBodyRef,
|
||||
...metadata
|
||||
} = input;
|
||||
const requestSize = input.requestBodySizeBytes ??
|
||||
(input.requestBodyText === undefined ? undefined : Buffer.byteLength(input.requestBodyText));
|
||||
const responseSize = input.responseBodySizeBytes ??
|
||||
(input.responseBodyText === undefined ? undefined : Buffer.byteLength(input.responseBodyText));
|
||||
return {
|
||||
...input,
|
||||
...metadata,
|
||||
...(requestSize === undefined ? {} : {
|
||||
requestBodySizeBytes: requestSize,
|
||||
requestBodyText: "",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants";
|
||||
import { REQUEST_LOG_BODIES_DIR, REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants";
|
||||
import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes";
|
||||
import {
|
||||
estimateUsageCostUsd,
|
||||
@@ -45,6 +46,9 @@ import type {
|
||||
GatewayProviderProtocol,
|
||||
ProviderModelPricing,
|
||||
RequestLogBody,
|
||||
RequestLogBodyChunk,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogBodySide,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogEntry,
|
||||
RequestLogFilterOptions,
|
||||
@@ -147,6 +151,7 @@ export type RequestLogRawTraceUpdateInput = {
|
||||
path?: string;
|
||||
provider?: string;
|
||||
requestBodyContentType?: string;
|
||||
requestBodyRef?: string;
|
||||
requestBodySizeBytes?: number;
|
||||
requestBodyText?: string;
|
||||
requestBodyTruncated?: boolean;
|
||||
@@ -154,6 +159,7 @@ export type RequestLogRawTraceUpdateInput = {
|
||||
requestId: string;
|
||||
isStream?: boolean;
|
||||
responseBodyContentType?: string;
|
||||
responseBodyRef?: string;
|
||||
responseBodySizeBytes?: number;
|
||||
responseBodyText?: string;
|
||||
responseBodyTruncated?: boolean;
|
||||
@@ -299,6 +305,8 @@ type ToolCallStreamState = {
|
||||
};
|
||||
|
||||
const maxBodyBytes = maxRequestLogBodyBytes;
|
||||
const requestLogInlineBodyBytes = 160 * 1024;
|
||||
const requestLogBodyChunkMaxBytes = 1024 * 1024;
|
||||
const maxAgentAnalysisRows = 5000;
|
||||
const maxAgentSessionDetailRequests = 250;
|
||||
const maxTracePayloadPreviewChars = 1600;
|
||||
@@ -326,7 +334,9 @@ const terminalSseResponseStatuses = new Set([
|
||||
]);
|
||||
const requestLogBodyMetadataSelect = `
|
||||
'' AS request_body_text,
|
||||
'' AS response_body_text
|
||||
'' AS response_body_text,
|
||||
request_body_ref,
|
||||
response_body_ref
|
||||
`;
|
||||
const emptyAgentAnalysisTotals: AgentAnalysisTotals = {
|
||||
avgDurationMs: 0,
|
||||
@@ -365,7 +375,12 @@ export class RequestLogStore {
|
||||
private revision = 0;
|
||||
private analysisCache?: AgentAnalysisCacheEntry;
|
||||
|
||||
constructor(private readonly dbFile: string) {}
|
||||
constructor(
|
||||
private readonly dbFile: string,
|
||||
private readonly bodyDir = dbFile === REQUEST_LOGS_DB_FILE
|
||||
? REQUEST_LOG_BODIES_DIR
|
||||
: join(dirname(dbFile), "request-log-bodies")
|
||||
) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.getDatabase();
|
||||
@@ -404,6 +419,9 @@ export class RequestLogStore {
|
||||
if (requestId) {
|
||||
const pending = this.takePendingRawTraceUpdate(database, requestId);
|
||||
if (pending) {
|
||||
if (command.input.captureBody === false) {
|
||||
deleteRequestLogBodyRefs(this.bodyDir, bodyRefsFromRawTraceInput(pending));
|
||||
}
|
||||
const pendingInput = command.input.captureBody === false
|
||||
? suppressRequestLogRawTraceBodies(pending)
|
||||
: pending;
|
||||
@@ -419,11 +437,12 @@ export class RequestLogStore {
|
||||
if (bundleId && hasProcessedRawTraceBundle(database, bundleId)) {
|
||||
continue;
|
||||
}
|
||||
const applied = await this.updateFromRawTrace(command.input);
|
||||
const rawTraceInput = this.prepareRawTraceInput(command.input, command.rawTraceFiles);
|
||||
const applied = await this.updateFromRawTrace(rawTraceInput);
|
||||
if (bundleId && applied) {
|
||||
rememberProcessedRawTraceBundle(database, bundleId, command.input.requestId);
|
||||
rememberProcessedRawTraceBundle(database, bundleId, rawTraceInput.requestId);
|
||||
} else if (!applied) {
|
||||
this.storePendingRawTraceUpdate(database, command.input);
|
||||
this.storePendingRawTraceUpdate(database, rawTraceInput);
|
||||
}
|
||||
}
|
||||
database.exec("COMMIT");
|
||||
@@ -573,7 +592,8 @@ export class RequestLogStore {
|
||||
: await estimateUsageCostUsd(costInput);
|
||||
const capturedRequestBody = bodyFromBuffer(
|
||||
input.requestBody,
|
||||
headerValue(requestHeaders, "content-type")
|
||||
headerValue(requestHeaders, "content-type"),
|
||||
{ bodyDir: this.bodyDir, side: "request" }
|
||||
);
|
||||
const requestBody: RequestLogBody = {
|
||||
...capturedRequestBody,
|
||||
@@ -584,7 +604,9 @@ export class RequestLogStore {
|
||||
responseBodyText,
|
||||
headerValue(responseHeaders, "content-type"),
|
||||
Boolean(input.responseBodyTruncated),
|
||||
input.responseBodySizeBytes
|
||||
input.responseBodySizeBytes,
|
||||
undefined,
|
||||
{ bodyDir: this.bodyDir, side: "response" }
|
||||
);
|
||||
const isStream = inferRequestLogIsStream({
|
||||
path: input.path,
|
||||
@@ -636,13 +658,15 @@ export class RequestLogStore {
|
||||
request_body_content_type,
|
||||
request_body_size_bytes,
|
||||
request_body_truncated,
|
||||
request_body_ref,
|
||||
response_body_text,
|
||||
response_body_encoding,
|
||||
response_body_content_type,
|
||||
response_body_size_bytes,
|
||||
response_body_truncated,
|
||||
response_body_ref,
|
||||
error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
let inserted = false;
|
||||
@@ -687,11 +711,13 @@ export class RequestLogStore {
|
||||
requestBody.contentType ?? "",
|
||||
requestBody.sizeBytes,
|
||||
requestBody.truncated ? 1 : 0,
|
||||
requestBody.bodyRef ?? "",
|
||||
responseBody.text,
|
||||
responseBody.encoding,
|
||||
responseBody.contentType ?? "",
|
||||
responseBody.sizeBytes,
|
||||
responseBody.truncated ? 1 : 0,
|
||||
responseBody.bodyRef ?? "",
|
||||
responseError ?? ""
|
||||
);
|
||||
if (result.changes === 0) return;
|
||||
@@ -774,6 +800,9 @@ export class RequestLogStore {
|
||||
rawInput,
|
||||
finalSuccessful
|
||||
);
|
||||
if (captureResolution.bodiesSuppressed) {
|
||||
deleteRequestLogBodyRefs(this.bodyDir, bodyRefsFromRawTraceInput(rawInput));
|
||||
}
|
||||
const input = captureResolution.input;
|
||||
const existingUsageContext = readRequestLogUsageContext(database, requestId);
|
||||
|
||||
@@ -908,27 +937,33 @@ export class RequestLogStore {
|
||||
url
|
||||
}) ? 1 : 0);
|
||||
}
|
||||
if (input.requestBodyText !== undefined && (
|
||||
captureResolution.bodiesSuppressed || input.requestBodyText.length > 0 || !existingOutcome.hasRequestBody
|
||||
const shouldApplyRequestBody = input.requestBodyText !== undefined ||
|
||||
Boolean(input.requestBodyRef && (!input.requestBodyTruncated || !existingOutcome.hasRequestBody));
|
||||
if (shouldApplyRequestBody && (
|
||||
captureResolution.bodiesSuppressed || Boolean(input.requestBodyRef) || (input.requestBodyText?.length ?? 0) > 0 || !existingOutcome.hasRequestBody
|
||||
)) {
|
||||
const requestBody = bodyFromText(
|
||||
input.requestBodyText,
|
||||
input.requestBodyText ?? "",
|
||||
input.requestBodyContentType ?? headerValue(mergedRequestHeaders ?? {}, "content-type"),
|
||||
Boolean(input.requestBodyTruncated),
|
||||
input.requestBodySizeBytes,
|
||||
rawTraceHardMaxBodyBytes
|
||||
rawTraceHardMaxBodyBytes,
|
||||
{ bodyDir: this.bodyDir, bodyRef: input.requestBodyRef, side: "request" }
|
||||
);
|
||||
pushBodyValues(sets, params, "request", requestBody);
|
||||
}
|
||||
if (input.responseBodyText !== undefined && (
|
||||
captureResolution.bodiesSuppressed || input.responseBodyText.length > 0 || !existingOutcome.hasResponseBody
|
||||
const shouldApplyResponseBody = input.responseBodyText !== undefined ||
|
||||
Boolean(input.responseBodyRef && (!input.responseBodyTruncated || !existingOutcome.hasResponseBody));
|
||||
if (shouldApplyResponseBody && (
|
||||
captureResolution.bodiesSuppressed || Boolean(input.responseBodyRef) || (input.responseBodyText?.length ?? 0) > 0 || !existingOutcome.hasResponseBody
|
||||
)) {
|
||||
const responseBody = bodyFromText(
|
||||
input.responseBodyText,
|
||||
input.responseBodyText ?? "",
|
||||
responseBodyContentType,
|
||||
Boolean(input.responseBodyTruncated),
|
||||
input.responseBodySizeBytes,
|
||||
rawTraceHardMaxBodyBytes
|
||||
rawTraceHardMaxBodyBytes,
|
||||
{ bodyDir: this.bodyDir, bodyRef: input.responseBodyRef, side: "response" }
|
||||
);
|
||||
pushBodyValues(sets, params, "response", responseBody);
|
||||
}
|
||||
@@ -1036,6 +1071,75 @@ export class RequestLogStore {
|
||||
return entry;
|
||||
}
|
||||
|
||||
async getBodyChunk(request: RequestLogBodyChunkRequest): Promise<RequestLogBodyChunk | undefined> {
|
||||
const database = await this.getDatabase();
|
||||
const requestLogId = normalizeCount(request.id);
|
||||
const side = request.side === "response" ? "response" : "request";
|
||||
if (requestLogId <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const row = queryRows(
|
||||
database,
|
||||
`
|
||||
SELECT
|
||||
${side}_body_text AS body_text,
|
||||
${side}_body_encoding AS body_encoding,
|
||||
${side}_body_content_type AS body_content_type,
|
||||
${side}_body_size_bytes AS body_size_bytes,
|
||||
${side}_body_truncated AS body_truncated,
|
||||
${side}_body_ref AS body_ref
|
||||
FROM request_logs
|
||||
WHERE rowid = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[requestLogId]
|
||||
)[0];
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const offset = clampInteger(request.offset, 0, Number.MAX_SAFE_INTEGER, 0);
|
||||
const length = clampInteger(request.length, 1, requestLogBodyChunkMaxBytes, requestLogBodyChunkMaxBytes);
|
||||
const encoding = String(row.body_encoding ?? "utf8") === "base64" ? "base64" : "utf8";
|
||||
const contentType = normalizeFilterValue(String(row.body_content_type ?? ""));
|
||||
const sizeBytes = normalizeCount(row.body_size_bytes);
|
||||
const truncated = normalizeCount(row.body_truncated) === 1;
|
||||
const bodyRef = normalizeFilterValue(String(row.body_ref ?? ""));
|
||||
|
||||
if (bodyRef) {
|
||||
const filePath = requestLogBodyPath(this.bodyDir, bodyRef);
|
||||
if (filePath && existsSync(filePath)) {
|
||||
return readRequestLogBodyChunkFromFile({
|
||||
bodyRef,
|
||||
contentType,
|
||||
encoding,
|
||||
filePath,
|
||||
length,
|
||||
offset,
|
||||
sizeBytes,
|
||||
truncated
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const text = String(row.body_text ?? "");
|
||||
const visible = text.slice(offset, offset + length);
|
||||
const nextOffset = offset + visible.length;
|
||||
return {
|
||||
...(bodyRef ? { bodyRef } : {}),
|
||||
contentType,
|
||||
encoding,
|
||||
eof: nextOffset >= text.length,
|
||||
length: visible.length,
|
||||
...(nextOffset < text.length ? { nextOffset } : {}),
|
||||
offset,
|
||||
sizeBytes: Math.max(sizeBytes, text.length),
|
||||
text: visible,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
|
||||
async analyze(filter: AgentAnalysisFilter = {}): Promise<AgentAnalysisSnapshot> {
|
||||
const database = await this.getDatabase();
|
||||
this.pruneOldRequestLogs(database);
|
||||
@@ -1091,11 +1195,13 @@ export class RequestLogStore {
|
||||
request_body_content_type,
|
||||
request_body_size_bytes,
|
||||
request_body_truncated,
|
||||
request_body_ref,
|
||||
response_body_text,
|
||||
response_body_encoding,
|
||||
response_body_content_type,
|
||||
response_body_size_bytes,
|
||||
response_body_truncated,
|
||||
response_body_ref,
|
||||
error
|
||||
FROM request_logs
|
||||
WHERE source_usage_id IS NULL
|
||||
@@ -1254,11 +1360,13 @@ export class RequestLogStore {
|
||||
request_body_content_type TEXT NOT NULL DEFAULT '',
|
||||
request_body_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
request_body_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
request_body_ref TEXT NOT NULL DEFAULT '',
|
||||
response_body_text TEXT NOT NULL DEFAULT '',
|
||||
response_body_encoding TEXT NOT NULL DEFAULT 'utf8',
|
||||
response_body_content_type TEXT NOT NULL DEFAULT '',
|
||||
response_body_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
response_body_truncated INTEGER NOT NULL DEFAULT 0,
|
||||
response_body_ref TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
@@ -1349,9 +1457,23 @@ export class RequestLogStore {
|
||||
return;
|
||||
}
|
||||
|
||||
const refs = queryRows(
|
||||
database,
|
||||
`
|
||||
SELECT request_body_ref, response_body_ref
|
||||
FROM request_logs
|
||||
WHERE source_usage_id IS NULL AND created_at < ?
|
||||
`,
|
||||
[cutoff]
|
||||
).flatMap((row) => [
|
||||
normalizeFilterValue(String(row.request_body_ref ?? "")),
|
||||
normalizeFilterValue(String(row.response_body_ref ?? ""))
|
||||
]).filter((value): value is string => Boolean(value));
|
||||
|
||||
database.prepare(
|
||||
"DELETE FROM request_logs WHERE source_usage_id IS NULL AND created_at < ?",
|
||||
).run(cutoff);
|
||||
deleteRequestLogBodyRefs(this.bodyDir, refs);
|
||||
this.lastRetentionCleanupDay = dayKey;
|
||||
}
|
||||
|
||||
@@ -1370,7 +1492,7 @@ export class RequestLogStore {
|
||||
update_json = excluded.update_json
|
||||
`).run(requestId, now, serialized.bytes, serialized.json);
|
||||
}
|
||||
prunePendingRawTraceUpdates(database, now);
|
||||
prunePendingRawTraceUpdates(database, now, this.bodyDir);
|
||||
}
|
||||
|
||||
private takePendingRawTraceUpdate(database: SqlDatabase, requestId: string): RequestLogRawTraceUpdateInput | undefined {
|
||||
@@ -1384,6 +1506,49 @@ export class RequestLogStore {
|
||||
const parsed = parseJson(String(row.update_json ?? ""));
|
||||
return isRecord(parsed) ? parsed as RequestLogRawTraceUpdateInput : undefined;
|
||||
}
|
||||
|
||||
private prepareRawTraceInput(
|
||||
input: RequestLogRawTraceUpdateInput,
|
||||
rawTraceFiles?: RequestLogRawTraceFiles
|
||||
): RequestLogRawTraceUpdateInput {
|
||||
const next: RequestLogRawTraceUpdateInput = { ...input };
|
||||
const requestBody = storeRawTraceBodyFile(this.bodyDir, rawTraceFiles?.requestBody);
|
||||
if (requestBody) {
|
||||
next.requestBodyRef = requestBody.bodyRef;
|
||||
if (!requestBody.truncated) next.requestBodyText ??= requestBody.previewText;
|
||||
next.requestBodyContentType ??= requestBody.contentType;
|
||||
next.requestBodySizeBytes = Math.max(normalizeCount(next.requestBodySizeBytes), requestBody.sizeBytes);
|
||||
next.requestBodyTruncated = Boolean(next.requestBodyTruncated) || requestBody.truncated;
|
||||
}
|
||||
const responseBody = storeRawTraceBodyFile(this.bodyDir, rawTraceFiles?.responseBody);
|
||||
if (responseBody) {
|
||||
next.responseBodyRef = responseBody.bodyRef;
|
||||
if (!responseBody.truncated) next.responseBodyText ??= responseBody.previewText;
|
||||
next.responseBodyContentType ??= responseBody.contentType;
|
||||
next.responseBodySizeBytes = Math.max(normalizeCount(next.responseBodySizeBytes), responseBody.sizeBytes);
|
||||
next.responseBodyTruncated = Boolean(next.responseBodyTruncated) || responseBody.truncated;
|
||||
}
|
||||
return this.prepareRawTraceTextBodies(next);
|
||||
}
|
||||
|
||||
private prepareRawTraceTextBodies(input: RequestLogRawTraceUpdateInput): RequestLogRawTraceUpdateInput {
|
||||
const next: RequestLogRawTraceUpdateInput = { ...input };
|
||||
if (!next.requestBodyRef && next.requestBodyText !== undefined) {
|
||||
const stored = storeBodyBuffer(this.bodyDir, Buffer.from(next.requestBodyText), next.requestBodyRef);
|
||||
if (stored) {
|
||||
next.requestBodyRef = stored.bodyRef;
|
||||
next.requestBodySizeBytes = Math.max(Buffer.byteLength(next.requestBodyText), normalizeCount(next.requestBodySizeBytes));
|
||||
}
|
||||
}
|
||||
if (!next.responseBodyRef && next.responseBodyText !== undefined) {
|
||||
const stored = storeBodyBuffer(this.bodyDir, Buffer.from(next.responseBodyText), next.responseBodyRef);
|
||||
if (stored) {
|
||||
next.responseBodyRef = stored.bodyRef;
|
||||
next.responseBodySizeBytes = Math.max(Buffer.byteLength(next.responseBodyText), normalizeCount(next.responseBodySizeBytes));
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
export const requestLogStore = new RequestLogStore(REQUEST_LOGS_DB_FILE);
|
||||
@@ -1430,6 +1595,15 @@ export async function getRequestLogDetail(request: RequestLogDetailRequest): Pro
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequestLogBodyChunk(request: RequestLogBodyChunkRequest): Promise<RequestLogBodyChunk | undefined> {
|
||||
try {
|
||||
return await requestLogRuntime.getBodyChunk(request);
|
||||
} catch (error) {
|
||||
console.warn(`[request-log] Failed to read request log body chunk: ${formatError(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgentAnalysis(filter?: AgentAnalysisFilter): Promise<AgentAnalysisSnapshot> {
|
||||
try {
|
||||
return await requestLogRuntime.analyze(filter);
|
||||
@@ -3452,11 +3626,13 @@ function ensureRequestLogSchema(database: SqlDatabase): void {
|
||||
addColumn("request_body_content_type", "TEXT NOT NULL DEFAULT ''");
|
||||
addColumn("request_body_size_bytes", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("request_body_truncated", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("request_body_ref", "TEXT NOT NULL DEFAULT ''");
|
||||
addColumn("response_body_text", "TEXT NOT NULL DEFAULT ''");
|
||||
addColumn("response_body_encoding", "TEXT NOT NULL DEFAULT 'utf8'");
|
||||
addColumn("response_body_content_type", "TEXT NOT NULL DEFAULT ''");
|
||||
addColumn("response_body_size_bytes", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("response_body_truncated", "INTEGER NOT NULL DEFAULT 0");
|
||||
addColumn("response_body_ref", "TEXT NOT NULL DEFAULT ''");
|
||||
addColumn("error", "TEXT NOT NULL DEFAULT ''");
|
||||
|
||||
if (needsModelSummaryMigration) {
|
||||
@@ -4048,11 +4224,13 @@ function readRequestLogById(database: SqlDatabase, id: number): StoredRequestLog
|
||||
request_body_content_type,
|
||||
request_body_size_bytes,
|
||||
request_body_truncated,
|
||||
request_body_ref,
|
||||
response_body_text,
|
||||
response_body_encoding,
|
||||
response_body_content_type,
|
||||
response_body_size_bytes,
|
||||
response_body_truncated,
|
||||
response_body_ref,
|
||||
error
|
||||
FROM request_logs
|
||||
WHERE rowid = ?
|
||||
@@ -4126,7 +4304,9 @@ function bodyFromRow(row: Record<string, SqlValue>, prefix: "request" | "respons
|
||||
|
||||
const encoding = String(row[`${prefix}_body_encoding`] ?? "utf8") === "base64" ? "base64" : "utf8";
|
||||
const contentType = normalizeFilterValue(String(row[`${prefix}_body_content_type`] ?? ""));
|
||||
const bodyRef = normalizeFilterValue(String(row[`${prefix}_body_ref`] ?? ""));
|
||||
return {
|
||||
...(bodyRef ? { bodyRef, preview: true } : {}),
|
||||
contentType,
|
||||
encoding,
|
||||
sizeBytes,
|
||||
@@ -4183,17 +4363,35 @@ function readDistinctValues(database: SqlDatabase, column: "credential_id" | "mo
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function bodyFromBuffer(buffer: Buffer, contentType?: string): RequestLogBody {
|
||||
type RequestLogBodyStorageOptions = {
|
||||
bodyDir: string;
|
||||
bodyRef?: string;
|
||||
side: RequestLogBodySide;
|
||||
};
|
||||
|
||||
function bodyFromBuffer(
|
||||
buffer: Buffer,
|
||||
contentType?: string,
|
||||
storage?: RequestLogBodyStorageOptions
|
||||
): RequestLogBody {
|
||||
const compacted = compactBase64ImagePayloads(buffer);
|
||||
const exceedsCaptureLimit = compacted.buffer.byteLength > maxBodyBytes;
|
||||
const data = exceedsCaptureLimit ? compacted.buffer.subarray(0, maxBodyBytes) : compacted.buffer;
|
||||
const textLike = isTextLikeContentType(contentType);
|
||||
const stored = textLike && storage
|
||||
? storeBodyBuffer(storage.bodyDir, buffer, storage.bodyRef, compacted.buffer)
|
||||
: undefined;
|
||||
const text = textLike
|
||||
? stored?.previewText ?? data.toString("utf8")
|
||||
: data.toString("base64");
|
||||
const truncated = !stored && (compacted.compacted || exceedsCaptureLimit);
|
||||
return {
|
||||
...(stored ? { bodyRef: stored.bodyRef, preview: true } : {}),
|
||||
contentType,
|
||||
encoding: textLike ? "utf8" : "base64",
|
||||
sizeBytes: buffer.byteLength,
|
||||
text: textLike ? data.toString("utf8") : data.toString("base64"),
|
||||
truncated: compacted.compacted || exceedsCaptureLimit
|
||||
text,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4202,20 +4400,29 @@ function bodyFromText(
|
||||
contentType?: string,
|
||||
alreadyTruncated = false,
|
||||
originalSizeBytes?: number,
|
||||
captureLimitBytes = maxBodyBytes
|
||||
captureLimitBytes = maxBodyBytes,
|
||||
storage?: RequestLogBodyStorageOptions
|
||||
): RequestLogBody {
|
||||
const buffer = Buffer.from(text);
|
||||
const sizeBytes = Math.max(buffer.byteLength, normalizeCount(originalSizeBytes));
|
||||
const compacted = compactBase64ImagePayloads(buffer);
|
||||
const truncated = alreadyTruncated || compacted.compacted || buffer.byteLength < sizeBytes ||
|
||||
compacted.buffer.byteLength > captureLimitBytes;
|
||||
const exceedsCaptureLimit = compacted.buffer.byteLength > captureLimitBytes;
|
||||
const data = exceedsCaptureLimit ? compacted.buffer.subarray(0, captureLimitBytes) : compacted.buffer;
|
||||
const stored = storage
|
||||
? storeBodyBuffer(storage.bodyDir, buffer, storage.bodyRef, compacted.buffer)
|
||||
: undefined;
|
||||
const truncated = alreadyTruncated || (!stored && (
|
||||
compacted.compacted ||
|
||||
buffer.byteLength < sizeBytes ||
|
||||
exceedsCaptureLimit
|
||||
));
|
||||
const bodyText = stored?.previewText ?? (exceedsCaptureLimit ? new StringDecoder("utf8").write(data) : data.toString("utf8"));
|
||||
return {
|
||||
...(stored ? { bodyRef: stored.bodyRef, preview: true } : {}),
|
||||
contentType,
|
||||
encoding: "utf8",
|
||||
sizeBytes,
|
||||
text: exceedsCaptureLimit ? new StringDecoder("utf8").write(data) : data.toString("utf8"),
|
||||
text: bodyText,
|
||||
truncated
|
||||
};
|
||||
}
|
||||
@@ -4236,6 +4443,231 @@ function pushBodyValues(
|
||||
params.push(body.sizeBytes);
|
||||
sets.push(`${prefix}_body_truncated = ?`);
|
||||
params.push(body.truncated ? 1 : 0);
|
||||
sets.push(`${prefix}_body_ref = ?`);
|
||||
params.push(body.bodyRef ?? "");
|
||||
}
|
||||
|
||||
type StoredRequestLogBodyFile = {
|
||||
bodyRef: string;
|
||||
contentType?: string;
|
||||
previewText: string;
|
||||
sizeBytes: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
|
||||
function storeRawTraceBodyFile(
|
||||
bodyDir: string,
|
||||
file: RequestLogRawTraceFile | undefined
|
||||
): StoredRequestLogBodyFile | undefined {
|
||||
if (!file || file.sizeBytes <= 0 || !existsSync(file.filePath)) {
|
||||
return undefined;
|
||||
}
|
||||
const bodyRef = createRequestLogBodyRef();
|
||||
const target = requestLogBodyPath(bodyDir, bodyRef, true);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
copyFileSync(file.filePath, target);
|
||||
const storedBytes = statSync(target).size;
|
||||
const sizeBytes = Math.max(file.sizeBytes, storedBytes);
|
||||
return {
|
||||
bodyRef,
|
||||
contentType: file.contentType,
|
||||
previewText: readRequestLogBodyPreview(target),
|
||||
sizeBytes,
|
||||
truncated: Boolean(file.truncated) || storedBytes < sizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
function storeBodyBuffer(
|
||||
bodyDir: string,
|
||||
buffer: Buffer,
|
||||
existingBodyRef?: string,
|
||||
previewBuffer = buffer
|
||||
): { bodyRef: string; previewText: string } | undefined {
|
||||
const shouldStore = Boolean(existingBodyRef) || buffer.byteLength > requestLogInlineBodyBytes;
|
||||
if (!shouldStore) {
|
||||
return undefined;
|
||||
}
|
||||
const bodyRef = normalizeBodyRef(existingBodyRef) ?? createRequestLogBodyRef();
|
||||
const target = requestLogBodyPath(bodyDir, bodyRef, true);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
if (existingBodyRef && existsSync(target)) {
|
||||
return {
|
||||
bodyRef,
|
||||
previewText: previewBuffer.byteLength > 0
|
||||
? createRequestLogBodyPreviewText(previewBuffer)
|
||||
: readRequestLogBodyPreview(target)
|
||||
};
|
||||
}
|
||||
if (!existingBodyRef || !existsSync(target)) {
|
||||
writeFileSync(target, buffer);
|
||||
}
|
||||
return {
|
||||
bodyRef,
|
||||
previewText: createRequestLogBodyPreviewText(buffer)
|
||||
};
|
||||
}
|
||||
|
||||
function readRequestLogBodyChunkFromFile({
|
||||
bodyRef,
|
||||
contentType,
|
||||
encoding,
|
||||
filePath,
|
||||
length,
|
||||
offset,
|
||||
sizeBytes,
|
||||
truncated
|
||||
}: {
|
||||
bodyRef: string;
|
||||
contentType?: string;
|
||||
encoding: "base64" | "utf8";
|
||||
filePath: string;
|
||||
length: number;
|
||||
offset: number;
|
||||
sizeBytes: number;
|
||||
truncated: boolean;
|
||||
}): RequestLogBodyChunk {
|
||||
const descriptor = openSync(filePath, "r");
|
||||
try {
|
||||
const storedBytes = fstatSync(descriptor).size;
|
||||
const boundedOffset = Math.max(0, Math.min(offset, storedBytes));
|
||||
const readLength = Math.max(0, Math.min(
|
||||
encoding === "utf8" ? Math.max(length, 4) : length,
|
||||
storedBytes - boundedOffset
|
||||
));
|
||||
const buffer = Buffer.allocUnsafe(readLength);
|
||||
let bytesRead = 0;
|
||||
while (bytesRead < readLength) {
|
||||
const count = readSync(descriptor, buffer, bytesRead, readLength - bytesRead, boundedOffset + bytesRead);
|
||||
if (count === 0) break;
|
||||
bytesRead += count;
|
||||
}
|
||||
const data = bytesRead === buffer.byteLength ? buffer : buffer.subarray(0, bytesRead);
|
||||
const safeLength = encoding === "utf8" && boundedOffset + data.byteLength < storedBytes
|
||||
? validUtf8PrefixLength(data)
|
||||
: data.byteLength;
|
||||
const safeData = safeLength > 0 ? data.subarray(0, safeLength) : data;
|
||||
const nextOffset = boundedOffset + safeData.byteLength;
|
||||
return {
|
||||
bodyRef,
|
||||
contentType,
|
||||
encoding,
|
||||
eof: nextOffset >= storedBytes,
|
||||
length: safeData.byteLength,
|
||||
...(nextOffset < storedBytes ? { nextOffset } : {}),
|
||||
offset: boundedOffset,
|
||||
sizeBytes: Math.max(sizeBytes, storedBytes),
|
||||
text: encoding === "base64" ? safeData.toString("base64") : new StringDecoder("utf8").write(safeData),
|
||||
truncated
|
||||
};
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function validUtf8PrefixLength(buffer: Buffer): number {
|
||||
if (buffer.byteLength === 0) return 0;
|
||||
let leadIndex = buffer.byteLength - 1;
|
||||
while (leadIndex >= 0 && (buffer[leadIndex] & 0xc0) === 0x80) {
|
||||
leadIndex -= 1;
|
||||
}
|
||||
if (leadIndex < 0) return 0;
|
||||
const lead = buffer[leadIndex];
|
||||
if ((lead & 0x80) === 0) return buffer.byteLength;
|
||||
const continuationBytes = buffer.byteLength - leadIndex - 1;
|
||||
const expectedContinuationBytes = (lead & 0xe0) === 0xc0
|
||||
? 1
|
||||
: (lead & 0xf0) === 0xe0
|
||||
? 2
|
||||
: (lead & 0xf8) === 0xf0
|
||||
? 3
|
||||
: 0;
|
||||
if (expectedContinuationBytes === 0) return leadIndex;
|
||||
return continuationBytes >= expectedContinuationBytes ? buffer.byteLength : leadIndex;
|
||||
}
|
||||
|
||||
function readRequestLogBodyPreview(filePath: string): string {
|
||||
const descriptor = openSync(filePath, "r");
|
||||
try {
|
||||
const size = fstatSync(descriptor).size;
|
||||
if (size <= requestLogInlineBodyBytes) {
|
||||
const buffer = Buffer.allocUnsafe(size);
|
||||
readSync(descriptor, buffer, 0, size, 0);
|
||||
return new StringDecoder("utf8").write(buffer);
|
||||
}
|
||||
const headBytes = Math.floor(requestLogInlineBodyBytes * 0.65);
|
||||
const tailBytes = requestLogInlineBodyBytes - headBytes;
|
||||
const head = Buffer.allocUnsafe(headBytes);
|
||||
const tail = Buffer.allocUnsafe(tailBytes);
|
||||
const headRead = readSync(descriptor, head, 0, headBytes, 0);
|
||||
const tailRead = readSync(descriptor, tail, 0, tailBytes, Math.max(0, size - tailBytes));
|
||||
return createRequestLogPreviewFromParts(
|
||||
head.subarray(0, headRead),
|
||||
tail.subarray(0, tailRead),
|
||||
Math.max(0, size - headRead - tailRead)
|
||||
);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestLogBodyPreviewText(buffer: Buffer): string {
|
||||
if (buffer.byteLength <= requestLogInlineBodyBytes) {
|
||||
return new StringDecoder("utf8").write(buffer);
|
||||
}
|
||||
const headBytes = Math.floor(requestLogInlineBodyBytes * 0.65);
|
||||
const tailBytes = requestLogInlineBodyBytes - headBytes;
|
||||
return createRequestLogPreviewFromParts(
|
||||
buffer.subarray(0, headBytes),
|
||||
buffer.subarray(Math.max(0, buffer.byteLength - tailBytes)),
|
||||
Math.max(0, buffer.byteLength - headBytes - tailBytes)
|
||||
);
|
||||
}
|
||||
|
||||
function createRequestLogPreviewFromParts(head: Buffer, tail: Buffer, omittedBytes: number): string {
|
||||
return [
|
||||
new StringDecoder("utf8").write(head),
|
||||
"",
|
||||
`... ${omittedBytes} bytes omitted from preview ...`,
|
||||
"",
|
||||
new StringDecoder("utf8").write(tail)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function createRequestLogBodyRef(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function requestLogBodyPath(bodyDir: string, bodyRef: string, createDirectory = false): string | undefined {
|
||||
const normalized = normalizeBodyRef(bodyRef);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
const shard = normalized.slice(0, 2);
|
||||
const directory = join(bodyDir, shard);
|
||||
if (createDirectory) {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
}
|
||||
return join(directory, normalized);
|
||||
}
|
||||
|
||||
function normalizeBodyRef(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized || !/^[A-Za-z0-9._-]+$/.test(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deleteRequestLogBodyRefs(bodyDir: string, refs: string[]): void {
|
||||
for (const ref of refs) {
|
||||
const filePath = requestLogBodyPath(bodyDir, ref);
|
||||
if (!filePath) continue;
|
||||
rmSync(filePath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function isTextLikeContentType(contentType: string | undefined): boolean {
|
||||
@@ -4292,6 +4724,8 @@ function readRequestLogStoredOutcome(database: SqlDatabase, requestId: string):
|
||||
gateway_status_code,
|
||||
length(request_body_text) AS request_body_chars,
|
||||
length(response_body_text) AS response_body_chars,
|
||||
request_body_ref,
|
||||
response_body_ref,
|
||||
ok,
|
||||
status_code
|
||||
FROM request_logs
|
||||
@@ -4306,8 +4740,8 @@ function readRequestLogStoredOutcome(database: SqlDatabase, requestId: string):
|
||||
gatewayError: String(row?.gateway_error ?? ""),
|
||||
gatewayOk: normalizeCount(row?.gateway_ok) === 1,
|
||||
gatewayStatusCode: normalizeCount(row?.gateway_status_code),
|
||||
hasRequestBody: normalizeCount(row?.request_body_chars) > 0,
|
||||
hasResponseBody: normalizeCount(row?.response_body_chars) > 0,
|
||||
hasRequestBody: normalizeCount(row?.request_body_chars) > 0 || Boolean(normalizeFilterValue(String(row?.request_body_ref ?? ""))),
|
||||
hasResponseBody: normalizeCount(row?.response_body_chars) > 0 || Boolean(normalizeFilterValue(String(row?.response_body_ref ?? ""))),
|
||||
ok: normalizeCount(row?.ok) === 1,
|
||||
statusCode: normalizeCount(row?.status_code)
|
||||
};
|
||||
@@ -4359,12 +4793,14 @@ function withBoundedRawTraceBodyTexts(
|
||||
...(requestBodyText === undefined ? {} : {
|
||||
requestBodySizeBytes: Math.max(requestBytes, normalizeCount(input.requestBodySizeBytes)),
|
||||
requestBodyText,
|
||||
requestBodyTruncated: Boolean(input.requestBodyTruncated) || Buffer.byteLength(requestBodyText) < requestBytes
|
||||
requestBodyTruncated: Boolean(input.requestBodyTruncated) ||
|
||||
(!input.requestBodyRef && Buffer.byteLength(requestBodyText) < requestBytes)
|
||||
}),
|
||||
...(responseBodyText === undefined ? {} : {
|
||||
responseBodySizeBytes: Math.max(responseBytes, normalizeCount(input.responseBodySizeBytes)),
|
||||
responseBodyText,
|
||||
responseBodyTruncated: Boolean(input.responseBodyTruncated) || Buffer.byteLength(responseBodyText) < responseBytes
|
||||
responseBodyTruncated: Boolean(input.responseBodyTruncated) ||
|
||||
(!input.responseBodyRef && Buffer.byteLength(responseBodyText) < responseBytes)
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -4389,14 +4825,14 @@ function withoutRawTraceBodyTexts(input: RequestLogRawTraceUpdateInput): Request
|
||||
Buffer.byteLength(requestBodyText),
|
||||
normalizeCount(input.requestBodySizeBytes)
|
||||
),
|
||||
requestBodyTruncated: true
|
||||
requestBodyTruncated: Boolean(input.requestBodyTruncated) || !input.requestBodyRef
|
||||
}),
|
||||
...(responseBodyText === undefined ? {} : {
|
||||
responseBodySizeBytes: Math.max(
|
||||
Buffer.byteLength(responseBodyText),
|
||||
normalizeCount(input.responseBodySizeBytes)
|
||||
),
|
||||
responseBodyTruncated: true
|
||||
responseBodyTruncated: Boolean(input.responseBodyTruncated) || !input.responseBodyRef
|
||||
})
|
||||
};
|
||||
}
|
||||
@@ -4405,13 +4841,19 @@ function rawTraceHasBodyText(input: RequestLogRawTraceUpdateInput): boolean {
|
||||
return input.requestBodyText !== undefined || input.responseBodyText !== undefined;
|
||||
}
|
||||
|
||||
function prunePendingRawTraceUpdates(database: SqlDatabase, now: number): void {
|
||||
function prunePendingRawTraceUpdates(database: SqlDatabase, now: number, bodyDir?: string): void {
|
||||
const expiredRows = queryRows(
|
||||
database,
|
||||
"SELECT update_json FROM request_log_pending_updates WHERE received_at < ?",
|
||||
[now - pendingRawTraceTtlMs]
|
||||
);
|
||||
if (bodyDir) deleteRequestLogBodyRefs(bodyDir, bodyRefsFromPendingRawTraceRows(expiredRows));
|
||||
database.prepare("DELETE FROM request_log_pending_updates WHERE received_at < ?")
|
||||
.run(now - pendingRawTraceTtlMs);
|
||||
const rows = queryRows(
|
||||
database,
|
||||
`
|
||||
SELECT request_id, update_bytes
|
||||
SELECT request_id, update_bytes, update_json
|
||||
FROM request_log_pending_updates
|
||||
ORDER BY received_at DESC, request_id DESC
|
||||
`
|
||||
@@ -4423,6 +4865,7 @@ function prunePendingRawTraceUpdates(database: SqlDatabase, now: number): void {
|
||||
const bytes = normalizeCount(row.update_bytes);
|
||||
if (retainedEntries >= maxPendingRawTraceEntries ||
|
||||
retainedBytes + bytes > maxPendingRawTraceTotalBytes) {
|
||||
if (bodyDir) deleteRequestLogBodyRefs(bodyDir, bodyRefsFromPendingRawTraceRows([row]));
|
||||
remove.run(String(row.request_id ?? ""));
|
||||
continue;
|
||||
}
|
||||
@@ -4431,6 +4874,21 @@ function prunePendingRawTraceUpdates(database: SqlDatabase, now: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function bodyRefsFromPendingRawTraceRows(rows: Record<string, SqlValue>[]): string[] {
|
||||
return rows.flatMap((row) => {
|
||||
const parsed = parseJson(String(row.update_json ?? ""));
|
||||
if (!isRecord(parsed)) return [];
|
||||
return bodyRefsFromRawTraceInput(parsed as RequestLogRawTraceUpdateInput);
|
||||
});
|
||||
}
|
||||
|
||||
function bodyRefsFromRawTraceInput(input: RequestLogRawTraceUpdateInput): string[] {
|
||||
return [
|
||||
normalizeFilterValue(String(input.requestBodyRef ?? "")),
|
||||
normalizeFilterValue(String(input.responseBodyRef ?? ""))
|
||||
].filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function readRequestHeadersForRequestId(database: SqlDatabase, requestId: string): Record<string, string | string[]> {
|
||||
const row = queryRows(database, "SELECT request_headers FROM request_logs WHERE request_id = ? LIMIT 1", [requestId])[0];
|
||||
return row ? parseHeaderJson(row.request_headers) : {};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants";
|
||||
import {
|
||||
RequestLogStore,
|
||||
type RequestLogRawTraceFile,
|
||||
type RequestLogRawTraceFiles,
|
||||
type RequestLogRecordInput,
|
||||
type RequestLogStoreWriteCommand
|
||||
} from "@ccr/core/observability/request-log-store";
|
||||
@@ -13,6 +14,11 @@ import { resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-li
|
||||
import { compactBase64ImagePayloads } from "@ccr/core/observability/request-log-body";
|
||||
import { preloadUsagePriceCatalog } from "@ccr/core/models/pricing-service";
|
||||
|
||||
type RevivedRawTraceBody = RequestLogRawTraceFile & {
|
||||
text: string;
|
||||
textTruncated: boolean;
|
||||
};
|
||||
|
||||
type WorkerConfiguration = {
|
||||
dbFile: string;
|
||||
mode: "query" | "writer";
|
||||
@@ -80,6 +86,9 @@ async function handleMessage(message: WorkerMessage): Promise<void> {
|
||||
case "getDetail":
|
||||
result = await store.getDetail(args[0] as Parameters<RequestLogStore["getDetail"]>[0]);
|
||||
break;
|
||||
case "getBodyChunk":
|
||||
result = await store.getBodyChunk(args[0] as Parameters<RequestLogStore["getBodyChunk"]>[0]);
|
||||
break;
|
||||
case "getTracePayload":
|
||||
result = await store.getTracePayload(args[0] as Parameters<RequestLogStore["getTracePayload"]>[0]);
|
||||
break;
|
||||
@@ -140,9 +149,16 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
||||
if (command.kind === "raw-trace-update") {
|
||||
const input = { ...command.input };
|
||||
const maxBodyBytes = resolveRawTraceBodyLimit(command.rawTraceFiles?.maxBodyBytes);
|
||||
const rawTraceFiles: RequestLogRawTraceFiles | undefined = command.rawTraceFiles
|
||||
? {
|
||||
cleanupDirectory: command.rawTraceFiles.cleanupDirectory,
|
||||
maxBodyBytes: command.rawTraceFiles.maxBodyBytes
|
||||
}
|
||||
: undefined;
|
||||
if (command.rawTraceFiles?.requestBody) {
|
||||
const body = readRawTraceBody(command.rawTraceFiles.requestBody, maxBodyBytes);
|
||||
if (body) {
|
||||
if (rawTraceFiles) rawTraceFiles.requestBody = fileMetadataFromRawTraceBody(body);
|
||||
input.requestBodyContentType = body.contentType ?? input.requestBodyContentType;
|
||||
input.requestBodySizeBytes = body.sizeBytes;
|
||||
input.requestBodyTruncated = body.truncated;
|
||||
@@ -152,6 +168,7 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
||||
if (command.rawTraceFiles?.responseBody) {
|
||||
const body = readRawTraceBody(command.rawTraceFiles.responseBody, maxBodyBytes);
|
||||
if (body) {
|
||||
if (rawTraceFiles) rawTraceFiles.responseBody = fileMetadataFromRawTraceBody(body);
|
||||
input.responseBodyContentType = body.contentType ?? input.responseBodyContentType;
|
||||
input.responseBodySizeBytes = body.sizeBytes;
|
||||
input.responseBodyTruncated = body.truncated;
|
||||
@@ -161,6 +178,7 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
||||
return {
|
||||
input,
|
||||
kind: "raw-trace-update",
|
||||
...(rawTraceFiles ? { rawTraceFiles } : {}),
|
||||
sequence: command.sequence
|
||||
};
|
||||
}
|
||||
@@ -174,10 +192,19 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri
|
||||
};
|
||||
}
|
||||
|
||||
function fileMetadataFromRawTraceBody(body: RevivedRawTraceBody): RequestLogRawTraceFile {
|
||||
return {
|
||||
contentType: body.contentType,
|
||||
filePath: body.filePath,
|
||||
sizeBytes: body.sizeBytes,
|
||||
truncated: body.truncated
|
||||
};
|
||||
}
|
||||
|
||||
function readRawTraceBody(
|
||||
file: RequestLogRawTraceFile,
|
||||
maxBodyBytes: number
|
||||
): (RequestLogRawTraceFile & { text: string }) | undefined {
|
||||
): RevivedRawTraceBody | undefined {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const filePath = verifiedRawTracePath(file.filePath);
|
||||
@@ -193,13 +220,14 @@ function readRawTraceBody(
|
||||
}
|
||||
const captured = offset === buffer.byteLength ? buffer : buffer.subarray(0, offset);
|
||||
const compacted = compactBase64ImagePayloads(captured);
|
||||
const sourceSizeBytes = Math.max(file.sizeBytes, storedBytes);
|
||||
return {
|
||||
...file,
|
||||
filePath,
|
||||
sizeBytes: Math.max(file.sizeBytes, storedBytes),
|
||||
sizeBytes: sourceSizeBytes,
|
||||
text: new StringDecoder("utf8").write(compacted.buffer),
|
||||
truncated: Boolean(file.truncated) || compacted.compacted ||
|
||||
offset < Math.max(file.sizeBytes, storedBytes)
|
||||
textTruncated: offset < storedBytes,
|
||||
truncated: Boolean(file.truncated) || storedBytes < sourceSizeBytes
|
||||
};
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") return undefined;
|
||||
@@ -209,8 +237,8 @@ function readRawTraceBody(
|
||||
}
|
||||
}
|
||||
|
||||
function shouldApplyRawTraceBodyText(body: RequestLogRawTraceFile & { text: string }): boolean {
|
||||
if (!body.truncated) return true;
|
||||
function shouldApplyRawTraceBodyText(body: RevivedRawTraceBody): boolean {
|
||||
if (!body.truncated && !body.textTruncated) return true;
|
||||
const contentType = body.contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
|
||||
const firstContentIndex = skipJsonWhitespace(body.text, 0);
|
||||
const firstContent = body.text.charCodeAt(firstContentIndex);
|
||||
|
||||
@@ -40,7 +40,7 @@ import { getPluginMarketplace } from "@ccr/core/plugins/marketplace";
|
||||
import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
|
||||
import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogBodyChunk, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync";
|
||||
import { getUsageStats } from "@ccr/core/usage/store";
|
||||
import { gatewayService } from "@ccr/core/gateway/service";
|
||||
@@ -78,6 +78,7 @@ import type {
|
||||
ProviderCatalogModelsRequest,
|
||||
ProviderIconDetectionRequest,
|
||||
ProviderManifestFetchRequest,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogListFilter,
|
||||
RouteScriptTestRequest,
|
||||
@@ -334,6 +335,7 @@ const rpcHandlers: Record<string, RpcHandler> = {
|
||||
getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(),
|
||||
getProxyStatus: () => proxyService.getStatus(),
|
||||
getRequestLogDetail: (request) => getRequestLogDetail(request as RequestLogDetailRequest),
|
||||
getRequestLogBodyChunk: (request) => getRequestLogBodyChunk(request as RequestLogBodyChunkRequest),
|
||||
getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined),
|
||||
getUpdateStatus: () => unsupportedUpdateStatus,
|
||||
getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined),
|
||||
|
||||
@@ -323,13 +323,14 @@ test("RequestLogRuntime persists unmatched raw trace updates across worker resta
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore retains a bounded prefix for oversized unmatched raw trace bodies", async () => {
|
||||
test("RequestLogStore stores oversized unmatched raw trace bodies in sidecar storage", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-pending-entry-budget-test-"));
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
try {
|
||||
const body = "s".repeat(3 * 1024 * 1024);
|
||||
await store.writeBatch([{
|
||||
input: {
|
||||
requestBodyText: "s".repeat(3 * 1024 * 1024),
|
||||
requestBodyText: body,
|
||||
requestId: "oversized-pending-raw"
|
||||
},
|
||||
kind: "raw-trace-update",
|
||||
@@ -344,25 +345,29 @@ test("RequestLogStore retains a bounded prefix for oversized unmatched raw trace
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.match(detail.requestBody.text, /^s+$/);
|
||||
assert.equal(Buffer.byteLength(detail.requestBody.text), 512 * 1024);
|
||||
assert.equal(detail.requestBody.sizeBytes, 3 * 1024 * 1024);
|
||||
assert.equal(detail.requestBody.truncated, true);
|
||||
assert.ok(detail.requestBody.bodyRef);
|
||||
assert.equal(detail.requestBody.preview, true);
|
||||
assert.match(detail.requestBody.text, /bytes omitted from preview/);
|
||||
assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.equal(await readStoredBodyText(store, page.items[0].id, "request"), body);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore bounds unmatched request and response bodies independently", async () => {
|
||||
test("RequestLogStore stores unmatched request and response bodies independently", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-pending-body-budget-test-"));
|
||||
const store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
try {
|
||||
const requestBody = "q".repeat(3 * 1024 * 1024);
|
||||
const responseBody = "p".repeat(3 * 1024 * 1024);
|
||||
await store.writeBatch([{
|
||||
input: {
|
||||
requestBodyText: "q".repeat(3 * 1024 * 1024),
|
||||
requestBodyText: requestBody,
|
||||
requestId: "oversized-pending-pair",
|
||||
responseBodyText: "p".repeat(3 * 1024 * 1024)
|
||||
responseBodyText: responseBody
|
||||
},
|
||||
kind: "raw-trace-update",
|
||||
sequence: 1
|
||||
@@ -376,12 +381,14 @@ test("RequestLogStore bounds unmatched request and response bodies independently
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.equal(Buffer.byteLength(detail.requestBody.text), 512 * 1024);
|
||||
assert.equal(Buffer.byteLength(detail.responseBody.text), 512 * 1024);
|
||||
assert.equal(detail.requestBody.sizeBytes, 3 * 1024 * 1024);
|
||||
assert.equal(detail.responseBody.sizeBytes, 3 * 1024 * 1024);
|
||||
assert.equal(detail.requestBody.truncated, true);
|
||||
assert.equal(detail.responseBody.truncated, true);
|
||||
assert.ok(detail.requestBody.bodyRef);
|
||||
assert.ok(detail.responseBody.bodyRef);
|
||||
assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(requestBody));
|
||||
assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(responseBody));
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.equal(detail.responseBody.truncated, false);
|
||||
assert.equal(await readStoredBodyText(store, page.items[0].id, "request"), requestBody);
|
||||
assert.equal(await readStoredBodyText(store, page.items[0].id, "response"), responseBody);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
@@ -423,7 +430,9 @@ test("RequestLogStore enforces a total byte budget for unmatched raw trace rows"
|
||||
const oldest = await store.getDetail({ id: oldestEntry.id });
|
||||
const newest = await store.getDetail({ id: newestEntry.id });
|
||||
assert.match(oldest.requestBody.text, /worker-model/);
|
||||
assert.match(newest.requestBody.text, /^(?:17)+$/);
|
||||
assert.ok(newest.requestBody.bodyRef);
|
||||
assert.match(newest.requestBody.text, /bytes omitted from preview/);
|
||||
assert.match(await readStoredBodyText(store, newestEntry.id, "request"), /^(?:17)+$/);
|
||||
} finally {
|
||||
await store.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
@@ -460,7 +469,8 @@ test("RequestLogRuntime preserves original response sizes when normal body captu
|
||||
assert.equal(metadataEntry.resolvedModel, "resolved-model");
|
||||
assert.equal(metadataEntry.responseModel, "response-model");
|
||||
assert.equal(truncated.responseBody.sizeBytes, Buffer.byteLength(fullResponse));
|
||||
assert.equal(Buffer.byteLength(truncated.responseBody.text), 512 * 1024);
|
||||
assert.ok(truncated.responseBody.bodyRef);
|
||||
assert.equal(Buffer.byteLength(await readStoredBodyText(runtime, truncatedEntry.id, "response")), 512 * 1024);
|
||||
assert.equal(truncated.responseBody.truncated, true);
|
||||
assert.equal(metadata.responseBody.sizeBytes, Buffer.byteLength("exists"));
|
||||
assert.equal(metadata.responseBody.text, "");
|
||||
@@ -586,7 +596,7 @@ test("RequestLogRuntime keeps the record body-removal policy for later file-back
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogRuntime retains inline raw trace bodies below the default safety limit", async () => {
|
||||
test("RequestLogRuntime stores large inline raw trace bodies in sidecar storage", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-raw-body-test-"));
|
||||
const runtime = createRuntime(dir);
|
||||
try {
|
||||
@@ -604,7 +614,9 @@ test("RequestLogRuntime retains inline raw trace bodies below the default safety
|
||||
const page = await runtime.list({ pageSize: 25 });
|
||||
const detail = await runtime.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.responseBody.text.length, body.length);
|
||||
assert.ok(detail.responseBody.bodyRef);
|
||||
assert.match(detail.responseBody.text, /bytes omitted from preview/);
|
||||
assert.equal(await readStoredBodyText(runtime, page.items[0].id, "response"), body);
|
||||
assert.equal(detail.responseBody.truncated, false);
|
||||
} finally {
|
||||
await runtime.close({ timeoutMs: 5_000 });
|
||||
@@ -612,7 +624,7 @@ test("RequestLogRuntime retains inline raw trace bodies below the default safety
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogRuntime bounds file-backed raw bodies and cleans bundles only after ACK", async () => {
|
||||
test("RequestLogRuntime stores file-backed raw bodies and cleans bundles only after ACK", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-raw-file-test-"));
|
||||
const spoolDir = path.join(dir, "raw-trace-spool");
|
||||
const bundleDir = path.join(spoolDir, "bundle");
|
||||
@@ -643,8 +655,10 @@ test("RequestLogRuntime bounds file-backed raw bodies and cleans bundles only af
|
||||
const page = await runtime.list({ pageSize: 25 });
|
||||
const detail = await runtime.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.responseBody.text.length, 256 * 1024);
|
||||
assert.equal(detail.responseBody.truncated, true);
|
||||
assert.ok(detail.responseBody.bodyRef);
|
||||
assert.match(detail.responseBody.text, /bytes omitted from preview/);
|
||||
assert.equal(await readStoredBodyText(runtime, page.items[0].id, "response"), body);
|
||||
assert.equal(detail.responseBody.truncated, false);
|
||||
} finally {
|
||||
await runtime.close({ timeoutMs: 5_000 });
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
@@ -685,8 +699,11 @@ test("RequestLogRuntime compacts Base64 images while reading file-backed raw tra
|
||||
const detail = await runtime.getDetail({ id: page.items[0].id });
|
||||
const captured = JSON.parse(detail.requestBody.text);
|
||||
assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(body));
|
||||
assert.equal(detail.requestBody.truncated, true);
|
||||
assert.ok(detail.requestBody.bodyRef);
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.match(captured.image_url.url, /^data:image\/jpeg;base64,\[base64 image omitted from log;/);
|
||||
const fullBody = JSON.parse(await readStoredBodyText(runtime, page.items[0].id, "request"));
|
||||
assert.equal(fullBody.image_url.url, `data:image/jpeg;base64,${"A".repeat(512 * 1024)}`);
|
||||
assert.equal(existsSync(bundleDir), false);
|
||||
} finally {
|
||||
await runtime.close({ timeoutMs: 5_000 });
|
||||
@@ -1621,6 +1638,20 @@ function createRuntime(dir, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readStoredBodyText(source, id, side) {
|
||||
let offset = 0;
|
||||
const chunks = [];
|
||||
while (true) {
|
||||
const chunk = await source.getBodyChunk({ id, length: 512 * 1024, offset, side });
|
||||
assert.ok(chunk, `expected ${side} body chunk at offset ${offset}`);
|
||||
chunks.push(chunk.text);
|
||||
if (chunk.eof) break;
|
||||
assert.ok(chunk.nextOffset > offset);
|
||||
offset = chunk.nextOffset;
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function createRecord(requestId) {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
|
||||
@@ -316,6 +316,104 @@ test("RequestLogStore keeps list rows lightweight and detail rows complete", asy
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore keeps large request bodies in sidecar storage and reads them by chunk", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-sidecar-test-"));
|
||||
let store;
|
||||
try {
|
||||
store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const body = JSON.stringify({
|
||||
messages: [{ content: "hello", role: "user" }],
|
||||
model: "request-model",
|
||||
padding: "中🙂".repeat(40 * 1024),
|
||||
tail: "request-tail"
|
||||
});
|
||||
const response = JSON.stringify({
|
||||
model: "response-model",
|
||||
padding: "y".repeat(220 * 1024),
|
||||
tail: "response-tail",
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 4,
|
||||
total_tokens: 7
|
||||
}
|
||||
});
|
||||
|
||||
await store.record({
|
||||
completedAt: new Date().toISOString(),
|
||||
durationMs: 42,
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
providerName: "test-provider",
|
||||
requestBody: Buffer.from(body, "utf8"),
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
requestId: "request-log-sidecar-test",
|
||||
responseBodyText: response,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
startedAt: new Date().toISOString(),
|
||||
statusCode: 200,
|
||||
url: "http://127.0.0.1:3456/v1/messages"
|
||||
});
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
assert.equal(page.items.length, 1);
|
||||
assert.equal(page.items[0].requestBody.text, "");
|
||||
assert.equal(page.items[0].requestBody.preview, true);
|
||||
assert.ok(page.items[0].requestBody.bodyRef);
|
||||
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.ok(detail);
|
||||
assert.equal(detail.requestBody.preview, true);
|
||||
assert.equal(detail.requestBody.truncated, false);
|
||||
assert.match(detail.requestBody.text, /bytes omitted from preview/);
|
||||
assert.match(detail.requestBody.text, /request-tail/);
|
||||
assert.equal(detail.responseBody?.preview, true);
|
||||
assert.equal(detail.responseBody?.truncated, false);
|
||||
assert.match(detail.responseBody?.text ?? "", /response-tail/);
|
||||
|
||||
const requestChunk = await store.getBodyChunk({
|
||||
id: detail.id,
|
||||
length: 512 * 1024,
|
||||
offset: 0,
|
||||
side: "request"
|
||||
});
|
||||
assert.ok(requestChunk);
|
||||
assert.equal(requestChunk.eof, true);
|
||||
assert.equal(requestChunk.truncated, false);
|
||||
assert.equal(requestChunk.text, body);
|
||||
|
||||
let unicodeOffset = 0;
|
||||
const unicodeChunks = [];
|
||||
while (true) {
|
||||
const unicodeChunk = await store.getBodyChunk({
|
||||
id: detail.id,
|
||||
length: 4097,
|
||||
offset: unicodeOffset,
|
||||
side: "request"
|
||||
});
|
||||
assert.ok(unicodeChunk);
|
||||
unicodeChunks.push(unicodeChunk.text);
|
||||
if (unicodeChunk.eof) break;
|
||||
assert.ok(unicodeChunk.nextOffset > unicodeOffset);
|
||||
unicodeOffset = unicodeChunk.nextOffset;
|
||||
}
|
||||
assert.equal(unicodeChunks.join(""), body);
|
||||
|
||||
const responseChunk = await store.getBodyChunk({
|
||||
id: detail.id,
|
||||
length: 512 * 1024,
|
||||
offset: 0,
|
||||
side: "response"
|
||||
});
|
||||
assert.ok(responseChunk);
|
||||
assert.equal(responseChunk.eof, true);
|
||||
assert.equal(responseChunk.truncated, false);
|
||||
assert.equal(responseChunk.text, response);
|
||||
} finally {
|
||||
await store?.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore stores decoded Claude App route models for observability", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-claude-app-model-test-"));
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts";
|
||||
import { rawTraceSyncHeader } from "@ccr/core/gateway/internal/shared.ts";
|
||||
import { rawTraceHardMaxBodyBytes } from "@ccr/core/observability/request-log-limits.ts";
|
||||
import { rawTraceHardMaxBodyBytes, rawTraceMaxPartBytes } from "@ccr/core/observability/request-log-limits.ts";
|
||||
import {
|
||||
applyRawTraceRequestLogPolicy,
|
||||
buildRawTraceConfig,
|
||||
@@ -117,15 +117,16 @@ test("raw trace defers body persistence when the upstream status is unknown", ()
|
||||
assert.equal(policy.update.requestBodyText, "private request body");
|
||||
});
|
||||
|
||||
test("raw trace source defaults to the 50 MB hard body ceiling", () => {
|
||||
test("raw trace source keeps raw body parts unbounded for sidecar storage", () => {
|
||||
const config = createConfig();
|
||||
const previous = process.env.CCR_RAW_TRACE_ENABLED;
|
||||
process.env.CCR_RAW_TRACE_ENABLED = "1";
|
||||
try {
|
||||
const rawTrace = buildRawTraceConfig(config, "sync-token");
|
||||
assert.equal(rawTrace.maxPartBytes, rawTraceHardMaxBodyBytes);
|
||||
assert.equal(rawTrace.maxPartBytes, rawTraceMaxPartBytes);
|
||||
assert.ok(rawTrace.maxPartBytes > rawTraceHardMaxBodyBytes);
|
||||
config.observability.requestLogMaxBodyBytes = Number.MAX_SAFE_INTEGER;
|
||||
assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceHardMaxBodyBytes);
|
||||
assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceMaxPartBytes);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CCR_RAW_TRACE_ENABLED;
|
||||
else process.env.CCR_RAW_TRACE_ENABLED = previous;
|
||||
|
||||
@@ -47,7 +47,7 @@ import { getPluginMarketplace } from "@ccr/core/plugins/marketplace";
|
||||
import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates";
|
||||
import { proxyService } from "@ccr/core/proxy/service";
|
||||
import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery";
|
||||
import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import { getAgentAnalysis, getAgentTracePayload, getRequestLogBodyChunk, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store";
|
||||
import trayController from "./tray-controller";
|
||||
import { appUpdateService } from "./update-service";
|
||||
import { getUsageStats } from "@ccr/core/usage/store";
|
||||
@@ -124,6 +124,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNe
|
||||
ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => getPluginMarketplace());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetRequestLogDetail, (_event, request) => getRequestLogDetail(request));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetRequestLogBodyChunk, (_event, request) => getRequestLogBodyChunk(request));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter));
|
||||
ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus());
|
||||
ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter));
|
||||
|
||||
@@ -71,6 +71,8 @@ import type {
|
||||
ProxyNetworkSnapshot,
|
||||
ProxyStatus,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogBodyChunk,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogEntry,
|
||||
RequestLogListFilter,
|
||||
RequestLogPage,
|
||||
@@ -129,6 +131,7 @@ contextBridge.exposeInMainWorld("ccr", {
|
||||
getProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise<ProxyNetworkSnapshot>,
|
||||
getProxyStatus: () => invoke(IPC_CHANNELS.appGetProxyStatus) as Promise<ProxyStatus>,
|
||||
getRequestLogDetail: (request: RequestLogDetailRequest) => invoke(IPC_CHANNELS.appGetRequestLogDetail, request) as Promise<RequestLogEntry | undefined>,
|
||||
getRequestLogBodyChunk: (request: RequestLogBodyChunkRequest) => invoke(IPC_CHANNELS.appGetRequestLogBodyChunk, request) as Promise<RequestLogBodyChunk | undefined>,
|
||||
getRequestLogs: (filter?: RequestLogListFilter) => invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise<RequestLogPage>,
|
||||
getUpdateStatus: () => invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise<AppUpdateStatus>,
|
||||
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise<UsageStatsSnapshot>,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
logResolvedRouteModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel,
|
||||
networkExchangeMatchesQuery, networkHeaderRows, networkLifecycleLabel, networkQueryRows, networkRowId, networkSummaryRows,
|
||||
Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus,
|
||||
ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogEntry, RequestLogListFilter,
|
||||
ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogBodyChunk, RequestLogEntry, RequestLogListFilter,
|
||||
RequestLogPage, requestLogPageSizeOptions, RequestLogStatusFilter, requestLogStatusOptions, Search, Select,
|
||||
translateOptions, Trash2, useAppNumberLocale, useAppText, useCallback, useEffect, useMemo, useRef,
|
||||
useState
|
||||
@@ -24,6 +24,7 @@ type NetworkResponseTab = "body" | "header" | "raw";
|
||||
const logJsonAutoExpandEntryLimit = 60;
|
||||
const logJsonContainerPreviewLimit = 80;
|
||||
const logJsonAutoExpandTextLimit = 160 * 1024;
|
||||
const logBodyAutoLoadJsonBytes = 2 * 1024 * 1024;
|
||||
const logBodyWorkerFilterDebounceMs = 180;
|
||||
type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration";
|
||||
type LogTableColumn = {
|
||||
@@ -992,12 +993,14 @@ function LogExpandedDetails({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="network-detail-panes grid h-[440px] min-h-0 grid-cols-1 lg:grid-cols-2">
|
||||
<LogJsonPanel body={entry.requestBody} headerEmptyLabel="No request headers" headers={entry.requestHeaders} title={t("请求")} />
|
||||
<LogJsonPanel body={entry.requestBody} headerEmptyLabel="No request headers" headers={entry.requestHeaders} requestLogId={entry.id} side="request" title={t("请求")} />
|
||||
<LogJsonPanel
|
||||
body={entry.responseBody}
|
||||
className="border-t lg:border-l lg:border-t-0"
|
||||
headerEmptyLabel="No response headers"
|
||||
headers={entry.responseHeaders}
|
||||
requestLogId={entry.id}
|
||||
side="response"
|
||||
subtitle={`HTTP ${entry.statusCode || "-"}`}
|
||||
title={t("响应")}
|
||||
/>
|
||||
@@ -1510,6 +1513,14 @@ type LogBodyPanelView = FormattedLogBody & {
|
||||
visible: string;
|
||||
};
|
||||
|
||||
type LogBodyChunkPanelView = {
|
||||
bodyKey: string;
|
||||
chunk?: RequestLogBodyChunk;
|
||||
error: string;
|
||||
loading: boolean;
|
||||
previousOffsets: number[];
|
||||
};
|
||||
|
||||
function useLogBodyWorkerView(
|
||||
body: RequestLogBody | undefined,
|
||||
bodyKey: string,
|
||||
@@ -1743,6 +1754,8 @@ function LogJsonPanel({
|
||||
className,
|
||||
headerEmptyLabel = "No values",
|
||||
headers,
|
||||
requestLogId,
|
||||
side,
|
||||
subtitle,
|
||||
title
|
||||
}: {
|
||||
@@ -1750,6 +1763,8 @@ function LogJsonPanel({
|
||||
className?: string;
|
||||
headerEmptyLabel?: string;
|
||||
headers?: Record<string, string | string[]>;
|
||||
requestLogId: number;
|
||||
side: "request" | "response";
|
||||
subtitle?: string;
|
||||
title: string;
|
||||
}) {
|
||||
@@ -1758,23 +1773,55 @@ function LogJsonPanel({
|
||||
const [preferTextBody, setPreferTextBody] = useState(false);
|
||||
const [bodyMode, setBodyMode] = useState<LogBodyFormatMode>("preview");
|
||||
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
||||
const [loadedBody, setLoadedBody] = useState<RequestLogBody>();
|
||||
const [chunkView, setChunkView] = useState<LogBodyChunkPanelView>();
|
||||
const [fullBodyLoading, setFullBodyLoading] = useState(false);
|
||||
const [fullBodyError, setFullBodyError] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const bodyKey = logBodyCacheKey(body);
|
||||
const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query);
|
||||
const fullBodyLoadIdRef = useRef(0);
|
||||
const sourceBodyKey = logBodyCacheKey(body);
|
||||
const effectiveBody = loadedBody && loadedBody.bodyRef && loadedBody.bodyRef === body?.bodyRef
|
||||
? loadedBody
|
||||
: body;
|
||||
const bodyKey = logBodyCacheKey(effectiveBody);
|
||||
const bodyView = useLogBodyWorkerView(effectiveBody, bodyKey, bodyMode, query);
|
||||
const chunkViewActive = chunkView?.bodyKey === sourceBodyKey;
|
||||
const toolbarBodyView = fullBodyLoading || fullBodyError
|
||||
? {
|
||||
...bodyView,
|
||||
error: fullBodyError || bodyView.error,
|
||||
loading: fullBodyLoading || bodyView.loading,
|
||||
mode: fullBodyLoading ? "full" as const : bodyView.mode
|
||||
}
|
||||
: bodyView;
|
||||
const displayedToolbarBodyView = chunkViewActive
|
||||
? {
|
||||
...toolbarBodyView,
|
||||
error: chunkView.error || toolbarBodyView.error,
|
||||
loading: chunkView.loading,
|
||||
mode: "full" as const,
|
||||
preview: false
|
||||
}
|
||||
: toolbarBodyView;
|
||||
const formatted = bodyView.text;
|
||||
const visible = bodyView.visible;
|
||||
const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]);
|
||||
const [expandedJsonPaths, setExpandedJsonPaths] = useState<Set<string>>(() => createInitialVisibleJsonPaths(bodyView));
|
||||
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview;
|
||||
const [expandedJsonPaths, setExpandedJsonPaths] = useState<Set<string>>(() => createInitialVisibleJsonPaths(bodyView, side));
|
||||
const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody;
|
||||
|
||||
useEffect(() => {
|
||||
fullBodyLoadIdRef.current += 1;
|
||||
setLoadedBody(undefined);
|
||||
setChunkView(undefined);
|
||||
setFullBodyLoading(false);
|
||||
setFullBodyError("");
|
||||
setPreferTextBody(false);
|
||||
setBodyMode("preview");
|
||||
}, [bodyKey]);
|
||||
}, [sourceBodyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView));
|
||||
}, [bodyView.bodyKey, bodyView.json, bodyView.text]);
|
||||
setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView, side));
|
||||
}, [bodyView.bodyKey, bodyView.json, bodyView.text, side]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullscreenOpen) {
|
||||
@@ -1801,6 +1848,134 @@ function LogJsonPanel({
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBodyChunk(offset: number) {
|
||||
if (!effectiveBody?.bodyRef || !window.ccr?.getRequestLogBodyChunk) {
|
||||
setBodyMode("full");
|
||||
return;
|
||||
}
|
||||
const loadId = fullBodyLoadIdRef.current + 1;
|
||||
fullBodyLoadIdRef.current = loadId;
|
||||
setBodyMode("full");
|
||||
setChunkView((current) => ({
|
||||
bodyKey: sourceBodyKey,
|
||||
chunk: current?.bodyKey === sourceBodyKey ? current.chunk : undefined,
|
||||
error: "",
|
||||
loading: true,
|
||||
previousOffsets: nextChunkPreviousOffsets(current, sourceBodyKey, offset)
|
||||
}));
|
||||
try {
|
||||
const chunk = await window.ccr.getRequestLogBodyChunk({
|
||||
id: requestLogId,
|
||||
length: 1024 * 1024,
|
||||
offset,
|
||||
side
|
||||
});
|
||||
if (fullBodyLoadIdRef.current !== loadId) {
|
||||
return;
|
||||
}
|
||||
if (!chunk) {
|
||||
throw new Error(t("Request log body is not available."));
|
||||
}
|
||||
setChunkView({
|
||||
bodyKey: sourceBodyKey,
|
||||
chunk,
|
||||
error: "",
|
||||
loading: false,
|
||||
previousOffsets: nextChunkPreviousOffsets(chunkView, sourceBodyKey, offset)
|
||||
});
|
||||
} catch (error) {
|
||||
if (fullBodyLoadIdRef.current === loadId) {
|
||||
setChunkView((current) => ({
|
||||
bodyKey: sourceBodyKey,
|
||||
chunk: current?.bodyKey === sourceBodyKey ? current.chunk : undefined,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
loading: false,
|
||||
previousOffsets: current?.bodyKey === sourceBodyKey ? current.previousOffsets : []
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInlineFullBody() {
|
||||
if (!effectiveBody?.bodyRef || !window.ccr?.getRequestLogBodyChunk) {
|
||||
return;
|
||||
}
|
||||
const loadId = fullBodyLoadIdRef.current + 1;
|
||||
fullBodyLoadIdRef.current = loadId;
|
||||
setFullBodyLoading(true);
|
||||
setFullBodyError("");
|
||||
try {
|
||||
const chunks: string[] = [];
|
||||
let offset = 0;
|
||||
let lastChunk: RequestLogBodyChunk | undefined;
|
||||
while (true) {
|
||||
const chunk = await window.ccr.getRequestLogBodyChunk({
|
||||
id: requestLogId,
|
||||
length: 1024 * 1024,
|
||||
offset,
|
||||
side
|
||||
});
|
||||
if (fullBodyLoadIdRef.current !== loadId) {
|
||||
return;
|
||||
}
|
||||
if (!chunk) {
|
||||
throw new Error(t("Request log body is not available."));
|
||||
}
|
||||
chunks.push(chunk.text);
|
||||
lastChunk = chunk;
|
||||
if (chunk.eof) {
|
||||
break;
|
||||
}
|
||||
offset = chunk.nextOffset ?? offset + chunk.length;
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
setLoadedBody({
|
||||
...effectiveBody,
|
||||
...(lastChunk?.bodyRef ? { bodyRef: lastChunk.bodyRef } : {}),
|
||||
contentType: lastChunk?.contentType ?? effectiveBody.contentType,
|
||||
encoding: lastChunk?.encoding ?? effectiveBody.encoding,
|
||||
preview: false,
|
||||
sizeBytes: lastChunk?.sizeBytes ?? effectiveBody.sizeBytes,
|
||||
text: chunks.join(""),
|
||||
truncated: Boolean(lastChunk?.truncated)
|
||||
});
|
||||
setBodyMode("full");
|
||||
} catch (error) {
|
||||
if (fullBodyLoadIdRef.current === loadId) {
|
||||
setFullBodyError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
if (fullBodyLoadIdRef.current === loadId) setFullBodyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!effectiveBody?.bodyRef ||
|
||||
!effectiveBody.preview ||
|
||||
loadedBody ||
|
||||
fullBodyLoading ||
|
||||
fullBodyError ||
|
||||
bodyMode !== "preview"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (effectiveBody.sizeBytes <= logBodyAutoLoadJsonBytes && isJsonLikeLogBody(effectiveBody)) {
|
||||
void loadInlineFullBody();
|
||||
return;
|
||||
}
|
||||
if (!chunkViewActive && !chunkView?.loading) {
|
||||
void loadBodyChunk(0);
|
||||
}
|
||||
}, [bodyMode, chunkView, chunkViewActive, effectiveBody, fullBodyError, fullBodyLoading, loadedBody]);
|
||||
|
||||
const displayedCopyText = chunkViewActive
|
||||
? chunkView.chunk?.text ?? ""
|
||||
: formatted;
|
||||
const displayedVisible = chunkViewActive
|
||||
? filterStaticLogBodyText(chunkView.chunk?.text ?? "", query)
|
||||
: visible;
|
||||
|
||||
return (
|
||||
<div className={cn("network-pane-split flex min-h-0 min-w-0 flex-col", className)}>
|
||||
<div className="network-pane-header flex h-10 min-w-0 shrink-0 items-center gap-3 border-b px-3">
|
||||
@@ -1827,9 +2002,8 @@ function LogJsonPanel({
|
||||
{selectedTab === "body" ? (
|
||||
<>
|
||||
<LogJsonBodyToolbar
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
onLoadFullBody={() => setBodyMode("full")}
|
||||
body={effectiveBody}
|
||||
bodyView={displayedToolbarBodyView}
|
||||
onQueryChange={setQuery}
|
||||
onToggleTextBody={() => setPreferTextBody((current) => !current)}
|
||||
preferTextBody={preferTextBody}
|
||||
@@ -1838,27 +2012,32 @@ function LogJsonPanel({
|
||||
/>
|
||||
<LogBodyViewer
|
||||
copyLabel={`${t("Copy")} ${title} ${t("body")}`}
|
||||
copyText={formatted}
|
||||
copyText={displayedCopyText}
|
||||
fullscreenLabel={t("Open fullscreen JSON viewer")}
|
||||
onFullscreen={() => setFullscreenOpen(true)}
|
||||
>
|
||||
<LogJsonBodyContent
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onToggleJsonPath={toggleJsonPath}
|
||||
showJsonTree={showJsonTree}
|
||||
value={bodyView.json}
|
||||
visible={visible}
|
||||
/>
|
||||
{chunkViewActive ? (
|
||||
<LogBodyChunkContent chunkView={chunkView} onLoadChunk={loadBodyChunk} query={query} visible={displayedVisible} />
|
||||
) : (
|
||||
<LogJsonBodyContent
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onToggleJsonPath={toggleJsonPath}
|
||||
showJsonTree={showJsonTree}
|
||||
value={bodyView.json}
|
||||
visible={visible}
|
||||
/>
|
||||
)}
|
||||
</LogBodyViewer>
|
||||
{fullscreenOpen ? (
|
||||
<LogJsonFullscreenViewer
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
body={effectiveBody}
|
||||
bodyView={displayedToolbarBodyView}
|
||||
chunkView={chunkViewActive ? chunkView : undefined}
|
||||
copyLabel={`${t("Copy")} ${title} ${t("body")}`}
|
||||
copyText={formatted}
|
||||
copyText={displayedCopyText}
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onClose={() => setFullscreenOpen(false)}
|
||||
onLoadFullBody={() => setBodyMode("full")}
|
||||
onLoadChunk={loadBodyChunk}
|
||||
onQueryChange={setQuery}
|
||||
onToggleJsonPath={toggleJsonPath}
|
||||
onToggleTextBody={() => setPreferTextBody((current) => !current)}
|
||||
@@ -1867,7 +2046,7 @@ function LogJsonPanel({
|
||||
showJsonTree={showJsonTree}
|
||||
subtitle={subtitle}
|
||||
title={title}
|
||||
visible={visible}
|
||||
visible={displayedVisible}
|
||||
value={bodyView.json}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1885,7 +2064,6 @@ function LogJsonPanel({
|
||||
function LogJsonBodyToolbar({
|
||||
body,
|
||||
bodyView,
|
||||
onLoadFullBody,
|
||||
onQueryChange,
|
||||
onToggleTextBody,
|
||||
preferTextBody,
|
||||
@@ -1894,7 +2072,6 @@ function LogJsonBodyToolbar({
|
||||
}: {
|
||||
body?: RequestLogBody;
|
||||
bodyView: LogBodyPanelView;
|
||||
onLoadFullBody: () => void;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleTextBody: () => void;
|
||||
preferTextBody: boolean;
|
||||
@@ -1902,12 +2079,8 @@ function LogJsonBodyToolbar({
|
||||
title: string;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === "";
|
||||
const canToggleJsonText = bodyView.json !== undefined && query.trim() === "";
|
||||
const showToggleButton = canLoadFullBody || canToggleJsonText;
|
||||
const toggleLabel = canLoadFullBody
|
||||
? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content")
|
||||
: preferTextBody ? "JSON" : t("Show full content");
|
||||
const toggleLabel = preferTextBody ? "JSON" : t("Text");
|
||||
|
||||
return (
|
||||
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5">
|
||||
@@ -1921,11 +2094,10 @@ function LogJsonBodyToolbar({
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{showToggleButton ? (
|
||||
{canToggleJsonText ? (
|
||||
<button
|
||||
className="network-tab shrink-0 border-0 bg-transparent p-0 text-[11px] font-semibold outline-none"
|
||||
disabled={bodyView.loading && bodyView.mode === "full"}
|
||||
onClick={canLoadFullBody ? onLoadFullBody : onToggleTextBody}
|
||||
onClick={onToggleTextBody}
|
||||
type="button"
|
||||
>
|
||||
{toggleLabel}
|
||||
@@ -1933,7 +2105,6 @@ function LogJsonBodyToolbar({
|
||||
) : null}
|
||||
{bodyView.loading ? <span className="network-muted shrink-0 text-[11px] font-semibold">{t("Loading full payload...")}</span> : null}
|
||||
{bodyView.error ? <span className="network-error-box shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold">{bodyView.error}</span> : null}
|
||||
{bodyView.preview ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("preview")}</span> : null}
|
||||
{bodyView.sourceSizeBytes > 0 ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{formatBytes(bodyView.sourceSizeBytes)}</span> : null}
|
||||
{body?.contentType ? <span className="network-muted hidden shrink-0 text-[11px] font-semibold sm:inline">{body.contentType}</span> : null}
|
||||
{body?.truncated ? <span className="network-service-paused rounded-full px-2 py-0.5 text-[11px] font-semibold">{t("truncated")}</span> : null}
|
||||
@@ -1961,14 +2132,72 @@ function LogJsonBodyContent({
|
||||
);
|
||||
}
|
||||
|
||||
function LogBodyChunkContent({
|
||||
chunkView,
|
||||
onLoadChunk,
|
||||
query,
|
||||
visible
|
||||
}: {
|
||||
chunkView: LogBodyChunkPanelView;
|
||||
onLoadChunk: (offset: number) => void | Promise<void>;
|
||||
query: string;
|
||||
visible: string;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const chunk = chunkView.chunk;
|
||||
const previousOffset = chunkView.previousOffsets.at(-1) ?? Math.max(0, (chunk?.offset ?? 0) - 1024 * 1024);
|
||||
const hasPrevious = Boolean(chunk && (chunkView.previousOffsets.length > 0 || chunk.offset > 0));
|
||||
const hasNext = Boolean(chunk && !chunk.eof && chunk.nextOffset !== undefined);
|
||||
const rangeLabel = chunk
|
||||
? `${formatBytes(chunk.offset)}-${formatBytes(chunk.offset + chunk.length)} / ${formatBytes(chunk.sizeBytes)}`
|
||||
: "";
|
||||
const content = chunkView.loading && !chunk
|
||||
? t("Loading full payload...")
|
||||
: chunkView.error && !chunk
|
||||
? chunkView.error
|
||||
: visible || (query.trim() ? "No matching lines" : "");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="network-body-meta flex min-h-9 shrink-0 items-center gap-2 border-b px-3 py-1.5 pr-24">
|
||||
<button
|
||||
aria-label={t("Previous")}
|
||||
className="network-control-button flex h-7 w-7 shrink-0 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:opacity-40"
|
||||
disabled={!hasPrevious || chunkView.loading}
|
||||
onClick={() => void onLoadChunk(previousOffset)}
|
||||
title={t("Previous")}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={t("Next")}
|
||||
className="network-control-button flex h-7 w-7 shrink-0 items-center justify-center rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:opacity-40"
|
||||
disabled={!hasNext || chunkView.loading}
|
||||
onClick={() => chunk?.nextOffset !== undefined && void onLoadChunk(chunk.nextOffset)}
|
||||
title={t("Next")}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{rangeLabel ? <span className="network-muted min-w-0 truncate text-[11px] font-semibold">{rangeLabel}</span> : null}
|
||||
{chunkView.loading ? <span className="network-muted shrink-0 text-[11px] font-semibold">{t("Loading full payload...")}</span> : null}
|
||||
{chunkView.error ? <span className="network-error-box shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold">{chunkView.error}</span> : null}
|
||||
</div>
|
||||
<pre className="network-code min-h-0 flex-1 overflow-auto whitespace-pre-wrap break-words p-3 pr-20 font-mono text-[11px] leading-5">{content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogJsonFullscreenViewer({
|
||||
body,
|
||||
bodyView,
|
||||
chunkView,
|
||||
copyLabel,
|
||||
copyText,
|
||||
expandedJsonPaths,
|
||||
onClose,
|
||||
onLoadFullBody,
|
||||
onLoadChunk,
|
||||
onQueryChange,
|
||||
onToggleJsonPath,
|
||||
onToggleTextBody,
|
||||
@@ -1982,11 +2211,12 @@ function LogJsonFullscreenViewer({
|
||||
}: {
|
||||
body?: RequestLogBody;
|
||||
bodyView: LogBodyPanelView;
|
||||
chunkView?: LogBodyChunkPanelView;
|
||||
copyLabel: string;
|
||||
copyText: string;
|
||||
expandedJsonPaths: Set<string>;
|
||||
onClose: () => void;
|
||||
onLoadFullBody: () => void;
|
||||
onLoadChunk: (offset: number) => void | Promise<void>;
|
||||
onQueryChange: (value: string) => void;
|
||||
onToggleJsonPath: (path: string) => void;
|
||||
onToggleTextBody: () => void;
|
||||
@@ -2024,7 +2254,6 @@ function LogJsonFullscreenViewer({
|
||||
<LogJsonBodyToolbar
|
||||
body={body}
|
||||
bodyView={bodyView}
|
||||
onLoadFullBody={onLoadFullBody}
|
||||
onQueryChange={onQueryChange}
|
||||
onToggleTextBody={onToggleTextBody}
|
||||
preferTextBody={preferTextBody}
|
||||
@@ -2033,13 +2262,17 @@ function LogJsonFullscreenViewer({
|
||||
/>
|
||||
<div className="network-json-fullscreen-body flex min-h-0 flex-1">
|
||||
<LogBodyViewer copyLabel={copyLabel} copyText={copyText}>
|
||||
<LogJsonBodyContent
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onToggleJsonPath={onToggleJsonPath}
|
||||
showJsonTree={showJsonTree}
|
||||
value={value}
|
||||
visible={visible}
|
||||
/>
|
||||
{chunkView ? (
|
||||
<LogBodyChunkContent chunkView={chunkView} onLoadChunk={onLoadChunk} query={query} visible={visible} />
|
||||
) : (
|
||||
<LogJsonBodyContent
|
||||
expandedJsonPaths={expandedJsonPaths}
|
||||
onToggleJsonPath={onToggleJsonPath}
|
||||
showJsonTree={showJsonTree}
|
||||
value={value}
|
||||
visible={visible}
|
||||
/>
|
||||
)}
|
||||
</LogBodyViewer>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2053,9 +2286,11 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string {
|
||||
}
|
||||
const text = body.text ?? "";
|
||||
return [
|
||||
body.bodyRef ?? "",
|
||||
body.encoding ?? "",
|
||||
body.contentType ?? "",
|
||||
body.sizeBytes,
|
||||
body.preview ? "preview" : "full",
|
||||
body.truncated ? "truncated" : "complete",
|
||||
text.length,
|
||||
text.slice(0, 96),
|
||||
@@ -2063,10 +2298,42 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string {
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set<string> {
|
||||
function isJsonLikeLogBody(body: RequestLogBody | undefined): boolean {
|
||||
if (!body || body.encoding === "base64") {
|
||||
return false;
|
||||
}
|
||||
const contentType = body.contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
||||
if (contentType === "application/json" || contentType.endsWith("+json")) {
|
||||
return true;
|
||||
}
|
||||
const first = body.text.trimStart().charAt(0);
|
||||
return first === "{" || first === "[";
|
||||
}
|
||||
|
||||
function nextChunkPreviousOffsets(
|
||||
current: LogBodyChunkPanelView | undefined,
|
||||
bodyKey: string,
|
||||
nextOffset: number
|
||||
): number[] {
|
||||
if (!current || current.bodyKey !== bodyKey || !current.chunk) {
|
||||
return [];
|
||||
}
|
||||
if (nextOffset > current.chunk.offset) {
|
||||
return [...current.previousOffsets, current.chunk.offset];
|
||||
}
|
||||
if (nextOffset < current.chunk.offset) {
|
||||
return current.previousOffsets.slice(0, -1);
|
||||
}
|
||||
return current.previousOffsets;
|
||||
}
|
||||
|
||||
function createInitialVisibleJsonPaths(bodyView: FormattedLogBody, side?: "request" | "response"): Set<string> {
|
||||
if (!isJsonContainer(bodyView.json)) {
|
||||
return new Set();
|
||||
}
|
||||
if (side === "request") {
|
||||
return new Set(["$"]);
|
||||
}
|
||||
if (
|
||||
bodyView.text.length > logJsonAutoExpandTextLimit ||
|
||||
jsonContainerEntryCount(bodyView.json, logJsonAutoExpandEntryLimit + 1) > logJsonAutoExpandEntryLimit
|
||||
|
||||
@@ -206,6 +206,7 @@ import type {
|
||||
ProxyNetworkSnapshot,
|
||||
ProxyStatus,
|
||||
RequestLogBody,
|
||||
RequestLogBodyChunk,
|
||||
RequestLogEntry,
|
||||
RequestLogListFilter,
|
||||
RequestLogPage,
|
||||
@@ -569,7 +570,7 @@ export type {
|
||||
ProviderAccountMeter, ProviderAccountStandardConnectorConfig, ProviderAccountSnapshot, ProviderAccountTestPath, ProviderAccountTestResult, ProviderDeepLinkPayload, ProviderDeepLinkRequest,
|
||||
ProviderCredentialConfig,
|
||||
ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, CodexProfileConfigFormat, ProfileScope, ProfileSurface, ProxyCertificateInstallResult, ProxyCertificateStatus, ProxyNetworkBody,
|
||||
ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, RequestLogBody, RequestLogEntry, RequestLogListFilter, RequestLogPage,
|
||||
ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, RequestLogBody, RequestLogBodyChunk, RequestLogEntry, RequestLogListFilter, RequestLogPage,
|
||||
RequestLogStatusFilter, RouterConfig, RouterFallbackConfig, RouterFallbackMode, RouterRule, RouterRuleType, TrayComponentVariants,
|
||||
TrayBalanceProgressConfig, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow, UsageSeriesPoint, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, UsageTotals,
|
||||
VirtualModelBaseModelMode, VirtualModelExecutionMode, VirtualModelFusionCustomToolConfig, VirtualModelFusionVisionConfig, VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider, VirtualModelProfileConfig, VirtualModelToolVisibility, ProviderIdentitySafetyIssue, ProviderPreset, ProviderPresetEndpoint
|
||||
|
||||
@@ -249,6 +249,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"No request headers": "No request headers",
|
||||
"No response headers": "No response headers",
|
||||
"筛选 JSON...": "Filter JSON...",
|
||||
"Text": "Text",
|
||||
"body": "Body",
|
||||
"header": "Headers",
|
||||
"入": "in",
|
||||
@@ -1593,7 +1594,7 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||
"Yes": "是",
|
||||
"Set as default provider": "设为默认供应商",
|
||||
"Show credential settings": "显示凭据配置",
|
||||
"Show full content": "查看完整内容",
|
||||
"Text": "文本",
|
||||
"Session": "会话",
|
||||
"Session Detail": "会话详情",
|
||||
"Session Requests": "会话请求",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RequestLogBody } from "@ccr/core/contracts/app";
|
||||
import type { FormattedLogBody } from "./logs";
|
||||
import { formatLogBodyView, type FormattedLogBody } from "./logs";
|
||||
|
||||
export const logBodyLargeTextThreshold = 256 * 1024;
|
||||
export const logBodyPreviewTextLimit = 160 * 1024;
|
||||
@@ -69,7 +69,7 @@ export function isLargeLogBody(
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
return Math.max(body.sizeBytes, body.text.length) > threshold;
|
||||
return Boolean(body.preview) || Math.max(body.sizeBytes, body.text.length) > threshold;
|
||||
}
|
||||
|
||||
export function createLogBodyPreviewText(
|
||||
@@ -101,3 +101,27 @@ export function createLogBodyPreviewText(
|
||||
tail
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function formatLogBodyForWorker(
|
||||
body: RequestLogBody | undefined,
|
||||
mode: LogBodyFormatMode,
|
||||
largeTextThreshold = logBodyLargeTextThreshold,
|
||||
previewTextLimit = logBodyPreviewTextLimit
|
||||
): FormattedLogBody & {
|
||||
large: boolean;
|
||||
preview: boolean;
|
||||
sourceSizeBytes: number;
|
||||
} {
|
||||
const large = isLargeLogBody(body, largeTextThreshold);
|
||||
const preview = large && mode !== "full";
|
||||
const formattedBodyView = formatLogBodyView(body);
|
||||
const bodyView = preview && formattedBodyView.json === undefined
|
||||
? { text: createLogBodyPreviewText(body, previewTextLimit) }
|
||||
: formattedBodyView;
|
||||
return {
|
||||
...bodyView,
|
||||
large,
|
||||
preview,
|
||||
sourceSizeBytes: body?.sizeBytes ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
createLogBodyPreviewText,
|
||||
isLargeLogBody,
|
||||
formatLogBodyForWorker,
|
||||
logBodyLargeTextThreshold,
|
||||
logBodyPreviewTextLimit,
|
||||
type LogBodyFilterRequest,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
type LogBodyWorkerRequest,
|
||||
type LogBodyWorkerResponse
|
||||
} from "./log-body-worker-protocol";
|
||||
import { filterLogText, formatLogBodyView, type FormattedLogBody } from "./logs";
|
||||
import { filterLogText, type FormattedLogBody } from "./logs";
|
||||
|
||||
type CachedFormattedBody = FormattedLogBody & {
|
||||
bodyKey: string;
|
||||
@@ -50,12 +49,12 @@ worker.onmessage = (event: MessageEvent<LogBodyWorkerRequest>) => {
|
||||
function formatBody(request: LogBodyFormatRequest): LogBodyFormatResult {
|
||||
const threshold = request.largeTextThreshold ?? logBodyLargeTextThreshold;
|
||||
const previewLimit = request.previewTextLimit ?? logBodyPreviewTextLimit;
|
||||
const large = isLargeLogBody(request.body, threshold);
|
||||
const preview = large && request.mode !== "full";
|
||||
const bodyView = preview
|
||||
? { text: createLogBodyPreviewText(request.body, previewLimit) }
|
||||
: formatLogBodyView(request.body);
|
||||
const sourceSizeBytes = request.body?.sizeBytes ?? 0;
|
||||
const { large, preview, sourceSizeBytes, ...bodyView } = formatLogBodyForWorker(
|
||||
request.body,
|
||||
request.mode,
|
||||
threshold,
|
||||
previewLimit
|
||||
);
|
||||
const visible = filterLogText(bodyView.text, request.query);
|
||||
|
||||
cachedBody = {
|
||||
|
||||
@@ -126,7 +126,9 @@ export function logBodyKey(body: RequestLogBody | undefined): string {
|
||||
return "missing";
|
||||
}
|
||||
return JSON.stringify([
|
||||
body.bodyRef ?? "",
|
||||
body.encoding ?? "",
|
||||
body.preview ? "preview" : "full",
|
||||
body.sizeBytes,
|
||||
body.text ?? ""
|
||||
]);
|
||||
|
||||
Vendored
+3
@@ -70,6 +70,8 @@ import type {
|
||||
ProxyNetworkSnapshot,
|
||||
ProxyStatus,
|
||||
RequestLogDetailRequest,
|
||||
RequestLogBodyChunk,
|
||||
RequestLogBodyChunkRequest,
|
||||
RequestLogEntry,
|
||||
RequestLogListFilter,
|
||||
RequestLogPage,
|
||||
@@ -117,6 +119,7 @@ declare global {
|
||||
getProxyNetworkCaptures: () => Promise<ProxyNetworkSnapshot>;
|
||||
getProxyStatus: () => Promise<ProxyStatus>;
|
||||
getRequestLogDetail: (request: RequestLogDetailRequest) => Promise<RequestLogEntry | undefined>;
|
||||
getRequestLogBodyChunk: (request: RequestLogBodyChunkRequest) => Promise<RequestLogBodyChunk | undefined>;
|
||||
getRequestLogs: (filter?: RequestLogListFilter) => Promise<RequestLogPage>;
|
||||
getUpdateStatus: () => Promise<AppUpdateStatus>;
|
||||
getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise<UsageStatsSnapshot>;
|
||||
|
||||
@@ -133,6 +133,7 @@ const webClientBridge: CcrApi = {
|
||||
getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType<CcrApi["getProxyNetworkCaptures"]>,
|
||||
getProxyStatus: () => rpc("getProxyStatus") as ReturnType<CcrApi["getProxyStatus"]>,
|
||||
getRequestLogDetail: (request) => rpc("getRequestLogDetail", [request]) as ReturnType<CcrApi["getRequestLogDetail"]>,
|
||||
getRequestLogBodyChunk: (request) => rpc("getRequestLogBodyChunk", [request]) as ReturnType<CcrApi["getRequestLogBodyChunk"]>,
|
||||
getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType<CcrApi["getRequestLogs"]>,
|
||||
getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType<CcrApi["getUpdateStatus"]>,
|
||||
getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType<CcrApi["getUsageStats"]>,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { RequestLogBody } from "@ccr/core/contracts/app";
|
||||
import { formatLogBodyForWorker } from "@ccr/ui/pages/home/shared/log-body-worker-protocol.ts";
|
||||
import { formatRouteTracePath } from "@ccr/ui/pages/home/shared/network.ts";
|
||||
|
||||
test("route trace paths use request and response dotted notation", () => {
|
||||
@@ -28,3 +30,23 @@ test("route trace paths use request and response dotted notation", () => {
|
||||
"request.url"
|
||||
);
|
||||
});
|
||||
|
||||
test("request log preview bodies still format parseable JSON as JSON", () => {
|
||||
const body: RequestLogBody = {
|
||||
bodyRef: "preview-json-body",
|
||||
contentType: "application/json",
|
||||
encoding: "utf8",
|
||||
preview: true,
|
||||
sizeBytes: 512 * 1024,
|
||||
text: JSON.stringify({ messages: [{ role: "user", content: "hello" }], model: "test-model" }),
|
||||
truncated: false
|
||||
};
|
||||
|
||||
const view = formatLogBodyForWorker(body, "preview", 256 * 1024, 160 * 1024);
|
||||
assert.equal(view.preview, true);
|
||||
assert.deepEqual(view.json, {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
model: "test-model"
|
||||
});
|
||||
assert.match(view.text, /"model": "test-model"/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user