feat: add frontend remote log uploader for Docker visibility
Trigger auto deployment for soc-langgraph / build-and-deploy (push) Failing after 14s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 49s

- POST /api/client-log endpoint in http.ts (Hono, no auth, 204 response)
  prints batched log entries to stdout so `docker compose logs` shows them
- New src/utils/remote-log.ts: remoteLog/remoteWarn batch to backend every 500ms
- Replace all bracket-tagged diagnostic console.log/warn calls in main.tsx and
  ToolCallStatus.tsx with remoteLog/remoteWarn (tags: thread-ui, ui-debug,
  render-msg, orphan-match, orphan-name-match, orphan-result, streaming-orphan,
  tool-ui-match)
- Add /api/client-log proxy entry to vite.config.ts so dev server forwards
  the request to the LangGraph backend (port 2024)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-13 17:33:43 +08:00
co-authored by Claude Sonnet 4.6
parent 2218bbcfef
commit 157a3c6a83
5 changed files with 121 additions and 27 deletions
+23
View File
@@ -68,6 +68,29 @@ function isSensitiveHeader(key: string): boolean {
export const app = new Hono();
// ── Client log endpoint ──────────────────────────────────────────────────────
// Receives batched frontend diagnostic logs and prints them to stdout so they
// are visible via `docker compose logs`. No authentication required.
app.post("/api/client-log", async (c) => {
try {
const body = await c.req.json();
const entries = Array.isArray(body) ? body : [body];
for (const entry of entries) {
const { level = "log", tag = "client", data, timestamp } = entry as {
level?: string;
tag?: string;
data?: unknown;
timestamp?: string;
};
const ts = timestamp ?? new Date().toISOString();
console.log(`[client-log][${tag}][${level}][${ts}] ${JSON.stringify(data)}`);
}
} catch {
// ignore malformed payloads
}
return new Response(null, { status: 204 });
});
app.use("*", async (c, next) => {
// Collect header names to delete (can't mutate while iterating)
const toDelete: string[] = [];
+2 -1
View File
@@ -1,6 +1,7 @@
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown, AlertCircle, CornerDownRight } from "lucide-react";
import { useState, useCallback, Component, type ReactNode } from "react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { remoteLog } from "@/utils/remote-log";
// ErrorBoundary to prevent Gen-UI card crashes from taking down the whole app
class CardErrorBoundary extends Component<
@@ -353,7 +354,7 @@ export default function ToolCallStatus({
matchCounters[uiName] = (matchCounters[uiName] ?? 0) + 1;
}
console.log('[tool-ui-match]', {
remoteLog('tool-ui-match', {
toolName: tc.name,
uiName: tc.name ? UI_NAME_MAP[tc.name] : undefined,
uiItemsCount: uiItems.length,
+41 -26
View File
@@ -20,6 +20,7 @@ import FileUploadButton, { type SelectedFile } from "@/components/FileUploadButt
import FileAttachmentPreview from "@/components/FileAttachmentPreview.tsx";
import ToolCallStatus from "@/components/ToolCallStatus.tsx";
import { ExecutionLogPanel } from "@/components/ExecutionLogPanel.tsx";
import { remoteLog, remoteWarn } from "@/utils/remote-log";
const LANGGRAPH_URL =
import.meta.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024";
@@ -235,7 +236,7 @@ function App() {
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ui = (thread.values as any)?.ui ?? [];
console.log('[thread-ui]', {
remoteLog('thread-ui', {
count: ui.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items: ui.map((u: any) => ({ name: u.name, msgId: u.metadata?.message_id?.slice(0,8), cardId: u.props?.card_id })),
@@ -772,15 +773,15 @@ function App() {
const cardId = ui.props?.card_id as string | undefined;
if (cardId) {
const matched = msgToolCalls.some((tc) => tc.id && cardId.includes(tc.id));
console.log('[orphan-match]', { cardId, tcIds: msgToolCalls.map(tc => tc.id), matched });
remoteWarn('orphan-match', { cardId, tcIds: msgToolCalls.map(tc => tc.id), matched });
return matched;
}
// Fallback: match by UI component name
const nameMatched = expectedUiNames.includes(ui.name);
console.log('[orphan-name-match]', { uiName: ui.name, expectedUiNames, nameMatched });
remoteWarn('orphan-name-match', { uiName: ui.name, expectedUiNames, nameMatched });
return nameMatched;
});
console.log('[orphan-result]', { orphanCount: orphans.length, matchedCount: matchedUi.length, msgId: message.id?.slice(0,12) });
remoteWarn('orphan-result', { orphanCount: orphans.length, matchedCount: matchedUi.length, msgId: message.id?.slice(0,12) });
} else if (idx === activeMessages.length - 1) {
// Last AI message without tool_calls: adopt all remaining orphans
// (these may include next-actions, chart-result, etc.)
@@ -803,7 +804,7 @@ function App() {
matchedUi = orphans.filter((ui) => !claimedByToolMsgs.has(ui.id));
}
}
console.log('[ui-debug]', {
remoteLog('ui-debug', {
messageId: message.id,
messageType: message.type,
allUiCount: allUi.length,
@@ -837,6 +838,16 @@ function App() {
return sa - sb;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasToolCalls_diag = ((message as any).tool_calls ?? []).length > 0;
const hasThinking_diag = Array.isArray(message.content) && (message.content as any[]).some((c) => c.type === "thinking");
const plainTextLen_diag = typeof message.content === "string"
? message.content.length
: Array.isArray(message.content)
? (message.content as any[]).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").length
: 0;
remoteWarn('render-msg', { idx, msgId: message.id?.slice(0,12), type: message.type, hasToolCalls: hasToolCalls_diag, hasThinking: hasThinking_diag, plainTextLen: plainTextLen_diag, matchedUiCount: uiItems.length, uiNames: uiItems.map(u => u.name) });
if (message.type === "human") {
const humanText = typeof message.content === "string"
? message.content
@@ -1039,26 +1050,29 @@ function App() {
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
deduplicateUiItems(
(thread.values?.ui ?? []).filter((ui: UIMsgLocal) => {
// Check if attached by message_id
const attachedByMsgId = activeMessages.some(
(m) => m.id === ui.metadata?.message_id,
);
if (attachedByMsgId) return false;
// Check if attached by card_id matching any tool_call_id (orphan logic)
const cardId = ui.props?.card_id as string | undefined;
if (cardId) {
const claimedByToolCall = activeMessages.some((m) => {
const tcs: { id?: string }[] = (m as any).tool_calls ?? [];
return tcs.some((tc) => tc.id && cardId.includes(tc.id));
});
if (claimedByToolCall) return false;
}
return true;
})
)
.map((ui: UIMsgLocal) => (
(() => {
const allStreamUi: UIMsgLocal[] = thread.values?.ui ?? [];
const orphans = deduplicateUiItems(
allStreamUi.filter((ui: UIMsgLocal) => {
// Check if attached by message_id
const attachedByMsgId = activeMessages.some(
(m) => m.id === ui.metadata?.message_id,
);
if (attachedByMsgId) return false;
// Check if attached by card_id matching any tool_call_id (orphan logic)
const cardId = ui.props?.card_id as string | undefined;
if (cardId) {
const claimedByToolCall = activeMessages.some((m) => {
const tcs: { id?: string }[] = (m as any).tool_calls ?? [];
return tcs.some((tc) => tc.id && cardId.includes(tc.id));
});
if (claimedByToolCall) return false;
}
return true;
})
);
remoteWarn('streaming-orphan', { totalUi: allStreamUi.length, orphanCount: orphans.length, orphanNames: orphans.map(u => u.name + ':' + String(u.props?.card_id ?? '').slice(0,20)) });
return orphans.map((ui: UIMsgLocal) => (
<div key={ui.id} className="card-enter">
<CardErrorBoundary>
<LoadExternalComponent
@@ -1070,7 +1084,8 @@ function App() {
/>
</CardErrorBoundary>
</div>
))}
));
})()}
{/* ── Thread error banner ── */}
{!!thread.error && !thread.isLoading && (
+51
View File
@@ -0,0 +1,51 @@
/**
* Remote logger: sends frontend console logs to backend for Docker visibility.
* Batches logs and sends them every 500ms to avoid flooding.
*/
const LOG_ENDPOINT = "/api/client-log";
const BATCH_INTERVAL = 500;
interface LogEntry {
level: string;
tag: string;
data: unknown;
timestamp: string;
}
let buffer: LogEntry[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
function flush() {
if (buffer.length === 0) return;
const batch = buffer;
buffer = [];
// Fire and forget
fetch(LOG_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batch),
}).catch(() => {/* silent */});
}
function enqueue(level: string, tag: string, data: unknown) {
buffer.push({ level, tag, data, timestamp: new Date().toISOString() });
if (!timer) {
timer = setTimeout(() => {
timer = null;
flush();
}, BATCH_INTERVAL);
}
}
export function remoteLog(tag: string, ...args: unknown[]) {
const data = args.length === 1 ? args[0] : args;
console.log(`[${tag}]`, data); // 保留本地 console 输出
enqueue("log", tag, data);
}
export function remoteWarn(tag: string, ...args: unknown[]) {
const data = args.length === 1 ? args[0] : args;
console.warn(`[${tag}]`, data);
enqueue("warn", tag, data);
}
+4
View File
@@ -75,6 +75,10 @@ export default defineConfig({
target: process.env.LANGGRAPH_BACKEND_URL ?? process.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024",
changeOrigin: true,
},
"/api/client-log": {
target: process.env.LANGGRAPH_BACKEND_URL ?? process.env.VITE_LANGGRAPH_URL ?? "http://localhost:2024",
changeOrigin: true,
},
},
},
});