add search, chat, and feedback analytics with dashboard

This commit is contained in:
miloschwartz
2026-09-28 14:03:29 -04:00
parent 9d1b9f3820
commit 6588fc870b
42 changed files with 5932 additions and 108 deletions
+21 -17
View File
@@ -6,29 +6,33 @@ import 'server-only';
* reverse proxy's) in front if you run several replicas.
*/
const windowMs = 60_000;
const limit = Number(process.env.AI_RATE_LIMIT_PER_MINUTE ?? 10);
const hits = new Map<string, number[]>();
export function checkRateLimit(key: string): { ok: boolean; retryAfter: number } {
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
export function createRateLimiter(limit: number) {
const hits = new Map<string, number[]>();
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) {
return function check(key: string): { ok: boolean; retryAfter: number } {
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
const now = Date.now();
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
if (recent.length >= limit) {
hits.set(key, recent);
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
}
recent.push(now);
hits.set(key, recent);
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
}
recent.push(now);
hits.set(key, recent);
// keep the map from growing forever
if (hits.size > 10_000) {
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
}
return { ok: true, retryAfter: 0 };
// keep the map from growing forever
if (hits.size > 10_000) {
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
}
return { ok: true, retryAfter: 0 };
};
}
export const checkRateLimit = createRateLimiter(Number(process.env.AI_RATE_LIMIT_PER_MINUTE ?? 10));
export function clientKey(req: Request) {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+143
View File
@@ -0,0 +1,143 @@
import 'server-only';
import type { ChatUIMessage } from '@/components/ai/search';
/**
* Analytics are stored by the Fossorial API (`/api/v1/docs-analytics`), called from the
* docs server with `FOSSORIAL_API_KEY` so the key never reaches the browser. With
* `FOSSORIAL_API_URL` or the key unset, events are dropped (fine for local development).
*/
const apiUrl = process.env.FOSSORIAL_API_URL?.replace(/\/+$/, '');
const apiKey = process.env.FOSSORIAL_API_KEY;
class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
}
}
let warned = false;
async function post(path: string, body: object) {
if (!apiUrl || !apiKey) {
if (!warned) console.warn('[analytics] FOSSORIAL_API_URL / FOSSORIAL_API_KEY not set; not storing analytics');
warned = true;
return;
}
const res = await fetch(`${apiUrl}/api/v1/docs-analytics${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api-key': apiKey },
body: JSON.stringify(body),
signal: AbortSignal.timeout(5000),
cache: 'no-store',
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new ApiError(res.status, `Fossorial API ${path} responded ${res.status} ${detail.slice(0, 300)}`);
}
}
/** ids come from the browser: keep them short and boring */
export function parseId(value: unknown): string | null {
return typeof value === 'string' && /^[\w-]{8,64}$/.test(value) ? value : null;
}
export function parseVote(value: unknown): -1 | 0 | 1 | null {
return value === 1 || value === -1 || value === 0 ? value : null;
}
/** `vote: 0` clears a previous vote */
export async function recordPageVote(page: string, visitorId: string, vote: -1 | 0 | 1) {
await post('/page-votes', { page, visitorId, vote });
}
/** returns false when the message isn't a stored assistant response */
export async function recordMessageVote(
messageId: string,
visitorId: string | null,
vote: -1 | 0 | 1,
): Promise<boolean> {
try {
await post('/message-votes', { messageId, visitorId, vote });
return true;
} catch (e) {
if (e instanceof ApiError && e.status === 404) return false;
throw e;
}
}
function messageText(message: ChatUIMessage) {
return message.parts
.flatMap((p) => (p.type === 'text' ? [p.text] : []))
.join('')
.trim();
}
function messagePage(message: ChatUIMessage) {
for (const part of message.parts) {
if (part.type !== 'data-client') continue;
try {
const url = new URL(part.data.location);
return `${url.pathname}${url.search}${url.hash}`.slice(0, 2048);
} catch {
return null;
}
}
return null;
}
/** creates the thread on its first question; re-sent messages (regenerate) are ignored */
export async function recordUserMessage(
threadId: string,
visitorId: string | null,
message: ChatUIMessage,
) {
const id = parseId(message.id);
const content = messageText(message);
if (!id || !content) return;
await post('/chat-messages', {
threadId,
visitorId,
page: messagePage(message),
id,
role: 'user',
content,
});
}
export async function recordAssistantMessage(threadId: string, message: ChatUIMessage) {
const id = parseId(message.id);
if (!id) return;
const tools = message.parts.flatMap((p) => {
if (!p.type.startsWith('tool-')) return [];
const input = (p as { input?: unknown }).input;
return [{ name: p.type.slice('tool-'.length), input }];
});
await post('/chat-messages', {
threadId,
id,
role: 'assistant',
content: messageText(message),
tools,
});
}
export interface SearchEvent {
id: string;
visitorId: string | null;
query: string;
results: number;
clickedUrl: string | null;
page: string | null;
}
/** one row per search; the dialog re-sends the same id as the query is refined */
export async function recordSearch(e: SearchEvent) {
await post('/searches', e);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Browser helpers for docs feedback. The visitor id is an anonymous random id kept in
* localStorage so a person can change their vote; nothing else identifies them.
*/
const VisitorKey = 'pg-visitor-id';
export function randomId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID();
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
let fallbackVisitorId: string | undefined;
export function getVisitorId() {
try {
let id = localStorage.getItem(VisitorKey);
if (!id) {
id = randomId();
localStorage.setItem(VisitorKey, id);
}
return id;
} catch {
// storage unavailable (private mode): one id per page load
return (fallbackVisitorId ??= randomId());
}
}
export type Vote = -1 | 0 | 1;
/** fire-and-forget; analytics must never break the page */
export function sendEvent(url: string, body: Record<string, unknown>) {
void fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, visitorId: getVisitorId() }),
keepalive: true,
}).catch(() => undefined);
}
export function sendFeedback(kind: 'page' | 'message', body: Record<string, unknown>) {
sendEvent(`/api/feedback/${kind}`, body);
}
+21
View File
@@ -0,0 +1,21 @@
import 'server-only';
import { clientKey, createRateLimiter } from '@/lib/ai/rate-limit';
const checkFeedbackLimit = createRateLimiter(Number(process.env.FEEDBACK_RATE_LIMIT_PER_MINUTE ?? 60));
export function json(status: number, body: object) {
return Response.json(body, { status });
}
/** shared guard for the feedback endpoints: rate limit + JSON body */
export async function readFeedback(req: Request): Promise<Record<string, unknown> | Response> {
if (!checkFeedbackLimit(clientKey(req)).ok) return json(429, { error: 'Too many requests.' });
const body = (await req.json().catch(() => null)) as unknown;
if (!body || typeof body !== 'object') return json(400, { error: 'Invalid body.' });
return body as Record<string, unknown>;
}
export function storageError(scope: string, error: unknown) {
console.error(`[analytics] ${scope}`, error);
return json(503, { error: 'Feedback storage is unavailable.' });
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function baseOptions(): BaseLayoutProps {
},
themeSwitch: { enabled: enableDarkMode },
links: [
// pill buttons styled like the pangolin.net navbar (secondary + primary)
// same height and radius as the header search and Ask AI button
{
type: 'custom',
on: 'nav',