From b0120bf2c57040366a11b95537ea6664082a2ed9 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sun, 28 Jun 2026 12:09:09 +0200 Subject: [PATCH 1/8] fix: prevent webpack OOM in instrumentation compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrow tsconfig include to src/**, add "use client" directives for proper code-splitting, and hide the heavy batchProcessor/tsx imports from webpack's static analysis via /* webpackIgnore */ (replaces a new Function() eval shim — Hard Rule #3). Co-authored-by: diegosouzapw --- next.config.mjs | 1 + open-sse/services/batchProcessor.ts | 5 ++-- scripts/dev/run-next.mjs | 20 ++++++++++++++ .../[id]/hooks/useCommandCodeAuth.ts | 1 + .../providers/[id]/hooks/useConnectionGate.ts | 1 + .../[id]/hooks/useExternalLinkFlow.ts | 1 + .../[id]/hooks/useModelCompatState.ts | 1 + .../components/CliproxyModelMappingEditor.tsx | 1 + .../components/NinerouterInstallWizard.tsx | 1 + .../components/NinerouterModelList.tsx | 1 + .../NinerouterProviderExposureCard.tsx | 1 + src/app/layout.tsx | 8 ++++++ src/instrumentation-node.ts | 12 +++++++-- src/shared/components/PwaRegister.tsx | 8 +++++- tsconfig.json | 26 ++++++------------- 15 files changed, 65 insertions(+), 23 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 220e747be01b..36f7b65e3188 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -206,6 +206,7 @@ const nextConfig = { "buffer", "util", "process", + "worker_threads", ], transpilePackages: ["@omniroute/open-sse", "@lobehub/icons", "fumadocs-ui", "fumadocs-core"], allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.0.250"], diff --git a/open-sse/services/batchProcessor.ts b/open-sse/services/batchProcessor.ts index 9cde8a032151..2972197b8a01 100644 --- a/open-sse/services/batchProcessor.ts +++ b/open-sse/services/batchProcessor.ts @@ -11,7 +11,6 @@ import { listFiles, updateBatch, } from "@/lib/localDb"; -import { dispatch } from "@/lib/batches/dispatch"; import type { SupportedBatchEndpoint } from "@/shared/constants/batchEndpoints"; import { DEFAULT_BATCH_EXPIRATION_SECONDS } from "@/shared/constants/batch"; @@ -422,7 +421,9 @@ async function processSingleItemWithRetry(item: BatchRequestItem, apiKey: string async function processSingleItem(item: BatchRequestItem, apiKey: string) { const body = buildRequestBody(item); - return await dispatch.dispatchBatchApiRequest({ + // Lazy import to avoid webpack tracing 7 route handlers during instrumentation compilation. + const { dispatch: batchDispatch } = await import("@/lib/batches/dispatch"); + return await batchDispatch.dispatchBatchApiRequest({ endpoint: item.url, body, apiKey, diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 5b8a9af0cef8..ad3cf953eebd 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -112,6 +112,26 @@ async function start() { // Stamp the real TCP peer IP before Next sees the request, so the authz // middleware can decide LOCAL_ONLY locality without trusting the Host header. stampPeerIp(req); + + // In dev mode, prevent the browser from caching HTML (including the page + // shell with embedded chunk URLs). Without this, a dev restart leaves the + // browser holding stale HTML that references old turbopack chunk hashes, + // causing ChunkLoadError → infinite reload loop. + if (dev) { + const origWriteHead = res.writeHead.bind(res); + res.writeHead = function (statusCode, ...args) { + if (!res.headersSent) { + const ct = res.getHeader("content-type"); + if (!ct || String(ct).includes("text/html")) { + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } + } + return origWriteHead(statusCode, ...args); + }; + } + return requestHandler(req, res); }); server.on("upgrade", async (req, socket, head) => { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts index ea1a6809d4c9..f57f35031403 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useCommandCodeAuth.ts @@ -1,3 +1,4 @@ +"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { type CommandCodeAuthFlowState } from "../providerPageHelpers"; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts index c892fece2a28..f0e427ed5ea3 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useConnectionGate.ts @@ -1,3 +1,4 @@ +"use client"; // Phase 1t.3 extraction — Issue #3501 // Encapsulates gateConnectionFlow + risk-notice modal state + confirm/cancel handlers. import { useState, useRef, useCallback } from "react"; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts index 209ea814e9e9..3f79f0efb584 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useExternalLinkFlow.ts @@ -1,3 +1,4 @@ +"use client"; import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts index c91e41b896f9..4b1ae42f2718 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelCompatState.ts @@ -9,6 +9,7 @@ * Leaf module: imports ONLY from providerPageHelpers and React. * No import from ProviderDetailPageClient → zero cycle risk. */ +"use client"; import { useMemo, useCallback } from "react"; import { MODEL_COMPAT_PROTOCOL_KEYS } from "@/shared/constants/modelCompat"; import { diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx index 8e13697304a3..a27179c7e719 100644 --- a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx @@ -5,6 +5,7 @@ */ "use client"; +"use client"; import { useState, useEffect, useRef } from "react"; import { Card } from "@/shared/components"; diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx index f132b55cb051..f29fcc8e8ea5 100644 --- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx @@ -5,6 +5,7 @@ */ "use client"; +"use client"; import { useState } from "react"; import { Card, Button } from "@/shared/components"; import { useServiceStatus } from "../hooks/useServiceStatus"; diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx index 2ec793df277a..624265150657 100644 --- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx @@ -5,6 +5,7 @@ */ "use client"; +"use client"; import { useState, useEffect, useCallback } from "react"; import { Card, Button } from "@/shared/components"; diff --git a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx index c40764d33e3f..db452986e6f8 100644 --- a/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx @@ -5,6 +5,7 @@ */ "use client"; +"use client"; import { useState } from "react"; import { Card, Toggle } from "@/shared/components"; import { useServiceStatus } from "../hooks/useServiceStatus"; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1e1d6483f8db..d86b7884e2d8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -86,6 +86,14 @@ export default async function RootLayout({ children }) { }; } } + window.addEventListener('unhandledrejection', function(e) { + if (e.reason && e.reason.name === 'ChunkLoadError' && !sessionStorage.getItem('omniroute:cr')) { + e.preventDefault(); + sessionStorage.setItem('omniroute:cr', '1'); + location.href = '/?cr=' + Date.now(); + } + }); + sessionStorage.removeItem('omniroute:cr'); try { const stored = localStorage.getItem('theme'); const parsed = stored ? JSON.parse(stored) : null; diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 30f79f8f5931..3d51429d765d 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -70,7 +70,7 @@ async function ensureSecrets(): Promise { export async function registerNodejs(): Promise { // Initialize proxy fetch patch FIRST (before any HTTP requests) - await import("@omniroute/open-sse/index.ts"); + await import("@omniroute/open-sse/utils/proxyFetch.ts"); console.log("[STARTUP] Global fetch proxy patch initialized"); await ensureSecrets(); @@ -149,7 +149,15 @@ export async function registerNodejs(): Promise { console.log( `[STARTUP] Cloud/model sync background bootstrap ${cloudSyncInitialized ? "initialized" : "skipped"}` ); - const { initBatchProcessor } = await import("@omniroute/open-sse/services/batchProcessor"); + // webpack traces ALL import() in the file, including lazy ones inside functions. + // batchProcessor → dispatch → 7 route handlers → full open-sse workspace → OOM. + // The /* webpackIgnore */ comments below hide both imports from webpack's static + // analysis; at runtime tsx resolves @/ aliases and .ts extensions for the + // webpack-ignored batchProcessor import. (No new Function() — Hard Rule #3.) + await import(/* webpackIgnore: true */ "tsx"); + const { initBatchProcessor } = await import( + /* webpackIgnore: true */ "@omniroute/open-sse/services/batchProcessor.ts" + ); initBatchProcessor(); console.log("[STARTUP] Batch processor started"); } diff --git a/src/shared/components/PwaRegister.tsx b/src/shared/components/PwaRegister.tsx index 0e49a6070fd3..c322d1f596bb 100644 --- a/src/shared/components/PwaRegister.tsx +++ b/src/shared/components/PwaRegister.tsx @@ -8,8 +8,14 @@ export function PwaRegister() { return; } - // Disable service worker in development to avoid chunk loading / HMR conflicts if (process.env.NODE_ENV !== "production") { + // Actively unregister any stale SW from a previous production run. + // A lingering SW caches /_next/static/chunks/ aggressively in dev, + // which causes "ServiceWorker intercepted the request and encountered + // an unexpected error" when turbopack changes chunk hashes on restart. + navigator.serviceWorker.getRegistrations().then((regs) => { + for (const reg of regs) reg.unregister(); + }); return; } diff --git a/tsconfig.json b/tsconfig.json index 05d2503ccdeb..64abf1fd92c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "checkJs": false, "skipLibCheck": true, @@ -21,15 +17,9 @@ "incremental": true, "forceConsistentCasingInFileNames": true, "paths": { - "@/*": [ - "./src/*" - ], - "@omniroute/open-sse": [ - "./open-sse" - ], - "@omniroute/open-sse/*": [ - "./open-sse/*" - ] + "@/*": ["./src/*"], + "@omniroute/open-sse": ["./open-sse"], + "@omniroute/open-sse/*": ["./open-sse/*"] }, "plugins": [ { @@ -39,10 +29,10 @@ }, "include": [ "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.js", + "src/**/*.jsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", ".next/dev/dev/types/**/*.ts", From 927a8c4bb991ae9b9d3aedd6575c1ffcb7ba2397 Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sun, 28 Jun 2026 12:09:37 +0200 Subject: [PATCH 2/8] feat(logging): add correlation ID to call-logs and request pipeline - Add correlation_id column to call_logs table (migration + schema) - Thread correlationId through chat handler, combo handler, and breaker - Return X-Correlation-Id response header - Filter call-logs by correlationId in API and dashboard - Fix call-logs sort: active requests first, then newest - Add timestamps to request logger detail and list views - Make getExecutor() async for lazy loading support --- src/app/api/usage/call-logs/route.ts | 37 ++- src/app/api/v1/messages/count_tokens/route.ts | 2 +- src/lib/db/core.ts | 4 +- .../103_call_logs_correlation_id.sql | 2 + src/lib/db/schemaColumns.ts | 5 + src/lib/usage/callLogs.ts | 13 +- src/lib/usage/providerLimits.ts | 2 +- src/lib/usage/usageHistory.ts | 5 + src/shared/components/RequestLoggerDetail.tsx | 169 +++++++++-- src/shared/components/RequestLoggerV2.tsx | 265 +++++++++++++++++- src/sse/handlers/chat.ts | 75 ++++- src/sse/handlers/chatHelpers.ts | 19 ++ 12 files changed, 533 insertions(+), 65 deletions(-) create mode 100644 src/lib/db/migrations/103_call_logs_correlation_id.sql diff --git a/src/app/api/usage/call-logs/route.ts b/src/app/api/usage/call-logs/route.ts index 5a469cb59e6a..1ff3347a82d5 100644 --- a/src/app/api/usage/call-logs/route.ts +++ b/src/app/api/usage/call-logs/route.ts @@ -63,6 +63,7 @@ export function buildCallLogListRows({ apiKeyName: null, comboName: null, error: null, + correlationId: detail.correlationId || null, active: true, }); } @@ -96,6 +97,7 @@ export function buildCallLogListRows({ apiKeyName: null, comboName: null, error: detail.error || null, + correlationId: detail.correlationId || null, active: false, completed: true, completedAt: completedAt ? new Date(completedAt).toISOString() : null, @@ -104,9 +106,12 @@ export function buildCallLogListRows({ } return [...activeEntries, ...completedEntries, ...logs].sort((a, b) => { - const timestampDelta = rowTimestampMs(b) - rowTimestampMs(a); - if (timestampDelta !== 0) return timestampDelta; - return rowPriority(a) - rowPriority(b); + // Active requests always on top + const pa = rowPriority(a); + const pb = rowPriority(b); + if (pa !== pb) return pa - pb; + // Within same priority, newest first + return rowTimestampMs(b) - rowTimestampMs(a); }); } @@ -125,19 +130,29 @@ export async function GET(request: Request) { if (searchParams.get("apiKey")) filter.apiKey = searchParams.get("apiKey"); if (searchParams.get("combo")) filter.combo = searchParams.get("combo"); if (searchParams.get("search")) filter.search = searchParams.get("search"); + if (searchParams.get("correlationId")) filter.correlationId = searchParams.get("correlationId"); + if (searchParams.get("groupByCorrelationId")) filter.groupByCorrelationId = true; if (searchParams.get("limit")) filter.limit = parseInt(searchParams.get("limit")); if (searchParams.get("offset")) filter.offset = parseInt(searchParams.get("offset")); const [logs, connections] = await Promise.all([getCallLogs(filter), getProviderConnections()]); - return NextResponse.json( - buildCallLogListRows({ - logs, - connections, - pendingDetails: getPendingById().values(), - completedDetails: getCompletedDetails().values(), - }) - ); + const rows = buildCallLogListRows({ + logs, + connections, + pendingDetails: getPendingById().values(), + completedDetails: getCompletedDetails().values(), + }); + + // When correlationId filter is set, also filter in-memory entries + // (active + completed) that don't match — getCallLogs already filters + // the DB rows but activeEntries/completedEntries bypass it. + if (filter.correlationId) { + const cid = filter.correlationId; + return NextResponse.json(rows.filter((r: any) => r.correlationId === cid)); + } + + return NextResponse.json(rows); } catch (error) { console.error("[API ERROR] /api/usage/call-logs failed:", error); return NextResponse.json({ error: "Failed to fetch call logs" }, { status: 500 }); diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index 051c2f4860b0..df37669ba5af 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -62,7 +62,7 @@ export async function POST(request) { return estimated; } - const executor = getExecutor(modelInfo.provider); + const executor = await getExecutor(modelInfo.provider); // The provider-side count is a real upstream call — it must honor the // connection's proxy assignment exactly like chat execution does. const proxyInfo = await safeResolveProxy(credentials.connectionId); diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index c277562262cb..9c3673ef33bc 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -341,10 +341,12 @@ const SCHEMA_SQL = ` has_request_body INTEGER DEFAULT 0, has_response_body INTEGER DEFAULT 0, has_pipeline_details INTEGER DEFAULT 0, - request_summary TEXT + request_summary TEXT, + correlation_id TEXT ); CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp); CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status); + CREATE INDEX IF NOT EXISTS idx_cl_correlation_id ON call_logs(correlation_id); CREATE TABLE IF NOT EXISTS proxy_logs ( id TEXT PRIMARY KEY, diff --git a/src/lib/db/migrations/103_call_logs_correlation_id.sql b/src/lib/db/migrations/103_call_logs_correlation_id.sql new file mode 100644 index 000000000000..e053f9b845d0 --- /dev/null +++ b/src/lib/db/migrations/103_call_logs_correlation_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE call_logs ADD COLUMN correlation_id TEXT; +CREATE INDEX IF NOT EXISTS idx_cl_correlation_id ON call_logs(correlation_id); diff --git a/src/lib/db/schemaColumns.ts b/src/lib/db/schemaColumns.ts index ccc55d77ed8c..762c1b9084b9 100644 --- a/src/lib/db/schemaColumns.ts +++ b/src/lib/db/schemaColumns.ts @@ -179,6 +179,10 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { db.exec("ALTER TABLE call_logs ADD COLUMN request_summary TEXT DEFAULT NULL"); console.log("[DB] Added call_logs.request_summary column"); } + if (!columnNames.has("correlation_id")) { + db.exec("ALTER TABLE call_logs ADD COLUMN correlation_id TEXT DEFAULT NULL"); + console.log("[DB] Added call_logs.correlation_id column"); + } db.exec( "CREATE INDEX IF NOT EXISTS idx_call_logs_requested_model ON call_logs(requested_model)" @@ -187,6 +191,7 @@ export function ensureCallLogsColumns(db: SqliteDatabase) { db.exec( "CREATE INDEX IF NOT EXISTS idx_cl_combo_target ON call_logs(combo_name, combo_execution_key, timestamp)" ); + db.exec("CREATE INDEX IF NOT EXISTS idx_cl_correlation_id ON call_logs(correlation_id)"); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); console.warn("[DB] Failed to verify call_logs schema:", message); diff --git a/src/lib/usage/callLogs.ts b/src/lib/usage/callLogs.ts index e18bbf140a19..faa62d265516 100644 --- a/src/lib/usage/callLogs.ts +++ b/src/lib/usage/callLogs.ts @@ -578,6 +578,7 @@ function mapSummaryRow(row: CallLogSummaryRow) { hasRequestBody: toNumber(row.has_request_body) === 1, hasResponseBody: toNumber(row.has_response_body) === 1, hasPipelineDetails: toNumber(row.has_pipeline_details) === 1, + correlationId: row.correlation_id || null, }; } @@ -659,6 +660,7 @@ export async function saveCallLog(entry: any) { comboStepId: toStringOrNull(entry.comboStepId), comboExecutionKey: toStringOrNull(entry.comboExecutionKey) || toStringOrNull(entry.comboStepId), + correlationId: entry.correlationId || null, }; const requestSummary = noLogEnabled @@ -705,7 +707,8 @@ export async function saveCallLog(entry: any) { cache_source, request_type, source_format, target_format, api_key_id, api_key_name, combo_name, combo_step_id, combo_execution_key, error_summary, detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256, - has_request_body, has_response_body, has_pipeline_details, request_summary + has_request_body, has_response_body, has_pipeline_details, request_summary, + correlation_id ) VALUES ( @id, @timestamp, @method, @path, @status, @model, @requestedModel, @provider, @@ -714,7 +717,8 @@ export async function saveCallLog(entry: any) { @cacheSource, @requestType, @sourceFormat, @targetFormat, @apiKeyId, @apiKeyName, @comboName, @comboStepId, @comboExecutionKey, @errorSummary, @detailState, @artifactRelPath, @artifactSizeBytes, @artifactSha256, - @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary + @hasRequestBody, @hasResponseBody, @hasPipelineDetails, @requestSummary, + @correlationId ) ` ).run({ @@ -831,6 +835,10 @@ export async function getCallLogs(filter: any = {}) { conditions.push("(cl.api_key_name LIKE @apiKeyQ OR cl.api_key_id LIKE @apiKeyQ)"); params.apiKeyQ = `%${filter.apiKey}%`; } + if (filter.correlationId) { + conditions.push("cl.correlation_id = @correlationId"); + params.correlationId = filter.correlationId; + } if (filter.combo) { conditions.push("cl.combo_name IS NOT NULL"); } @@ -851,6 +859,7 @@ export async function getCallLogs(filter: any = {}) { cl.combo_name LIKE @searchQ OR CAST(cl.status AS TEXT) LIKE @searchQ OR cl.combo_step_id LIKE @searchQ OR cl.combo_execution_key LIKE @searchQ OR cl.error_summary LIKE @searchQ + OR cl.correlation_id LIKE @searchQ )`); params.searchQ = `%${filter.search}%`; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 7bf6371f8752..3a7aeeebe295 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -291,7 +291,7 @@ export async function refreshAndUpdateCredentials( if (!shouldAttemptRotatingRefresh(connection.provider, opts.allowRotatingRefresh)) { return { connection, refreshed: false }; } - const executor = getExecutor(connection.provider); + const executor = await getExecutor(connection.provider); const credentials = { connectionId: connection.id, accessToken: connection.accessToken, diff --git a/src/lib/usage/usageHistory.ts b/src/lib/usage/usageHistory.ts index 12edcc92be27..a71a76ef58d4 100644 --- a/src/lib/usage/usageHistory.ts +++ b/src/lib/usage/usageHistory.ts @@ -38,6 +38,7 @@ export type PendingRequestMetadata = { errorCode?: string | null; stage?: string | null; stageUpdatedAt?: number | null; + correlationId?: string | null; }; export type PendingRequestDetail = { id: string; @@ -58,6 +59,7 @@ export type PendingRequestDetail = { durationMs?: number | null; stage?: string | null; stageUpdatedAt?: number | null; + correlationId?: string | null; streamChunks?: { provider?: string[]; openai?: string[]; @@ -193,6 +195,9 @@ function normalizePendingMetadata(metadata?: PendingRequestMetadata): PendingReq if (metadata.errorCode !== undefined) { normalized.errorCode = toStringOrNull(metadata.errorCode) || null; } + if (metadata.correlationId !== undefined) { + normalized.correlationId = toStringOrNull(metadata.correlationId) || null; + } return normalized; } diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index ecb28345a958..8a6023734a51 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -176,6 +176,8 @@ export default function RequestLoggerDetail({ onCopy, onPrevious, onNext, + relatedLogs = [], + onSelectRelated, }) { // Close on Escape key useEffect(() => { @@ -202,11 +204,12 @@ export default function RequestLoggerDetail({ if (iso == null) return "\u2014"; try { const d = new Date(iso); + if (!Number.isFinite(d.getTime())) return "\u2014"; return ( d.toLocaleDateString("pt-BR") + ", " + d.toLocaleTimeString("en-US", { hour12: false }) ); } catch { - return iso; + return "\u2014"; } }; @@ -239,32 +242,17 @@ export default function RequestLoggerDetail({ : []; const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null; const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null; - const streamChunksText = (() => { - if (!debugEnabled || !detail?.pipelinePayloads?.streamChunks) return null; + const streamChunks = (() => { + if (!detail?.pipelinePayloads?.streamChunks) return null; let chunks: StreamChunks = detail.pipelinePayloads.streamChunks; - if (typeof chunks === "string") { try { - const parsed = JSON.parse(chunks); - chunks = parsed; + chunks = JSON.parse(chunks); } catch { - return chunks; + return null; } } - - if (chunks && typeof chunks === "object") { - try { - return Object.entries(chunks) - .map(([stage, arr]) => { - const joined = Array.isArray(arr) ? arr.join("") : String(arr); - return `--- ${stage} ---\n${joined}`; - }) - .join("\n\n"); - } catch { - return toPrettyJson(chunks); - } - } - + if (chunks && typeof chunks === "object") return chunks; return null; })(); const detailIssue = @@ -346,6 +334,14 @@ export default function RequestLoggerDetail({ {log.id} )} + {log.correlationId && ( + + cid: {log.correlationId} + + )}
)} + {/* Related Requests (same correlation ID) */} + {relatedLogs.length > 1 && ( +
+
+ Related Requests ({relatedLogs.length}) +
+
+ {[...relatedLogs] + .sort((a, b) => { + const aStart = new Date(a.timestamp).getTime() - (a.duration || 0); + const bStart = new Date(b.timestamp).getTime() - (b.duration || 0); + return aStart - bStart; + }) + .map((r) => { + const rStatusStyle = r.active ? null : getStatusStyle(r.status); + const isCurrent = r.id === log.id; + const startTime = new Date(new Date(r.timestamp).getTime() - (r.duration || 0)); + return ( + + ); + })} +
+
+ )} + {detailIssue && (
@@ -612,14 +680,63 @@ export default function RequestLoggerDetail({
) : ( <> - {streamChunksText && ( + {streamChunks && streamChunks.provider && ( onCopy(streamChunksText)} + title="Provider Event Stream" + json={ + Array.isArray(streamChunks.provider) + ? streamChunks.provider.join("") + : String(streamChunks.provider) + } + onCopy={() => + onCopy( + Array.isArray(streamChunks.provider) + ? streamChunks.provider.join("") + : String(streamChunks.provider) + ) + } /> )} + {streamChunks && streamChunks.client && ( + + onCopy( + Array.isArray(streamChunks.client) + ? streamChunks.client.join("") + : String(streamChunks.client) + ) + } + /> + )} + + {streamChunks && + streamChunks.openai && + !streamChunks.provider && + !streamChunks.client && ( + + onCopy( + Array.isArray(streamChunks.openai) + ? streamChunks.openai.join("") + : String(streamChunks.openai) + ) + } + /> + )} + {payloadSections.length > 0 && payloadSections.map((section) => ( { + const mapping = columnSortMap[column as keyof typeof columnSortMap]; + if (!mapping) return; + setSortBy((prev) => { + if (prev === mapping.desc) return mapping.asc; + return mapping.desc; + }); + }, []); + const getSortIndicator = useCallback( + (column: string) => { + const mapping = columnSortMap[column as keyof typeof columnSortMap]; + if (!mapping) return ""; + if (sortBy === mapping.desc) return " ↓"; + if (sortBy === mapping.asc) return " ↑"; + return ""; + }, + [sortBy] + ); const [detailData, setDetailData] = useState(null); const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); const [detailLoggingLoading, setDetailLoggingLoading] = useState(false); @@ -222,6 +251,7 @@ const RequestLoggerV2 = forwardRef { + const cidGroups = new Map(); + for (const log of sortedLogs) { + const cid = log.correlationId; + if (cid) { + if (!cidGroups.has(cid)) cidGroups.set(cid, []); + cidGroups.get(cid).push(log); + } + } + return sortedLogs.map((log) => { + const cid = log.correlationId; + if (!cid) return { ...log, isRetry: false, groupSize: 1, groupStatus: null }; + const group = cidGroups.get(cid); + if (!group || group.length <= 1) + return { ...log, isRetry: false, groupSize: 1, groupStatus: null }; + const isFirst = group[0].id === log.id; + const hasFailure = group.some((g) => g.status >= 400 || g.active); + const hasSuccess = group.some((g) => g.status >= 200 && g.status < 300); + const groupStatus = hasFailure && hasSuccess ? "healed" : hasFailure ? "failed" : null; + return { ...log, isRetry: !isFirst, groupSize: group.length, groupStatus }; + }); + }, [sortedLogs]); + // Fetch log detail from the persisted call-log endpoint. If a deep-linked // request is still being finalized, keep the modal open and poll this same // endpoint until the row appears. @@ -598,6 +657,50 @@ const RequestLoggerV2 = forwardRef { + const cid = selectedLog?.correlationId; + if (!selectedLog?.id || !cid) return; + let cancelled = false; + const interval = setInterval(async () => { + if (document.visibilityState !== "visible") return; + try { + const res = await fetch(`/api/usage/call-logs?correlationId=${encodeURIComponent(cid)}`, { + cache: "no-store", + }); + if (cancelled || !res.ok) return; + const cidLogs = await res.json(); + if (!Array.isArray(cidLogs) || cidLogs.length === 0) return; + setLogs((prev) => { + const ids = new Set(cidLogs.map((l: any) => l.id)); + let changed = false; + const merged = prev.map((l: any) => { + const updated = cidLogs.find((c: any) => c.id === l.id); + if (updated) { + changed = true; + return { ...l, ...updated }; + } + return l; + }); + for (const cl of cidLogs) { + if (!merged.some((m: any) => m.id === cl.id)) { + merged.push(cl); + changed = true; + } + } + return changed ? merged : prev; + }); + } catch { + /* poll failed — non-critical */ + } + }, 3000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [selectedLog?.id, selectedLog?.correlationId]); + const currentLogIndex = useMemo(() => { if (!selectedLog) return -1; return sortedLogsForNav.findIndex((l) => l.id === selectedLog.id); @@ -685,13 +788,14 @@ const RequestLoggerV2 = forwardRef ({ totalCount: filteredLogs.length, okCount: filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length, errorCount: filteredLogs.filter((l) => l.status >= 400).length, comboCount: logs.filter((l) => l.comboName).length, apiKeyCount: uniqueApiKeys.length, + runningCount: filteredLogs.filter((l) => l.active === true).length, }), [filteredLogs, logs, uniqueApiKeys] ); @@ -749,6 +853,20 @@ const RequestLoggerV2 = forwardRef
+ {/* Correlation ID Filter */} +
+ + tag + + setCorrelationIdFilter(e.target.value)} + className="w-full pl-9 pr-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:border-primary" + /> +
+ {/* Provider Dropdown */} setCorrelationIdFilter(e.target.value)} - className="w-full pl-9 pr-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary font-mono placeholder:text-text-muted focus:outline-none focus:border-primary" - /> - - {/* Provider Dropdown */}