From ab8906a421f2f34d5854c27d957e2dc263dcfb5d Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Thu, 5 Mar 2026 15:36:02 -0800 Subject: [PATCH] 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. --- apps/web/app/api/feedback/route.ts | 76 +++++++++++++++++--- apps/web/app/components/chat-message.tsx | 55 +++++++------- apps/web/app/components/posthog-provider.tsx | 28 ++++++-- apps/web/app/layout.tsx | 7 +- 4 files changed, 120 insertions(+), 46 deletions(-) diff --git a/apps/web/app/api/feedback/route.ts b/apps/web/app/api/feedback/route.ts index 38ff158c024..be95060933b 100644 --- a/apps/web/app/api/feedback/route.ts +++ b/apps/web/app/api/feedback/route.ts @@ -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>; +}; + +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. diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index a291aae56b9..6e81534d4cb 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -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 ( -
+
@@ -976,7 +975,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS ); })} - {!isStreaming && } + {!isStreaming && POSTHOG_KEY && }
); }); diff --git a/apps/web/app/components/posthog-provider.tsx b/apps/web/app/components/posthog-provider.tsx index 1ed68bf5629..7bf47f97465 100644 --- a/apps/web/app/components/posthog-provider.tsx +++ b/apps/web/app/components/posthog-provider.tsx @@ -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 ( + + + {children} + + ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8028f7852d3..c077d1f4510 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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({ - + + {children} + - {children} );