]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Track browser-extension requests separately in the dashboard (#107)
authorStefan Gasser <redacted>
Tue, 23 Jun 2026 07:31:37 +0000 (09:31 +0200)
committerGitHub <redacted>
Tue, 23 Jun 2026 07:31:37 +0000 (09:31 +0200)
Add a `source` column to request logs so `/api/mask` calls sent with the
`X-PasteGuard-Source: browser-extension` header are counted and badged
separately from direct API calls and provider-proxied requests.

- Backfill `source` from `provider` for existing rows via migration
- Split stats into api_requests vs browser_extension_requests
- Replace the route-only Provider column with a unified Source column
- Add an Extension stat card

docs/api-reference/dashboard-api.mdx
docs/api-reference/mask.mdx
src/routes/api.test.ts
src/routes/api.ts
src/routes/utils.ts
src/services/logger.test.ts [new file with mode: 0644]
src/services/logger.ts
src/views/dashboard/page.tsx

index 29497049ddc17c9424bfc23a4106233c1467491e..5c06c1ba0e2c2f7dac8c80f6ae526507ddc1650b 100644 (file)
@@ -36,6 +36,7 @@ curl "http://localhost:3000/dashboard/api/logs?limit=100&offset=0"
       "timestamp": "2026-01-15T10:30:00Z",
       "mode": "mask",
       "provider": "openai",
+      "source": "openai",
       "model": "gpt-5.2",
       "pii_detected": true,
       "entities": "[\"EMAIL_ADDRESS\",\"PERSON\"]",
@@ -83,7 +84,9 @@ curl http://localhost:3000/dashboard/api/stats
   "total_requests": 1500,
   "pii_requests": 342,
   "pii_percentage": 22.8,
-  "openai_requests": 1200,
+  "proxy_requests": 1000,
+  "api_requests": 100,
+  "browser_extension_requests": 100,
   "local_requests": 300,
   "avg_scan_time_ms": 45,
   "total_tokens": 125000,
index 0cac87f9ca8add340bd9696ef32495512dfc22e6..53598a08eafdc2a58933adb688c47830493e8c7f 100644 (file)
@@ -26,6 +26,12 @@ curl -X POST http://localhost:3000/api/mask \
   }'
 ```
 
+Browser extensions can identify themselves in dashboard logs by sending:
+
+```http
+X-PasteGuard-Source: browser-extension
+```
+
 ## Parameters
 
 | Parameter | Type | Required | Description |
index a0747faeca18c8fa8c420141bee8d5e40937df8b..207556c69fc1343c1201b224f13f3337398f5f3d 100644 (file)
@@ -24,6 +24,9 @@ mock.module("../pii/detect", () => ({
 // Mock the logger to avoid database operations
 mock.module("../services/logger", () => ({
   logRequest: mock(() => {}),
+  normalizeRequestSource: mock((provider: string, sourceHeader?: string | null) =>
+    provider === "api" && sourceHeader === "browser-extension" ? "browser_extension" : provider,
+  ),
 }));
 
 // Enable every secret type so the ordering test doesn't depend on the ambient config.
index 0bd26d12c400295d02b9fce41ada641fd5e69cdb..5bff1faa8f45db93d53c434bea4c733f58bbac51 100644 (file)
@@ -19,7 +19,7 @@ import { mask as maskPII } from "../pii/mask";
 import { detectSecrets } from "../secrets/detect";
 import { maskSecrets } from "../secrets/mask";
 import { getLanguageDetector, type SupportedLanguage } from "../services/language-detector";
-import { logRequest } from "../services/logger";
+import { logRequest, normalizeRequestSource } from "../services/logger";
 import { createLogData } from "./utils";
 
 export const apiRoutes = new Hono();
@@ -83,6 +83,7 @@ apiRoutes.post("/mask", async (c) => {
   const startTime = Date.now();
   const config = getConfig();
   const userAgent = c.req.header("user-agent") || null;
+  const source = normalizeRequestSource("api", c.req.header("x-pasteguard-source"));
 
   // Parse and validate request
   const body = await c.req.json().catch(() => null);
@@ -170,6 +171,7 @@ apiRoutes.post("/mask", async (c) => {
       logRequest(
         createLogData({
           provider: "api",
+          source,
           model: "mask",
           startTime,
           pii: {
@@ -235,6 +237,7 @@ apiRoutes.post("/mask", async (c) => {
       logRequest(
         createLogData({
           provider: "api",
+          source,
           model: "mask",
           startTime,
           pii: { hasPII: false, entityTypes: [], language, languageFallback, scanTimeMs: 0 },
@@ -261,6 +264,7 @@ apiRoutes.post("/mask", async (c) => {
   logRequest(
     createLogData({
       provider: "api",
+      source,
       model: "mask",
       startTime,
       pii: {
index 11ae6dee239320f8e03193cbec2468bf513c8665..b5cfd66183ae54874d53207c4583d6fc0012844b 100644 (file)
@@ -8,7 +8,7 @@
 import type { Context } from "hono";
 import { getConfig } from "../config";
 import { ProviderError } from "../providers/errors";
-import type { RequestLogData } from "../services/logger";
+import type { RequestLogData, RequestSource } from "../services/logger";
 import { logRequest } from "../services/logger";
 import type { PIIDetectResult } from "../services/pii";
 import type { SecretsProcessResult } from "../services/secrets";
@@ -208,6 +208,7 @@ export function toSecretsHeaderData<T>(
 
 export interface CreateLogDataOptions {
   provider: "openai" | "anthropic" | "codex" | "local" | "api";
+  source?: RequestSource;
   model: string;
   startTime: number;
   pii?: PIILogData;
@@ -229,6 +230,7 @@ export function createLogData(options: CreateLogDataOptions): RequestLogData {
     timestamp: new Date().toISOString(),
     mode: config.mode,
     provider,
+    source: options.source,
     model: model || "unknown",
     piiDetected: pii?.hasPII ?? false,
     entities: pii?.entityTypes ?? [],
diff --git a/src/services/logger.test.ts b/src/services/logger.test.ts
new file mode 100644 (file)
index 0000000..e722343
--- /dev/null
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "bun:test";
+import { normalizeRequestSource } from "./logger";
+
+describe("normalizeRequestSource", () => {
+  test("uses provider as source for provider-backed requests", () => {
+    expect(normalizeRequestSource("openai")).toBe("openai");
+    expect(normalizeRequestSource("anthropic")).toBe("anthropic");
+    expect(normalizeRequestSource("codex")).toBe("codex");
+    expect(normalizeRequestSource("local")).toBe("local");
+  });
+
+  test("keeps regular API requests as api", () => {
+    expect(normalizeRequestSource("api")).toBe("api");
+  });
+
+  test("marks browser extension API requests", () => {
+    expect(normalizeRequestSource("api", "browser-extension")).toBe("browser_extension");
+  });
+});
index 0a41130988f4df002a7252c1892716d7a7fa794e..7727f2510b948eb5ae3730ec0a53b763016e15fa 100644 (file)
@@ -3,11 +3,15 @@ import { mkdirSync } from "node:fs";
 import { getConfig } from "../config";
 import { shouldLogMaskedContent } from "./log-content";
 
+export type RequestProvider = "openai" | "anthropic" | "codex" | "local" | "api";
+export type RequestSource = RequestProvider | "browser_extension";
+
 export interface RequestLog {
   id?: number;
   timestamp: string;
   mode: "route" | "mask";
-  provider: "openai" | "anthropic" | "codex" | "local" | "api";
+  provider: RequestProvider;
+  source: RequestSource;
   model: string;
   pii_detected: boolean;
   entities: string;
@@ -36,11 +40,27 @@ export interface Stats {
   proxy_requests: number;
   local_requests: number;
   api_requests: number;
+  browser_extension_requests: number;
   avg_scan_time_ms: number;
   total_tokens: number;
   requests_last_hour: number;
 }
 
+export function normalizeRequestSource(
+  provider: RequestProvider,
+  sourceHeader?: string | null,
+): RequestSource {
+  if (provider !== "api") {
+    return provider;
+  }
+
+  if (sourceHeader?.trim().toLowerCase() === "browser-extension") {
+    return "browser_extension";
+  }
+
+  return "api";
+}
+
 /**
  * SQLite-based logger for request tracking
  */
@@ -70,6 +90,7 @@ export class Logger {
         timestamp TEXT NOT NULL,
         mode TEXT NOT NULL DEFAULT 'route',
         provider TEXT NOT NULL,
+        source TEXT,
         model TEXT NOT NULL,
         pii_detected INTEGER NOT NULL DEFAULT 0,
         entities TEXT,
@@ -100,6 +121,10 @@ export class Logger {
       this.db.run("ALTER TABLE request_logs ADD COLUMN status_code INTEGER");
       this.db.run("ALTER TABLE request_logs ADD COLUMN error_message TEXT");
     }
+    if (!columns.find((c) => c.name === "source")) {
+      this.db.run("ALTER TABLE request_logs ADD COLUMN source TEXT");
+      this.db.run("UPDATE request_logs SET source = provider WHERE source IS NULL");
+    }
 
     // Create indexes for performance
     this.db.run(`
