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
+33
View File
@@ -0,0 +1,33 @@
import { parseId, recordSearch } from '@/lib/analytics/api';
import { json, readFeedback, storageError } from '@/lib/analytics/http';
function path(value: unknown, max: number) {
return typeof value === 'string' && value.startsWith('/') ? value.slice(0, max) : null;
}
/** `{ id, query, results, clickedUrl?, page?, visitorId }` from the search dialog */
export async function POST(req: Request) {
const body = await readFeedback(req);
if (body instanceof Response) return body;
const id = parseId(body.id);
const query = typeof body.query === 'string' ? body.query.replace(/\s+/g, ' ').trim().slice(0, 256) : '';
const results = Number(body.results);
if (!id || !query || !Number.isInteger(results) || results < 0) {
return json(400, { error: 'Invalid search event.' });
}
try {
await recordSearch({
id,
visitorId: parseId(body.visitorId),
query,
results: Math.min(results, 1000),
clickedUrl: path(body.clickedUrl, 512),
page: path(body.page, 2048),
});
} catch (e) {
return storageError('search', e);
}
return json(200, { ok: true });
}
+29 -1
View File
@@ -1,6 +1,8 @@
import { after } from 'next/server';
import {
convertToModelMessages,
createUIMessageStreamResponse,
generateId,
stepCountIs,
streamText,
toUIMessageStream,
@@ -11,6 +13,7 @@ import { AIConfigError, getModel } from '@/lib/ai/model';
import { getSystemPrompt } from '@/lib/ai/prompt';
import { checkRateLimit, clientKey } from '@/lib/ai/rate-limit';
import { tools } from '@/lib/ai/tools';
import { parseId, recordAssistantMessage, recordUserMessage } from '@/lib/analytics/api';
export const maxDuration = 120;
@@ -37,13 +40,31 @@ export async function POST(req: Request) {
throw e;
}
const body = (await req.json().catch(() => null)) as { messages?: ChatUIMessage[] } | null;
const body = (await req.json().catch(() => null)) as {
messages?: ChatUIMessage[];
threadId?: unknown;
visitorId?: unknown;
} | null;
const messages = (body?.messages ?? []).slice(-MAX_MESSAGES);
const tooLong = messages.some((m) =>
m.parts.some((p) => p.type === 'text' && p.text.length > MAX_MESSAGE_CHARS),
);
if (messages.length === 0 || tooLong) return json(400, 'Invalid or too long message.');
// analytics: store the question now and the answer when the stream ends. Failures are
// logged and never affect the chat; `after` keeps the function alive for the writes.
const threadId = parseId(body?.threadId);
const visitorId = parseId(body?.visitorId);
const question = messages.at(-1);
const logError = (e: unknown) => console.error('[analytics] chat', e);
let saved: Promise<void> = Promise.resolve();
if (threadId && question?.role === 'user') {
saved = recordUserMessage(threadId, visitorId, question).catch(logError);
after(async () => {
await saved;
});
}
const instructions: SystemModelMessage = {
role: 'system',
content: getSystemPrompt(),
@@ -74,6 +95,13 @@ export async function POST(req: Request) {
return createUIMessageStreamResponse({
stream: toUIMessageStream({
stream: result.stream,
// gives the response a stable id so the client can vote on it
originalMessages: messages,
generateMessageId: generateId,
onEnd({ responseMessage }) {
if (!threadId) return;
saved = saved.then(() => recordAssistantMessage(threadId, responseMessage)).catch(logError);
},
onError: () => 'The assistant hit an error. Please try again.',
}),
});
+20
View File
@@ -0,0 +1,20 @@
import { parseId, parseVote, recordMessageVote } from '@/lib/analytics/api';
import { json, readFeedback, storageError } from '@/lib/analytics/http';
/** `{ messageId, vote: 1 | -1 | 0, visitorId }` for an assistant response */
export async function POST(req: Request) {
const body = await readFeedback(req);
if (body instanceof Response) return body;
const messageId = parseId(body.messageId);
const vote = parseVote(body.vote);
if (!messageId || vote === null) return json(400, { error: 'Invalid feedback.' });
try {
const found = await recordMessageVote(messageId, parseId(body.visitorId), vote);
if (!found) return json(404, { error: 'Unknown message.' });
} catch (e) {
return storageError('message vote', e);
}
return json(200, { ok: true });
}
+24
View File
@@ -0,0 +1,24 @@
import { source } from '@/lib/source';
import { parseId, parseVote, recordPageVote } from '@/lib/analytics/api';
import { json, readFeedback, storageError } from '@/lib/analytics/http';
/** `{ page: '/some/path', vote: 1 | -1 | 0, visitorId }`; `0` removes the vote */
export async function POST(req: Request) {
const body = await readFeedback(req);
if (body instanceof Response) return body;
const visitorId = parseId(body.visitorId);
const vote = parseVote(body.vote);
const page = typeof body.page === 'string' ? body.page : '';
const slugs = page.split('/').filter(Boolean);
if (!visitorId || vote === null || !page.startsWith('/') || !source.getPage(slugs)) {
return json(400, { error: 'Invalid feedback.' });
}
try {
await recordPageVote(source.getPage(slugs)!.url, visitorId, vote);
} catch (e) {
return storageError('page vote', e);
}
return json(200, { ok: true });
}