feat(web): wrap app in PostHog React provider with survey-based feedback

Enables useThumbSurvey for feedback buttons and sends un-redacted conversation traces on user feedback.
This commit is contained in:
kumarabhirup 2026-03-05 15:36:02 -08:00
parent 261f49de9b
commit ab8906a421
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
4 changed files with 120 additions and 46 deletions

View File

@ -1,23 +1,79 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { resolveWebChatDir } from "@/lib/workspace";
import { trackServer } from "@/lib/telemetry";
export const runtime = "nodejs";
type ChatLine = {
id: string;
role: "user" | "assistant";
content: string;
parts?: Array<Record<string, unknown>>;
};
function extractTextContent(line: ChatLine): string {
if (line.parts) {
return line.parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text as string)
.join("");
}
return line.content;
}
/**
* POST /api/feedback
*
* When a user submits thumbs up/down feedback, emit an un-redacted
* $ai_trace event to PostHog so the full conversation is visible
* in LLM Analytics regardless of the extension's privacy mode.
*/
export async function POST(req: Request) {
try {
const body = await req.json();
const { messageId, sessionId, sentiment } = body as {
messageId?: string;
const { sessionId, messageId } = (await req.json()) as {
sessionId?: string;
sentiment?: "positive" | "negative" | null;
messageId?: string;
};
if (!messageId || !sentiment) {
if (!sessionId) {
return Response.json({ ok: true });
}
trackServer("survey sent", {
$survey_id: process.env.POSTHOG_FEEDBACK_SURVEY_ID || "dench-feedback",
$survey_response: sentiment === "positive" ? 1 : 2,
const filePath = join(resolveWebChatDir(), `${sessionId}.jsonl`);
if (!existsSync(filePath)) {
return Response.json({ ok: true });
}
const lines: ChatLine[] = readFileSync(filePath, "utf-8")
.trim()
.split("\n")
.filter((l) => l.trim())
.map((l) => {
try { return JSON.parse(l) as ChatLine; } catch { return null; }
})
.filter((m): m is ChatLine => m !== null);
// Include all messages up to (and including) the feedback target.
let cutoff = lines.length;
if (messageId) {
const idx = lines.findIndex((m) => m.id === messageId);
if (idx >= 0) cutoff = idx + 1;
}
const conversation = lines.slice(0, cutoff);
const inputState = conversation
.filter((m) => m.role === "user")
.map((m) => ({ role: "user" as const, content: extractTextContent(m) }));
const outputState = conversation
.filter((m) => m.role === "assistant")
.map((m) => ({ role: "assistant" as const, content: extractTextContent(m) }));
trackServer("$ai_trace", {
$ai_trace_id: sessionId,
message_id: messageId,
$ai_session_id: sessionId,
$ai_span_name: "chat_session",
$ai_input_state: inputState.length > 0 ? inputState : undefined,
$ai_output_state: outputState.length > 0 ? outputState : undefined,
});
} catch {
// Fail silently -- feedback capture should never block the user.

View File

@ -2,6 +2,7 @@
import dynamic from "next/dynamic";
import type { UIMessage } from "ai";
import { useThumbSurvey } from "posthog-js/react/surveys";
import { memo, useCallback, useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import type { Components } from "react-markdown";
@ -688,58 +689,56 @@ function createMarkdownComponents(
/* ─── Feedback buttons (thumbs up / down) ─── */
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY || "";
const FEEDBACK_SURVEY_ID = "019cc021-a8bf-0000-d41d-b82956ef7e6a";
function FeedbackButtons({ messageId, sessionId }: { messageId: string; sessionId?: string | null }) {
const [sentiment, setSentiment] = useState<"positive" | "negative" | null>(null);
const [submitting, setSubmitting] = useState(false);
const revealTrace = useCallback((sid: string | null | undefined, mid: string) => {
if (!sid) return;
fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: sid, messageId: mid }),
}).catch(() => {});
}, []);
const handleFeedback = useCallback(async (value: "positive" | "negative") => {
const next = sentiment === value ? null : value;
setSentiment(next);
setSubmitting(true);
try {
await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messageId, sessionId, sentiment: next }),
});
} catch { /* fail silently */ }
setSubmitting(false);
}, [sentiment, messageId, sessionId]);
const { respond, response, triggerRef } = useThumbSurvey({
surveyId: FEEDBACK_SURVEY_ID,
properties: {
$ai_trace_id: sessionId,
message_id: messageId,
},
onResponse: () => revealTrace(sessionId, messageId),
});
if (!POSTHOG_KEY) return null;
const btnBase = "p-1 rounded-md transition-colors disabled:opacity-30";
const btnBase = "p-1 rounded-md transition-colors";
return (
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div ref={triggerRef} className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
disabled={submitting}
onClick={() => handleFeedback("positive")}
onClick={() => respond("up")}
className={btnBase}
style={{
color: sentiment === "positive" ? "var(--color-accent)" : "var(--color-text-muted)",
color: response === "up" ? "var(--color-accent)" : "var(--color-text-muted)",
}}
title="Good response"
aria-label="Thumbs up"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill={sentiment === "positive" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg width="14" height="14" viewBox="0 0 24 24" fill={response === "up" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 10v12" /><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
</svg>
</button>
<button
type="button"
disabled={submitting}
onClick={() => handleFeedback("negative")}
onClick={() => respond("down")}
className={btnBase}
style={{
color: sentiment === "negative" ? "var(--color-error, #ef4444)" : "var(--color-text-muted)",
color: response === "down" ? "var(--color-error, #ef4444)" : "var(--color-text-muted)",
}}
title="Bad response"
aria-label="Thumbs down"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill={sentiment === "negative" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg width="14" height="14" viewBox="0 0 24 24" fill={response === "down" ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 14V2" /><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
</svg>
</button>
@ -976,7 +975,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
);
})}
</AnimatePresence>
{!isStreaming && <FeedbackButtons messageId={message.id} sessionId={sessionId} />}
{!isStreaming && POSTHOG_KEY && <FeedbackButtons messageId={message.id} sessionId={sessionId} />}
</div>
);
});

View File

@ -1,6 +1,7 @@
"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
@ -28,14 +29,10 @@ function initPostHog(anonymousId: string) {
initialized = true;
}
export function PostHogPageviewTracker({ anonymousId }: { anonymousId: string }) {
function PageviewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
initPostHog(anonymousId);
}, []);
useEffect(() => {
if (!initialized) return;
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : "");
@ -44,3 +41,24 @@ export function PostHogPageviewTracker({ anonymousId }: { anonymousId: string })
return null;
}
export function PostHogProvider({
anonymousId,
children,
}: {
anonymousId: string;
children: React.ReactNode;
}) {
useEffect(() => {
initPostHog(anonymousId);
}, [anonymousId]);
if (!POSTHOG_KEY) return <>{children}</>;
return (
<PHProvider client={posthog}>
<PageviewTracker />
{children}
</PHProvider>
);
}

View File

@ -1,7 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Suspense } from "react";
import { getAnonymousId } from "@/lib/telemetry";
import { PostHogPageviewTracker } from "./components/posthog-provider";
import { PostHogProvider } from "./components/posthog-provider";
import "./globals.css";
export const metadata: Metadata = {
@ -43,9 +43,10 @@ export default function RootLayout({
</head>
<body className="antialiased">
<Suspense fallback={null}>
<PostHogPageviewTracker anonymousId={getAnonymousId()} />
<PostHogProvider anonymousId={getAnonymousId()}>
{children}
</PostHogProvider>
</Suspense>
{children}
</body>
</html>
);