From: Stefan Gasser Date: Tue, 23 Jun 2026 07:31:37 +0000 (+0200) Subject: Track browser-extension requests separately in the dashboard (#107) X-Git-Tag: v0.7.0~10 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=a395fc1a00d0fdb660a0162de4655edf45debe7f;p=sgasser-llm-shield.git Track browser-extension requests separately in the dashboard (#107) 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 --- diff --git a/docs/api-reference/dashboard-api.mdx b/docs/api-reference/dashboard-api.mdx index 2949704..5c06c1b 100644 --- a/docs/api-reference/dashboard-api.mdx +++ b/docs/api-reference/dashboard-api.mdx @@ -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, diff --git a/docs/api-reference/mask.mdx b/docs/api-reference/mask.mdx index 0cac87f..53598a0 100644 --- a/docs/api-reference/mask.mdx +++ b/docs/api-reference/mask.mdx @@ -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 | diff --git a/src/routes/api.test.ts b/src/routes/api.test.ts index a0747fa..207556c 100644 --- a/src/routes/api.test.ts +++ b/src/routes/api.test.ts @@ -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. diff --git a/src/routes/api.ts b/src/routes/api.ts index 0bd26d1..5bff1fa 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -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: { diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 11ae6de..b5cfd66 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -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( 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 index 0000000..e722343 --- /dev/null +++ b/src/services/logger.test.ts @@ -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"); + }); +}); diff --git a/src/services/logger.ts b/src/services/logger.ts index 0a41130..7727f25 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -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): 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(","), diff --git a/src/views/dashboard/page.tsx b/src/views/dashboard/page.tsx index f1a33bf..0ccb2a0 100644 --- a/src/views/dashboard/page.tsx +++ b/src/views/dashboard/page.tsx @@ -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 = () => (
( accent="accent" /> + ( Status - - Provider - Model @@ -406,7 +403,7 @@ const LogsSection: FC = () => ( - +
@@ -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('') + '
'; } +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 = '
📋
No requests yet
'; + tbody.innerHTML = '
📋
No requests yet
'; 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() { ? '' + log.status_code + '' : 'OK'; - const sourceBadge = log.provider === 'api' - ? 'API' - : 'PROXY'; + const sourceBadge = '' + formatSourceLabel(source) + ''; const mainRow = '' + @@ -604,10 +614,6 @@ async function fetchLogs() { '' + '' + sourceBadge + '' + '' + statusBadge + '' + - '' + - '' + log.provider + '' + - '' + '' + log.model + '' + '' + langDisplay + '' + '' + @@ -629,7 +635,7 @@ async function fetchLogs() { const detailRow = '' + - '' + + '' + '
' + detailContent + '
' + '' + '';