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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user