mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-30 17:59:10 +02:00
add search, chat, and feedback analytics with dashboard
This commit is contained in:
+21
-17
@@ -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() ||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.' });
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user