mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
fix: add four-stage request log payloads
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* GET /api/logs/detail — List detailed request logs
|
||||
* GET /api/logs/detail/:id — Get specific detailed log
|
||||
* POST /api/logs/detail/toggle — Enable/disable detailed logging
|
||||
* GET /api/logs/detail — List detailed request logs + current enabled flag
|
||||
* POST /api/logs/detail — Enable/disable detailed logging
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
getRequestDetailLogs,
|
||||
getRequestDetailLogCount,
|
||||
@@ -15,9 +14,8 @@ import { updateSettings } from "@/lib/db/settings";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const authError = await requireManagementAuth(req);
|
||||
if (authError) return authError;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
|
||||
@@ -31,9 +29,8 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthenticated(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const authError = await requireManagementAuth(req);
|
||||
if (authError) return authError;
|
||||
|
||||
const body = await req.json();
|
||||
const enabled = body.enabled === true || body.enabled === "1";
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getCallLogById } from "@/lib/usageDb";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { id } = await params;
|
||||
const log = await getCallLogById(id);
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getCallLogs } from "@/lib/usageDb";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const filter: Record<string, any> = {};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CORS_ORIGIN, CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
|
||||
|
||||
@@ -75,7 +75,7 @@ export async function POST(request: Request) {
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(normalized),
|
||||
});
|
||||
return await handleChat(newRequest);
|
||||
return await handleChat(newRequest, buildClientRawRequest(request, body));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
@@ -91,5 +91,5 @@ export async function POST(request, { params }) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
return await handleChat(newRequest);
|
||||
return await handleChat(newRequest, buildClientRawRequest(request, rawBody));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CORS_ORIGIN } from "@/shared/utils/cors";
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.ts";
|
||||
import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
@@ -87,7 +87,7 @@ export async function POST(request, { params }) {
|
||||
body: JSON.stringify(convertedBody),
|
||||
});
|
||||
|
||||
return await handleChat(newRequest);
|
||||
return await handleChat(newRequest, buildClientRawRequest(request, rawBody));
|
||||
} catch (error) {
|
||||
console.log("Error handling Gemini request:", error);
|
||||
return Response.json({ error: { message: error.message, code: 500 } }, { status: 500 });
|
||||
|
||||
@@ -8,20 +8,28 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance } from "./core";
|
||||
import { getSettings } from "./settings";
|
||||
import { isNoLog } from "../compliance";
|
||||
import {
|
||||
protectPayloadForLog,
|
||||
serializePayloadForStorage,
|
||||
parseStoredPayload,
|
||||
} from "../logPayloads";
|
||||
|
||||
export interface RequestDetailLog {
|
||||
id?: string;
|
||||
call_log_id?: string | null;
|
||||
timestamp?: string;
|
||||
client_request?: string | null;
|
||||
translated_request?: string | null;
|
||||
provider_response?: string | null;
|
||||
client_response?: string | null;
|
||||
client_request?: unknown | null;
|
||||
translated_request?: unknown | null;
|
||||
provider_response?: unknown | null;
|
||||
client_response?: unknown | null;
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
source_format?: string | null;
|
||||
target_format?: string | null;
|
||||
duration_ms?: number;
|
||||
api_key_id?: string | null;
|
||||
no_log?: boolean;
|
||||
}
|
||||
|
||||
/** Returns true if detailed logging is enabled in settings */
|
||||
@@ -37,16 +45,14 @@ export async function isDetailedLoggingEnabled(): Promise<boolean> {
|
||||
|
||||
/** Save a detailed log entry — caller must verify isDetailedLoggingEnabled() first */
|
||||
export function saveRequestDetailLog(entry: RequestDetailLog): void {
|
||||
const noLogEnabled =
|
||||
Boolean(entry.no_log) || (entry.api_key_id ? isNoLog(entry.api_key_id) : false);
|
||||
if (noLogEnabled) return;
|
||||
|
||||
const db = getDbInstance();
|
||||
const id = entry.id ?? uuidv4();
|
||||
const timestamp = entry.timestamp ?? new Date().toISOString();
|
||||
|
||||
// Trim large bodies to avoid excessive disk usage (max 64KB each)
|
||||
const trim = (s: string | null | undefined, max = 65536): string | null => {
|
||||
if (!s) return null;
|
||||
return s.length > max ? s.slice(0, max) + "…[truncated]" : s;
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO request_detail_logs
|
||||
@@ -58,10 +64,10 @@ export function saveRequestDetailLog(entry: RequestDetailLog): void {
|
||||
id,
|
||||
entry.call_log_id ?? null,
|
||||
timestamp,
|
||||
trim(entry.client_request),
|
||||
trim(entry.translated_request),
|
||||
trim(entry.provider_response),
|
||||
trim(entry.client_response),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.client_request)),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.translated_request)),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.provider_response)),
|
||||
serializePayloadForStorage(protectPayloadForLog(entry.client_response)),
|
||||
entry.provider ?? null,
|
||||
entry.model ?? null,
|
||||
entry.source_format ?? null,
|
||||
@@ -73,7 +79,7 @@ export function saveRequestDetailLog(entry: RequestDetailLog): void {
|
||||
/** Fetch detailed logs (latest first) */
|
||||
export function getRequestDetailLogs(limit = 50, offset = 0): RequestDetailLog[] {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM request_detail_logs
|
||||
@@ -81,14 +87,34 @@ export function getRequestDetailLogs(limit = 50, offset = 0): RequestDetailLog[]
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
)
|
||||
.all(limit, offset) as RequestDetailLog[];
|
||||
.all(limit, offset) as Array<Record<string, unknown>>;
|
||||
|
||||
return rows.map(mapDetailedLogRow);
|
||||
}
|
||||
|
||||
/** Get a single detailed log by ID */
|
||||
export function getRequestDetailLogById(id: string): RequestDetailLog | null {
|
||||
const db = getDbInstance();
|
||||
return (db.prepare("SELECT * FROM request_detail_logs WHERE id = ?").get(id) ??
|
||||
null) as RequestDetailLog | null;
|
||||
const row = db.prepare("SELECT * FROM request_detail_logs WHERE id = ?").get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return row ? mapDetailedLogRow(row) : null;
|
||||
}
|
||||
|
||||
/** Get the most recent detailed log for a call log ID */
|
||||
export function getRequestDetailLogByCallLogId(callLogId: string): RequestDetailLog | null {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM request_detail_logs
|
||||
WHERE call_log_id = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
`
|
||||
)
|
||||
.get(callLogId) as Record<string, unknown> | undefined;
|
||||
return row ? mapDetailedLogRow(row) : null;
|
||||
}
|
||||
|
||||
/** Get total count of detailed logs */
|
||||
@@ -99,3 +125,20 @@ export function getRequestDetailLogCount(): number {
|
||||
};
|
||||
return row?.cnt ?? 0;
|
||||
}
|
||||
|
||||
function mapDetailedLogRow(row: Record<string, unknown>): RequestDetailLog {
|
||||
return {
|
||||
id: typeof row.id === "string" ? row.id : undefined,
|
||||
call_log_id: typeof row.call_log_id === "string" ? row.call_log_id : null,
|
||||
timestamp: typeof row.timestamp === "string" ? row.timestamp : undefined,
|
||||
client_request: parseStoredPayload(row.client_request),
|
||||
translated_request: parseStoredPayload(row.translated_request),
|
||||
provider_response: parseStoredPayload(row.provider_response),
|
||||
client_response: parseStoredPayload(row.client_response),
|
||||
provider: typeof row.provider === "string" ? row.provider : null,
|
||||
model: typeof row.model === "string" ? row.model : null,
|
||||
source_format: typeof row.source_format === "string" ? row.source_format : null,
|
||||
target_format: typeof row.target_format === "string" ? row.target_format : null,
|
||||
duration_ms: typeof row.duration_ms === "number" ? row.duration_ms : 0,
|
||||
};
|
||||
}
|
||||
|
||||
109
src/lib/logPayloads.ts
Normal file
109
src/lib/logPayloads.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { sanitizePII } from "./piiSanitizer";
|
||||
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"api_key",
|
||||
"apiKey",
|
||||
"api-key",
|
||||
"authorization",
|
||||
"Authorization",
|
||||
"x-api-key",
|
||||
"X-Api-Key",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"refresh_token",
|
||||
"refreshToken",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
]);
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function cloneLogPayload<T>(value: T): T {
|
||||
if (value === null || value === undefined) return value;
|
||||
if (typeof globalThis.structuredClone === "function") {
|
||||
return globalThis.structuredClone(value);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
export function normalizePayloadForLog(payload: unknown): unknown {
|
||||
if (typeof payload !== "string") return payload;
|
||||
|
||||
const trimmed = payload.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { _rawText: payload };
|
||||
}
|
||||
}
|
||||
|
||||
export function redactPayload(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object") return payload;
|
||||
if (Array.isArray(payload)) return payload.map(redactPayload);
|
||||
|
||||
const redacted: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
redacted[key] = "[REDACTED]";
|
||||
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
|
||||
redacted[key] = "Bearer [REDACTED]";
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
redacted[key] = redactPayload(value);
|
||||
} else {
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function sanitizePayloadPII(payload: unknown): unknown {
|
||||
if (typeof payload === "string") {
|
||||
return sanitizePII(payload).text;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map(sanitizePayloadPII);
|
||||
}
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const sanitized: JsonRecord = {};
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
sanitized[key] = sanitizePayloadPII(value);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function protectPayloadForLog(payload: unknown): unknown {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
const normalized = normalizePayloadForLog(payload);
|
||||
const piiSanitized = sanitizePayloadPII(normalized);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
export function serializePayloadForStorage(payload: unknown, maxLength = 65536): string | null {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
|
||||
const exact = JSON.stringify(payload);
|
||||
if (exact.length <= maxLength) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
_truncated: true,
|
||||
_originalSize: exact.length,
|
||||
_preview: exact.slice(0, maxLength),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseStoredPayload(value: unknown): unknown | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return { _rawText: value };
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,12 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { getSettings } from "../db/settings";
|
||||
import { getRequestDetailLogByCallLogId } from "../db/detailedLogs";
|
||||
import { shouldPersistToDisk, CALL_LOGS_DIR } from "./migrations";
|
||||
import { getLoggedInputTokens, getLoggedOutputTokens } from "./tokenAccounting";
|
||||
import { isNoLog } from "../compliance";
|
||||
import { sanitizePII } from "../piiSanitizer";
|
||||
import { protectPayloadForLog, parseStoredPayload } from "../logPayloads";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -35,15 +37,6 @@ function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function parseJsonString(value: unknown): unknown | null {
|
||||
if (typeof value !== "string" || value.trim().length === 0) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTruncatedFlag(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
return (value as Record<string, unknown>)._truncated === true;
|
||||
@@ -108,80 +101,6 @@ export function invalidateCallLogsMaxCache(): void {
|
||||
expiresAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fields that should always be redacted from logged payloads */
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
"api_key",
|
||||
"apiKey",
|
||||
"api-key",
|
||||
"authorization",
|
||||
"Authorization",
|
||||
"x-api-key",
|
||||
"X-Api-Key",
|
||||
"access_token",
|
||||
"accessToken",
|
||||
"refresh_token",
|
||||
"refreshToken",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Redact sensitive fields from a payload before persistence.
|
||||
*/
|
||||
function redactPayload(obj: any): any {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(redactPayload);
|
||||
|
||||
const redacted: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
redacted[key] = "[REDACTED]";
|
||||
} else if (typeof value === "string" && value.startsWith("Bearer ")) {
|
||||
redacted[key] = "Bearer [REDACTED]";
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
redacted[key] = redactPayload(value);
|
||||
} else {
|
||||
redacted[key] = value;
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize PII from string fields in a payload.
|
||||
* Uses lib/piiSanitizer config flags to determine if redaction is enabled.
|
||||
*/
|
||||
function sanitizePayloadPII(obj: any): any {
|
||||
if (typeof obj === "string") {
|
||||
return sanitizePII(obj).text;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sanitizePayloadPII);
|
||||
}
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const sanitized: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
sanitized[key] = sanitizePayloadPII(value);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply payload protection chain before persistence.
|
||||
* 1) Optional PII sanitization
|
||||
* 2) Mandatory key/token redaction
|
||||
*/
|
||||
function protectPayloadForLog(payload: any): any {
|
||||
if (!payload || !shouldLogPayloadInDb) return null;
|
||||
const piiSanitized = sanitizePayloadPII(payload);
|
||||
return redactPayload(piiSanitized);
|
||||
}
|
||||
|
||||
let logIdCounter = 0;
|
||||
function generateLogId() {
|
||||
logIdCounter++;
|
||||
@@ -198,8 +117,10 @@ export async function saveCallLog(entry: any) {
|
||||
const apiKeyId = entry.apiKeyId || null;
|
||||
const noLogEnabled = Boolean(entry.noLog) || (apiKeyId ? isNoLog(apiKeyId) : false);
|
||||
|
||||
const protectedRequestBody = noLogEnabled ? null : protectPayloadForLog(entry.requestBody);
|
||||
const protectedResponseBody = noLogEnabled ? null : protectPayloadForLog(entry.responseBody);
|
||||
const protectedRequestBody =
|
||||
noLogEnabled || !shouldLogPayloadInDb ? null : protectPayloadForLog(entry.requestBody);
|
||||
const protectedResponseBody =
|
||||
noLogEnabled || !shouldLogPayloadInDb ? null : protectPayloadForLog(entry.responseBody);
|
||||
|
||||
// Resolve account name
|
||||
let account = entry.connectionId ? entry.connectionId.slice(0, 8) : "-";
|
||||
@@ -227,7 +148,7 @@ export async function saveCallLog(entry: any) {
|
||||
};
|
||||
|
||||
const logEntry = {
|
||||
id: generateLogId(),
|
||||
id: typeof entry.id === "string" && entry.id.length > 0 ? entry.id : generateLogId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
method: entry.method || "POST",
|
||||
path: entry.path || "/v1/chat/completions",
|
||||
@@ -470,8 +391,8 @@ export async function getCallLogById(id: string) {
|
||||
apiKeyId: toStringOrNull(entryRow.api_key_id),
|
||||
apiKeyName: toStringOrNull(entryRow.api_key_name),
|
||||
comboName: toStringOrNull(entryRow.combo_name),
|
||||
requestBody: parseJsonString(entryRow.request_body),
|
||||
responseBody: parseJsonString(entryRow.response_body),
|
||||
requestBody: parseStoredPayload(entryRow.request_body),
|
||||
responseBody: parseStoredPayload(entryRow.response_body),
|
||||
error: toStringOrNull(entryRow.error),
|
||||
};
|
||||
|
||||
@@ -492,7 +413,20 @@ export async function getCallLogById(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
const detailed = getRequestDetailLogByCallLogId(id);
|
||||
if (!detailed) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
pipelinePayloads: {
|
||||
clientRequest: detailed.client_request ?? null,
|
||||
providerRequest: detailed.translated_request ?? null,
|
||||
providerResponse: detailed.provider_response ?? null,
|
||||
clientResponse: detailed.client_response ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,8 +80,42 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
}
|
||||
};
|
||||
|
||||
const requestJson = detail?.requestBody ? JSON.stringify(detail.requestBody, null, 2) : null;
|
||||
const responseJson = detail?.responseBody ? JSON.stringify(detail.responseBody, null, 2) : null;
|
||||
const toPrettyJson = (payload) => {
|
||||
if (payload === null || payload === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const pipelinePayloads = detail?.pipelinePayloads || null;
|
||||
const payloadSections = pipelinePayloads
|
||||
? [
|
||||
{
|
||||
key: "client-request",
|
||||
title: "Client Request",
|
||||
json: toPrettyJson(pipelinePayloads.clientRequest),
|
||||
},
|
||||
{
|
||||
key: "provider-request",
|
||||
title: "Provider Request",
|
||||
json: toPrettyJson(pipelinePayloads.providerRequest),
|
||||
},
|
||||
{
|
||||
key: "provider-response",
|
||||
title: "Provider Response",
|
||||
json: toPrettyJson(pipelinePayloads.providerResponse),
|
||||
},
|
||||
{
|
||||
key: "client-response",
|
||||
title: "Client Response",
|
||||
json: toPrettyJson(pipelinePayloads.clientResponse),
|
||||
},
|
||||
].filter((section) => section.json)
|
||||
: [];
|
||||
const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null;
|
||||
const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -240,33 +274,41 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Response Payload (返回) — show first */}
|
||||
{responseJson && (
|
||||
{payloadSections.length > 0 &&
|
||||
payloadSections.map((section) => (
|
||||
<PayloadSection
|
||||
key={section.key}
|
||||
title={section.title}
|
||||
json={section.json}
|
||||
onCopy={() => onCopy(section.json)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{payloadSections.length === 0 && responseJson && (
|
||||
<PayloadSection
|
||||
title="Response Payload (返回)"
|
||||
title="Response Payload (Legacy)"
|
||||
json={responseJson}
|
||||
onCopy={() => onCopy(responseJson)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Request Payload (请求) */}
|
||||
{requestJson && (
|
||||
{payloadSections.length === 0 && requestJson && (
|
||||
<PayloadSection
|
||||
title="Request Payload (请求)"
|
||||
title="Request Payload (Legacy)"
|
||||
json={requestJson}
|
||||
onCopy={() => onCopy(requestJson)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!requestJson && !responseJson && !loading && (
|
||||
{payloadSections.length === 0 && !requestJson && !responseJson && !loading && (
|
||||
<div className="p-6 text-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 block opacity-40">
|
||||
info
|
||||
</span>
|
||||
<p className="text-sm">No payload data available for this log entry.</p>
|
||||
<p className="text-xs mt-1">
|
||||
Request/response bodies are only captured for non-streaming calls or when
|
||||
streaming completes normally.
|
||||
Enable detailed logging first if you want the four-stage client/provider payload
|
||||
view for new requests.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -93,6 +93,9 @@ export default function RequestLoggerV2() {
|
||||
const [selectedLog, setSelectedLog] = useState(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailData, setDetailData] = useState(null);
|
||||
const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false);
|
||||
const [detailLoggingLoading, setDetailLoggingLoading] = useState(false);
|
||||
const [detailLoggingReady, setDetailLoggingReady] = useState(false);
|
||||
const intervalRef = useRef(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
@@ -161,6 +164,20 @@ export default function RequestLoggerV2() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/logs/detail?limit=1")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data) return;
|
||||
setDetailLoggingEnabled(data.enabled === true);
|
||||
setDetailLoggingReady(true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
@@ -232,6 +249,25 @@ export default function RequestLoggerV2() {
|
||||
setDetailData(null);
|
||||
};
|
||||
|
||||
const toggleDetailLogging = async () => {
|
||||
setDetailLoggingLoading(true);
|
||||
try {
|
||||
const nextEnabled = !detailLoggingEnabled;
|
||||
const res = await fetch("/api/logs/detail", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: nextEnabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update detailed logging");
|
||||
setDetailLoggingEnabled(nextEnabled);
|
||||
setDetailLoggingReady(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle detailed logging:", error);
|
||||
} finally {
|
||||
setDetailLoggingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Unique accounts and providers for dropdowns
|
||||
|
||||
const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))];
|
||||
@@ -271,6 +307,33 @@ export default function RequestLoggerV2() {
|
||||
{recording ? "Recording" : "Paused"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleDetailLogging}
|
||||
disabled={detailLoggingLoading}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors disabled:opacity-60 ${
|
||||
detailLoggingEnabled
|
||||
? "bg-amber-500/10 border-amber-500/30 text-amber-700 dark:text-amber-300"
|
||||
: "bg-bg-subtle border-border text-text-muted"
|
||||
}`}
|
||||
title="Capture four-stage pipeline payloads for new requests"
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${detailLoggingEnabled ? "bg-amber-500" : "bg-text-muted"}`}
|
||||
/>
|
||||
{detailLoggingLoading
|
||||
? "Updating detailed logs..."
|
||||
: detailLoggingEnabled
|
||||
? "Detailed Logs On"
|
||||
: "Detailed Logs Off"}
|
||||
</button>
|
||||
|
||||
{detailLoggingReady && (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
New requests will {detailLoggingEnabled ? "" : "not "}capture client/provider pipeline
|
||||
payloads.
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[18px]">
|
||||
|
||||
@@ -44,6 +44,7 @@ import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTel
|
||||
import { generateRequestId } from "../../shared/utils/requestId";
|
||||
import { logAuditEvent } from "../../lib/compliance/index";
|
||||
import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy";
|
||||
import { cloneLogPayload } from "@/lib/logPayloads";
|
||||
import {
|
||||
applyTaskAwareRouting,
|
||||
getTaskRoutingConfig,
|
||||
@@ -81,6 +82,13 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const rawClientBody = cloneLogPayload(body);
|
||||
|
||||
// Build clientRawRequest for logging (if not provided)
|
||||
if (!clientRawRequest) {
|
||||
clientRawRequest = buildClientRawRequest(request, rawClientBody);
|
||||
}
|
||||
|
||||
// FASE-01: Input sanitization — prompt injection detection & PII redaction
|
||||
telemetry.startPhase("validate");
|
||||
const sanitizeResult = sanitizeRequest(body, log as any);
|
||||
@@ -113,16 +121,6 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
);
|
||||
}
|
||||
|
||||
// Build clientRawRequest for logging (if not provided)
|
||||
if (!clientRawRequest) {
|
||||
const url = new URL(request.url);
|
||||
clientRawRequest = {
|
||||
endpoint: url.pathname,
|
||||
body,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
// Log request endpoint and model
|
||||
const url = new URL(request.url);
|
||||
const modelStr = body.model;
|
||||
@@ -344,6 +342,15 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
return withSessionHeader(response, sessionId);
|
||||
}
|
||||
|
||||
export function buildClientRawRequest(request: Request, body: unknown) {
|
||||
const url = new URL(request.url);
|
||||
return {
|
||||
endpoint: url.pathname,
|
||||
body: cloneLogPayload(body),
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle single model chat request
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user