Files
docs-v2/lib/analytics/api.ts
T

144 lines
3.9 KiB
TypeScript

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);
}