@@ -116,15 +141,16 @@ 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, status_code, error_message)
+        (timestamp, mode, provider, source, 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(
       entry.timestamp,
       entry.mode,
       entry.provider,
+      entry.source,
       entry.model,
       entry.pii_detected ? 1 : 0,
       entry.entities,
@@ -154,7 +180,10 @@ export class Logger {
       LIMIT ? OFFSET ?
     `);
 
-    return stmt.all(limit, offset) as RequestLog[];
+    return (stmt.all(limit, offset) as RequestLog[]).map((log) => ({
+      ...log,
+      source: log.source || normalizeRequestSource(log.provider),
+    }));
   }
 
   /**
@@ -181,7 +210,12 @@ export class Logger {
       .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'local'`)
       .get() as { count: number };
     const apiResult = this.db
-      .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE provider = 'api'`)
+      .prepare(
+        `SELECT COUNT(*) as count FROM request_logs WHERE provider = 'api' AND source != 'browser_extension'`,
+      )
+      .get() as { count: number };
+    const browserExtensionResult = this.db
+      .prepare(`SELECT COUNT(*) as count FROM request_logs WHERE source = 'browser_extension'`)
       .get() as { count: number };
 
     // Average scan time
@@ -216,6 +250,7 @@ export class Logger {
       proxy_requests: proxyResult.count,
       local_requests: localResult.count,
       api_requests: apiResult.count,
+      browser_extension_requests: browserExtensionResult.count,
       avg_scan_time_ms: Math.round(scanTimeResult.avg || 0),
       total_tokens: tokensResult.total,
       requests_last_hour: hourResult.count,
@@ -290,7 +325,8 @@ export function getLogger(): Logger {
 export interface RequestLogData {
   timestamp: string;
   mode: "route" | "mask";
-  provider: "openai" | "anthropic" | "codex" | "local" | "api";
+  provider: RequestProvider;
+  source?: RequestSource;
   model: string;
   piiDetected: boolean;
   entities: string[];
@@ -329,6 +365,7 @@ export function logRequest(data: RequestLogData, userAgent: string | null): void
       timestamp: data.timestamp,
       mode: data.mode,
       provider: data.provider,
+      source: data.source ?? normalizeRequestSource(data.provider),
       model: data.model,
       pii_detected: data.piiDetected,
       entities: data.entities.join(","),
index f1a33bf485aba869565ed142466e8e177b361d04..0ccb2a0803a4b1cb8d80d7dd20b7ad3451d14843 100644 (file)
@@ -96,6 +96,7 @@ const DashboardPage: FC = () => {
                                                        .bg-success { background: var(--color-success); }
                                                        .bg-success\\/10 { background: rgba(22, 163, 74, 0.1); }
                                                        .bg-teal { background: var(--color-teal); }
+                                                       .bg-teal\\/10 { background: rgba(13, 148, 136, 0.1); }
                                                        .bg-anthropic { background: var(--color-anthropic); }
                                                        .bg-anthropic\\/10 { background: rgba(217, 119, 6, 0.1); }
                                                        .bg-error { background: var(--color-error); }
@@ -173,8 +174,6 @@ const DashboardPage: FC = () => {
                                                        /* Route mode visibility */
                                                        .route-only { display: none; }
                                                        [data-mode="route"] .route-only { display: block; }
-                                                       [data-mode="route"] th.route-only,
-                                                       [data-mode="route"] td.route-only { display: table-cell; }
 
                                                        /* Transitions */
                                                        .transition-all {
@@ -244,7 +243,7 @@ const Header: FC = () => (
 const StatsGrid: FC = () => (
        <div
                id="stats-grid"
-               class="grid grid-cols-5 gap-4 mb-8 [&[data-mode='route']]:grid-cols-7"
+               class="grid grid-cols-6 gap-4 mb-8 [&[data-mode='route']]:grid-cols-8"
        >
                <StatCard label="Total Requests" valueId="total-requests" />
                <StatCard
@@ -255,6 +254,7 @@ const StatsGrid: FC = () => (
                        accent="accent"
                />
                <StatCard label="API Requests" valueId="api-requests" accent="accent" />
+               <StatCard label="Extension" valueId="browser-extension-requests" accent="teal" />
                <StatCard label="Avg PII Scan" valueId="avg-scan" accent="teal" />
                <StatCard label="Requests/Hour" valueId="requests-hour" />
                <StatCard
@@ -384,9 +384,6 @@ 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">
                                                                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>
                                                        <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">
                                                                Model
                                                        </th>
@@ -406,7 +403,7 @@ const LogsSection: FC = () => (
                                        </thead>
                                        <tbody id="logs-body">
                                                <tr>
-                                                       <td colSpan={9}>
+                                                       <td colSpan={8}>
                                                                <div class="flex flex-col justify-center items-center p-10 gap-3">
                                                                        <div class="loader-bars">
                                                                                <div class="loader-bar" />
@@ -444,6 +441,7 @@ async function fetchStats() {
 
     document.getElementById('total-requests').textContent = data.total_requests.toLocaleString();
     document.getElementById('api-requests').textContent = data.api_requests.toLocaleString();
+    document.getElementById('browser-extension-requests').textContent = (data.browser_extension_requests || 0).toLocaleString();
     document.getElementById('avg-scan').textContent = data.avg_scan_time_ms + 'ms';
     document.getElementById('requests-hour').textContent = data.requests_last_hour.toLocaleString();
 
@@ -559,6 +557,19 @@ function renderEntityList(entities) {
   ).join('') + '</div>';
 }
 
+function formatSourceLabel(source) {
+  return source === 'browser_extension' ? 'Browser Extension' : source.toUpperCase();
+}
+
+function sourceBadgeClass(source) {
+  if (source === 'openai') return 'bg-info/10 text-info';
+  if (source === 'anthropic') return 'bg-anthropic/10 text-anthropic';
+  if (source === 'codex') return 'bg-success/10 text-success';
+  if (source === 'local') return 'bg-success/10 text-success';
+  if (source === 'browser_extension') return 'bg-teal/10 text-teal';
+  return 'bg-accent/10 text-accent';
+}
+
 async function fetchLogs() {
   try {
     const res = await fetch('/dashboard/api/logs?limit=50');
@@ -566,7 +577,7 @@ async function fetchLogs() {
     const tbody = document.getElementById('logs-body');
 
     if (data.logs.length === 0) {
-      tbody.innerHTML = '<tr><td colspan="9"><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;
     }
 
@@ -578,6 +589,7 @@ async function fetchLogs() {
       const isError = log.status_code && log.status_code >= 400;
       const lang = log.language || 'en';
       const detectedLang = log.detected_language;
+      const source = log.source;
 
       const formatLang = (code) => code ? code.toUpperCase() : lang.toUpperCase();
 
@@ -592,9 +604,7 @@ async function fetchLogs() {
         ? '<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 sourceBadge = log.provider === 'api'
-        ? '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide bg-accent/10 text-accent">API</span>'
-        : '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide bg-elevated text-text-muted">PROXY</span>';
+      const sourceBadge = '<span class="inline-flex items-center px-2 py-1 rounded-sm font-mono text-[0.6rem] font-medium uppercase tracking-wide ' + sourceBadgeClass(source) + '">' + formatSourceLabel(source) + '</span>';
 
       const mainRow =
         '<tr id="log-' + logId + '" class="cursor-pointer transition-colors hover:bg-elevated ' + (isExpanded ? 'log-row-expanded bg-elevated' : '') + '" onclick="toggleRow(' + logId + ')">' +
@@ -604,10 +614,6 @@ async function fetchLogs() {
           '</td>' +
           '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' + sourceBadge + '</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 === 'openai' ? 'bg-info/10 text-info' : log.provider === 'anthropic' ? 'bg-anthropic/10 text-anthropic' : 'bg-success/10 text-success') + '">' + log.provider + '</span>' +
-          '</td>' +
           '<td class="font-mono text-[0.7rem] text-text-secondary px-4 py-3 border-b border-border-subtle align-middle">' + log.model + '</td>' +
           '<td class="font-mono text-[0.65rem] font-medium px-4 py-3 border-b border-border-subtle align-middle">' + langDisplay + '</td>' +
           '<td class="text-sm px-4 py-3 border-b border-border-subtle align-middle">' +
@@ -629,7 +635,7 @@ async function fetchLogs() {
 
       const detailRow =
         '<tr id="detail-' + logId + '" class="' + (isExpanded ? 'detail-row-visible' : 'hidden') + '">' +
-          '<td colspan="9" class="p-0 bg-detail border-b border-border-subtle">' +
+          '<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