"timestamp": "2026-01-15T10:30:00Z",
"mode": "mask",
"provider": "openai",
+ "source": "openai",
"model": "gpt-5.2",
"pii_detected": true,
"entities": "[\"EMAIL_ADDRESS\",\"PERSON\"]",
"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,
}'
```
+Browser extensions can identify themselves in dashboard logs by sending:
+
+```http
+X-PasteGuard-Source: browser-extension
+```
+
## Parameters
| Parameter | Type | Required | Description |
// 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.
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();
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);
logRequest(
createLogData({
provider: "api",
+ source,
model: "mask",
startTime,
pii: {
logRequest(
createLogData({
provider: "api",
+ source,
model: "mask",
startTime,
pii: { hasPII: false, entityTypes: [], language, languageFallback, scanTimeMs: 0 },
logRequest(
createLogData({
provider: "api",
+ source,
model: "mask",
startTime,
pii: {
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";
export interface CreateLogDataOptions {
provider: "openai" | "anthropic" | "codex" | "local" | "api";
+ source?: RequestSource;
model: string;
startTime: number;
pii?: PIILogData;
timestamp: new Date().toISOString(),
mode: config.mode,
provider,
+ source: options.source,
model: model || "unknown",
piiDetected: pii?.hasPII ?? false,
entities: pii?.entityTypes ?? [],
--- /dev/null
+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");
+ });
+});
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;
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
*/
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,
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(`
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,
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),
+ }));
}
/**
.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
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,
export interface RequestLogData {
timestamp: string;
mode: "route" | "mask";
- provider: "openai" | "anthropic" | "codex" | "local" | "api";
+ provider: RequestProvider;
+ source?: RequestSource;
model: string;
piiDetected: boolean;
entities: string[];
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(","),
.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); }
/* 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 {
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
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
<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>
</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" />
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();
).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');
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;
}
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();
? '<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 + ')">' +
'</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">' +
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>';