fix: log and display API errors in dashboard (#40)
authorStefan Gasser <redacted>
Fri, 16 Jan 2026 22:30:36 +0000 (23:30 +0100)
committerGitHub <redacted>
Fri, 16 Jan 2026 22:30:36 +0000 (23:30 +0100)
Fixes #35

- Add LLMError class to preserve upstream status code and body
- Add status_code and error_message columns to request logs
- Add Status column to dashboard with OK/error badges
- Pass through upstream errors (429, 401, etc.) with original status
- Return OpenAI-compatible JSON format for all error responses
- Set X-PasteGuard headers consistently via Hono context

src/routes/proxy.ts
src/services/llm-client.ts
src/services/logger.ts
src/views/dashboard/page.tsx

index 7e6407be2149cef3b09ebcc90cc3f4c867bcef20..4700d2bda18f7f41c14444b741799417a6364cc1 100644 (file)
@@ -1,7 +1,6 @@
 import { zValidator } from "@hono/zod-validator";
 import type { Context } from "hono";
 import { Hono } from "hono";
-import { HTTPException } from "hono/http-exception";
 import { proxy } from "hono/proxy";
 import { z } from "zod";
 import { getConfig, type MaskingConfig } from "../config";
@@ -12,11 +11,12 @@ import {
 } from "../secrets/detect";
 import { type RedactionContext, redactSecrets, unredactResponse } from "../secrets/redact";
 import { getRouter, type MaskDecision, type RoutingDecision } from "../services/decision";
-import type {
-  ChatCompletionRequest,
-  ChatCompletionResponse,
-  ChatMessage,
-  LLMResult,
+import {
+  type ChatCompletionRequest,
+  type ChatCompletionResponse,
+  type ChatMessage,
+  LLMError,
+  type LLMResult,
 } from "../services/llm-client";
 import { logRequest, type RequestLogData } from "../services/logger";
 import { unmaskResponse } from "../services/masking";
@@ -48,6 +48,41 @@ function isMaskDecision(decision: RoutingDecision): decision is MaskDecision {
   return decision.mode === "mask";
 }
 
+/**
+ * Create log data for error responses
+ */
+function createErrorLogData(
+  body: ChatCompletionRequest,
+  startTime: number,
+  statusCode: number,
+  errorMessage: string,
+  decision?: RoutingDecision,
+  secretsResult?: SecretsDetectionResult,
+  maskedContent?: string,
+): RequestLogData {
+  const config = getConfig();
+  return {
+    timestamp: new Date().toISOString(),
+    mode: decision?.mode ?? config.mode,
+    provider: decision?.provider ?? "upstream",
+    model: body.model || "unknown",
+    piiDetected: decision?.piiResult.hasPII ?? false,
+    entities: decision
+      ? [...new Set(decision.piiResult.newEntities.map((e) => e.entity_type))]
+      : [],
+    latencyMs: Date.now() - startTime,
+    scanTimeMs: decision?.piiResult.scanTimeMs ?? 0,
+    language: decision?.piiResult.language ?? config.pii_detection.fallback_language,
+    languageFallback: decision?.piiResult.languageFallback ?? false,
+    detectedLanguage: decision?.piiResult.detectedLanguage,
+    maskedContent,
+    secretsDetected: secretsResult?.detected,
+    secretsTypes: secretsResult?.matches.map((m) => m.type),
+    statusCode,
+    errorMessage,
+  };
+}
+
 proxyRoutes.get("/models", (c) => {
   const { upstream } = getRouter().getProvidersInfo();
 
@@ -153,7 +188,23 @@ proxyRoutes.post(
       decision = await router.decide(body.messages, secretsResult);
     } catch (error) {
       console.error("PII detection error:", error);
-      throw new HTTPException(503, { message: "PII detection service unavailable" });
+      const errorMessage = "PII detection service unavailable";
+      logRequest(
+        createErrorLogData(body, startTime, 503, errorMessage, undefined, secretsResult),
+        c.req.header("User-Agent") || null,
+      );
+
+      return c.json(
+        {
+          error: {
+            message: errorMessage,
+            type: "server_error",
+            param: null,
+            code: "service_unavailable",
+          },
+        },
+        503,
+      );
     }
 
     return handleCompletion(
@@ -317,14 +368,31 @@ async function handleCompletion(
     maskedContent = formatMessagesForLog(decision.maskedMessages);
   }
 
-  try {
-    const result = await client.chatCompletion(request, authHeader);
+  // Determine secrets state
+  const secretsDetected = secretsResult?.detected ?? false;
+  const secretsTypes = secretsResult?.matches.map((m) => m.type) ?? [];
 
-    // Determine secrets state from passed result
-    const secretsDetected = secretsResult?.detected ?? false;
-    const secretsTypes = secretsResult?.matches.map((m) => m.type) ?? [];
+  // Set response headers (included automatically by c.json/c.body)
+  c.header("X-PasteGuard-Mode", decision.mode);
+  c.header("X-PasteGuard-Provider", decision.provider);
+  c.header("X-PasteGuard-PII-Detected", decision.piiResult.hasPII.toString());
+  c.header("X-PasteGuard-Language", decision.piiResult.language);
+  if (decision.piiResult.languageFallback) {
+    c.header("X-PasteGuard-Language-Fallback", "true");
+  }
+  if (decision.mode === "mask") {
+    c.header("X-PasteGuard-PII-Masked", decision.piiResult.hasPII.toString());
+  }
+  if (secretsDetected && secretsTypes.length > 0) {
+    c.header("X-PasteGuard-Secrets-Detected", "true");
+    c.header("X-PasteGuard-Secrets-Types", secretsTypes.join(","));
+  }
+  if (secretsRedacted) {
+    c.header("X-PasteGuard-Secrets-Redacted", "true");
+  }
 
-    setPasteGuardHeaders(c, decision, secretsDetected, secretsTypes, secretsRedacted);
+  try {
+    const result = await client.chatCompletion(request, authHeader);
 
     if (result.isStreaming) {
       return handleStreamingResponse(
@@ -353,37 +421,56 @@ async function handleCompletion(
     );
   } catch (error) {
     console.error("LLM request error:", error);
+
+    // Pass through upstream LLM errors with original status code
+    if (error instanceof LLMError) {
+      logRequest(
+        createErrorLogData(
+          body,
+          startTime,
+          error.status,
+          error.message,
+          decision,
+          secretsResult,
+          maskedContent,
+        ),
+        c.req.header("User-Agent") || null,
+      );
+
+      // Pass through upstream error - must use Response for dynamic status code
+      return new Response(error.body, {
+        status: error.status,
+        headers: c.res.headers,
+      });
+    }
+
+    // For other errors (network, timeout, etc.), return 502 in OpenAI-compatible format
     const message = error instanceof Error ? error.message : "Unknown error";
-    throw new HTTPException(502, { message: `LLM provider error: ${message}` });
-  }
-}
+    const errorMessage = `Provider error: ${message}`;
+    logRequest(
+      createErrorLogData(
+        body,
+        startTime,
+        502,
+        errorMessage,
+        decision,
+        secretsResult,
+        maskedContent,
+      ),
+      c.req.header("User-Agent") || null,
+    );
 
-/**
- * Set X-PasteGuard response headers
- */
-function setPasteGuardHeaders(
-  c: Context,
-  decision: RoutingDecision,
-  secretsDetected?: boolean,
-  secretsTypes?: string[],
-  secretsRedacted?: boolean,
-) {
-  c.header("X-PasteGuard-Mode", decision.mode);
-  c.header("X-PasteGuard-Provider", decision.provider);
-  c.header("X-PasteGuard-PII-Detected", decision.piiResult.hasPII.toString());
-  c.header("X-PasteGuard-Language", decision.piiResult.language);
-  if (decision.piiResult.languageFallback) {
-    c.header("X-PasteGuard-Language-Fallback", "true");
-  }
-  if (decision.mode === "mask") {
-    c.header("X-PasteGuard-PII-Masked", decision.piiResult.hasPII.toString());
-  }
-  if (secretsDetected && secretsTypes && secretsTypes.length > 0) {
-    c.header("X-PasteGuard-Secrets-Detected", "true");
-    c.header("X-PasteGuard-Secrets-Types", secretsTypes.join(","));
-  }
-  if (secretsRedacted) {
-    c.header("X-PasteGuard-Secrets-Redacted", "true");
+    return c.json(
+      {
+        error: {
+          message: errorMessage,
+          type: "server_error",
+          param: null,
+          code: "upstream_error",
+        },
+      },
+      502,
+    );
   }
 }
 
index b986a14da04b375f86ea1fe09562e7d2da5291d7..4120addb4e38300d83ac701fa45a9bc7b1103046 100644 (file)
@@ -58,6 +58,20 @@ export type LLMResult =
       provider: "upstream" | "local";
     };
 
+/**
+ * Error from upstream LLM provider with original status code and response
+ */
+export class LLMError extends Error {
+  constructor(
+    public readonly status: number,
+    public readonly statusText: string,
+    public readonly body: string,
+  ) {
+    super(`API error: ${status} ${statusText}`);
+    this.name = "LLMError";
+  }
+}
+
 /**
  * LLM Client for OpenAI-compatible APIs (OpenAI, Ollama, etc.)
  */
@@ -134,7 +148,7 @@ export class LLMClient {
 
     if (!response.ok) {
       const errorText = await response.text();
-      throw new Error(`LLM API error: ${response.status} ${response.statusText} - ${errorText}`);
+      throw new LLMError(response.status, response.statusText, errorText);
     }
 
     if (isStreaming) {
index 7cf66b66f062100c52f9870130a2a21e93a37712..0d86880b5ad8a90b88acd349dcd8aba2be32975c 100644 (file)
@@ -21,6 +21,8 @@ export interface RequestLog {
   masked_content: string | null;
   secrets_detected: number | null;
   secrets_types: string | null;
+  status_code: number | null;
+  error_message: string | null;
 }
 
 /**
@@ -84,7 +86,7 @@ export class Logger {
       )
     `);
 
-    // Migrate existing databases: add secrets columns if missing
+    // Migrate existing databases: add missing columns
     const columns = this.db.prepare("PRAGMA table_info(request_logs)").all() as Array<{
       name: string;
     }>;
@@ -92,6 +94,10 @@ export class Logger {
       this.db.run("ALTER TABLE request_logs ADD COLUMN secrets_detected INTEGER");
       this.db.run("ALTER TABLE request_logs ADD COLUMN secrets_types TEXT");
     }
+    if (!columns.find((c) => c.name === "status_code")) {
+      this.db.run("ALTER TABLE request_logs ADD COLUMN status_code INTEGER");
+      this.db.run("ALTER TABLE request_logs ADD COLUMN error_message TEXT");
+    }
 
     // Create indexes for performance
     this.db.run(`
@@ -108,9 +114,9 @@ export class Logger {
   log(entry: Omit<RequestLog, "id">): void {
     const stmt = this.db.prepare(`
       INSERT INTO request_logs
-        (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content, secrets_detected, secrets_types)
+        (timestamp, mode, provider, model, pii_detected, entities, latency_ms, scan_time_ms, prompt_tokens, completion_tokens, user_agent, language, language_fallback, detected_language, masked_content, secrets_detected, secrets_types, status_code, error_message)
       VALUES
-        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     `);
 
     stmt.run(
@@ -131,6 +137,8 @@ export class Logger {
       entry.masked_content,
       entry.secrets_detected ?? null,
       entry.secrets_types ?? null,
+      entry.status_code ?? null,
+      entry.error_message ?? null,
     );
   }
 
@@ -288,6 +296,8 @@ export interface RequestLogData {
   maskedContent?: string;
   secretsDetected?: boolean;
   secretsTypes?: string[];
+  statusCode?: number;
+  errorMessage?: string;
 }
 
 export function logRequest(data: RequestLogData, userAgent: string | null): void {
@@ -321,6 +331,8 @@ export function logRequest(data: RequestLogData, userAgent: string | null): void
       masked_content: shouldLogContent ? (data.maskedContent ?? null) : null,
       secrets_detected: data.secretsDetected !== undefined ? (data.secretsDetected ? 1 : 0) : null,
       secrets_types: shouldLogSecretTypes ? data.secretsTypes!.join(",") : null,
+      status_code: data.statusCode ?? null,
+      error_message: data.errorMessage ?? null,
     });
   } catch (error) {
     console.error("Failed to log request:", error);
index d1eb1adc2879d46e7e702cbd007ddc332cbbc277..931a444e70a8a4369bfba605011e0b724f5553b8 100644 (file)
@@ -373,6 +373,9 @@ const LogsSection: FC = () => (
                                                        <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
                                                                Time
                                                        </th>
+                                                       <th class="bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
+                                                               Status
+                                                       </th>
                                                        <th class="route-only bg-elevated font-mono text-[0.65rem] font-medium uppercase tracking-widest text-text-muted px-4 py-3.5 text-left border-b border-border sticky top-0">
                                                                Provider
                                                        </th>
@@ -395,7 +398,7 @@ const LogsSection: FC = () => (
                                        </thead>
                                        <tbody id="logs-body">
                                                <tr>
-                                                       <td colSpan={7}>
+                                                       <td colSpan={8}>
                                                                <div class="flex flex-col justify-center items-center p-10 gap-3">
                                                                        <div class="loader-bars">
                                                                                <div class="loader-bar" />
@@ -554,7 +557,7 @@ async function fetchLogs() {
     const tbody = document.getElementById('logs-body');
 
     if (data.logs.length === 0) {
-      tbody.innerHTML = '<tr><td colspan="7"><div class="text-center py-10 text-text-muted"><div class="text-2xl mb-3 opacity-40">📋</div><div class="text-sm">No requests yet</div></div></td></tr>';
+      tbody.innerHTML = '<tr><td colspan="8"><div class="text-center py-10 text-text-muted"><div class="text-2xl mb-3 opacity-40">📋</div><div class="text-sm">No requests yet</div></div></td></tr>';
       return;
     }
 
@@ -563,6 +566,7 @@ async function fetchLogs() {
       const entities = log.entities ? log.entities.split(',').filter(e => e.trim()) : [];
       const secretsTypes = log.secrets_types ? log.secrets_types.split(',').filter(s => s.trim()) : [];
       const secretsDetected = log.secrets_detected === 1;
+      const isError = log.status_code && log.status_code >= 400;
       const lang = log.language || 'en';
       const detectedLang = log.detected_language;
 
@@ -575,12 +579,17 @@ async function fetchLogs() {
       const logId = log.id || index;
       const isExpanded = expandedRowId === logId;
 
+      const statusBadge = isError
+        ? '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide bg-error/10 text-error">' + log.status_code + '</span>'
+        : '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide bg-success/10 text-success">OK</span>';
+
       const mainRow =
         '<tr id="log-' + logId + '" class="cursor-pointer transition-colors hover:bg-elevated ' + (isExpanded ? 'log-row-expanded bg-elevated' : '') + '" onclick="toggleRow(' + logId + ')">' +
           '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
             '<span id="arrow-' + logId + '" class="arrow-icon inline-flex items-center justify-center w-[18px] h-[18px] mr-2 rounded-sm bg-elevated text-text-muted text-[0.65rem] transition-transform ' + (isExpanded ? 'rotate-90 bg-accent/10 text-accent' : '') + '">â–¶</span>' +
             '<span class="font-mono text-[0.7rem] text-text-secondary">' + time + '</span>' +
           '</td>' +
+          '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + statusBadge + '</td>' +
           '<td class="route-only text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
             '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide ' +
               (log.provider === 'upstream' ? 'bg-info/10 text-info' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
@@ -600,12 +609,14 @@ async function fetchLogs() {
           '<td class="font-mono text-[0.7rem] text-teal px-4 py-3 border-b border-border-subtle align-middle">' + log.scan_time_ms + 'ms</td>' +
         '</tr>';
 
+      const detailContent = isError && log.error_message
+        ? '<div class="font-mono text-xs leading-relaxed text-error bg-error/10 border border-error/20 rounded-lg p-3 whitespace-pre-wrap break-words">' + log.error_message.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>'
+        : '<div class="font-mono text-xs leading-relaxed text-text-secondary bg-surface border border-border-subtle rounded-lg p-3 whitespace-pre-wrap break-words">' + formatMaskedPreview(log.masked_content, entities) + '</div>';
+
       const detailRow =
         '<tr id="detail-' + logId + '" class="' + (isExpanded ? 'detail-row-visible' : 'hidden') + '">' +
-          '<td colspan="7" class="p-0 bg-detail border-b border-border-subtle">' +
-            '<div class="p-4 px-5 animate-slide-down">' +
-              '<div class="font-mono text-xs leading-relaxed text-text-secondary bg-surface border border-border-subtle rounded-lg p-3 whitespace-pre-wrap break-words">' + formatMaskedPreview(log.masked_content, entities) + '</div>' +
-            '</div>' +
+          '<td colspan="8" class="p-0 bg-detail border-b border-border-subtle">' +
+            '<div class="p-4 px-5 animate-slide-down">' + detailContent + '</div>' +
           '</td>' +
         '</tr>';
 
git clone https://git.99rst.org/PROJECT