From 196cb787f1242de0e6f6de949b082121ab0ed4de Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 10 Apr 2026 13:22:34 +0200 Subject: [PATCH] feat(inbox): support session problem signals Add dedicated rendering for session_problem signals in the inbox detail pane, including video playback and an "Events around the problem" table showing user-behavior events with human-readable names and millisecond timestamps. --- apps/code/src/renderer/api/posthogClient.ts | 39 +++ .../components/detail/ReportDetailPane.tsx | 132 +++------ .../inbox/components/detail/SignalCard.tsx | 269 +++++++++++++++++- apps/code/src/shared/constants/oauth.ts | 3 +- 4 files changed, 347 insertions(+), 96 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 913308d60..a18fe8b80 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1645,4 +1645,43 @@ export class PostHogAPIClient { ); } } + + /** Find an exported asset by session recording ID. */ + async findExportBySessionRecordingId( + projectId: number, + sessionRecordingId: string, + ): Promise { + const urlPath = `/api/projects/${projectId}/exports/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("session_recording_id", sessionRecordingId); + url.searchParams.set("export_format", "video/mp4"); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ id: number; has_content: boolean }>; + }; + const match = data.results?.find((e) => e.has_content); + return match?.id ?? null; + } + + /** Get the presigned content URL for an exported asset (e.g. rasterized recording). */ + async getExportContentUrl( + projectId: number, + exportId: number, + ): Promise { + const urlPath = `/api/projects/${projectId}/exports/${exportId}/content/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const blob = await response.blob(); + return URL.createObjectURL(blob); + } } diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 67c0a7287..2d6b7c017 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -13,7 +13,6 @@ import { ArrowSquareOutIcon, CaretDownIcon, CaretRightIcon, - ClockIcon, Cloud as CloudIcon, CommandIcon, GithubLogoIcon, @@ -38,8 +37,6 @@ import type { PriorityJudgmentArtefact, SignalFindingArtefact, SignalReport, - SignalReportArtefact, - SignalReportArtefactsResponse, SuggestedReviewersArtefact, } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; @@ -60,23 +57,6 @@ import { SignalCard } from "./SignalCard"; // ── Helpers ───────────────────────────────────────────────────────────────── -function getArtefactsUnavailableMessage( - reason: SignalReportArtefactsResponse["unavailableReason"], -): string { - switch (reason) { - case "forbidden": - return "Evidence could not be loaded with the current API permissions."; - case "not_found": - return "Evidence endpoint is unavailable for this signal in this environment."; - case "invalid_payload": - return "Evidence format was unexpected, so no artefacts could be shown."; - case "request_failed": - return "Evidence is temporarily unavailable. You can still create a task from this report."; - default: - return "Evidence is currently unavailable for this signal."; - } -} - function DetailRow({ label, value, @@ -151,10 +131,6 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { }); const allArtefacts = artefactsQuery.data?.results ?? []; - const videoSegments = allArtefacts.filter( - (a): a is SignalReportArtefact => a.type === "video_segment", - ); - const suggestedReviewers = useMemo(() => { const reviewerArtefact = allArtefacts.find( (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", @@ -193,17 +169,24 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { }, [allArtefacts]); const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason; - const showArtefactsUnavailable = - !artefactsQuery.isLoading && - (!!artefactsQuery.error || !!artefactsUnavailableReason); - const artefactsUnavailableMessage = artefactsQuery.error - ? "Evidence could not be loaded right now. You can still create a task from this report." - : getArtefactsUnavailableMessage(artefactsUnavailableReason); + void artefactsUnavailableReason; // TODO: wire up unavailable UI const signalsQuery = useInboxReportSignals(report.id, { enabled: true, }); - const signals = signalsQuery.data?.signals ?? []; + const allSignals = signalsQuery.data?.signals ?? []; + const sessionProblemSignals = allSignals.filter( + (s) => + s.source_product === "session_replay" && + s.source_type === "session_problem", + ); + const signals = allSignals.filter( + (s) => + !( + s.source_product === "session_replay" && + s.source_type === "session_problem" + ), + ); // ── Task creation ─────────────────────────────────────────────────────── const { navigateToTaskInput, navigateToTask } = useNavigationStore(); @@ -225,11 +208,11 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { const buildPrompt = useCallback(() => { return buildSignalTaskPrompt({ report, - artefacts: videoSegments, - signals, + artefacts: [], + signals: [...signals, ...sessionProblemSignals], replayBaseUrl, }); - }, [report, videoSegments, signals, replayBaseUrl]); + }, [report, signals, sessionProblemSignals, replayBaseUrl]); const handleCreateTask = useCallback(() => { if (!canActOnReport) return; @@ -527,67 +510,28 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { )} - {/* ── Evidence (session segments) ─────────────────────── */} - - - Evidence - - {artefactsQuery.isLoading && ( - - Loading evidence... - - )} - {showArtefactsUnavailable && ( - - {artefactsUnavailableMessage} + {/* ── Session problem evidence ─────────────────────────── */} + {sessionProblemSignals.length > 0 && ( + + + Evidence ({sessionProblemSignals.length}) - )} - {!artefactsQuery.isLoading && - !showArtefactsUnavailable && - videoSegments.length === 0 && ( - - No session segments available for this report. - - )} - - {videoSegments.map((artefact) => ( - - - {artefact.content.content} - - - - - - {artefact.content.start_time - ? new Date( - artefact.content.start_time, - ).toLocaleString() - : "Unknown time"} - - - {replayBaseUrl && artefact.content.session_id && ( - - View replay - - - )} - - - ))} - - + + {sessionProblemSignals.map((signal) => ( + + ))} + + + )} diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index 6813fa8f4..f549ce647 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -1,5 +1,7 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -10,7 +12,7 @@ import { } from "@phosphor-icons/react"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import type { Signal, SignalFindingContent } from "@shared/types"; -import { useState } from "react"; +import { useRef, useState } from "react"; const COLLAPSE_THRESHOLD = 300; @@ -33,6 +35,12 @@ function signalCardSourceLine(signal: { ERROR_TRACKING_TYPE_LABELS[source_type] ?? source_type.replace(/_/g, " "); return `Error tracking · ${typeLabel}`; } + if ( + source_product === "session_replay" && + source_type === "session_problem" + ) { + return "Session replay · Session problem"; + } if ( source_product === "session_replay" && source_type === "session_segment_cluster" @@ -91,6 +99,27 @@ interface LlmEvalExtra { provider?: string; } +interface SessionProblemEventEntry { + event: string; + timestamp: string; + current_url?: string; + event_type?: string; + interaction_text?: string; +} + +interface SessionProblemExtra { + session_id?: string; + segment_title?: string; + start_time?: string; + end_time?: string; + problem_type?: string; + distinct_id?: string; + session_start_time?: string; + session_end_time?: string; + exported_asset_id?: number; + event_history?: SessionProblemEventEntry[]; +} + interface ErrorTrackingExtra { fingerprint?: string; } @@ -173,6 +202,14 @@ function isLlmEvalExtra( return "evaluation_id" in extra && "trace_id" in extra; } +function isSessionProblemExtra( + extra: Record, +): extra is Record & SessionProblemExtra { + return ( + "session_id" in extra && "problem_type" in extra && "segment_title" in extra + ); +} + function isErrorTrackingExtra( extra: Record, ): extra is Record & ErrorTrackingExtra { @@ -476,6 +513,221 @@ function LlmEvalSignalCard({ ); } +const PROBLEM_TYPE_LABELS: Record< + string, + { label: string; color: "red" | "orange" } +> = { + blocking_exception: { label: "Blocking exception", color: "red" }, + non_blocking_exception: { label: "Non-blocking exception", color: "orange" }, + abandonment: { label: "Abandonment", color: "red" }, + confusion: { label: "Confusion", color: "orange" }, + failure: { label: "Failure", color: "red" }, +}; + +// Human-readable labels for common PostHog dollar-prefixed event names +const EVENT_DISPLAY_NAMES: Record = { + $pageview: "Pageview", + $autocapture: "Autocapture", + $exception: "Exception", + $rageclick: "Rageclick", + $dead_click: "Dead click", + $screen: "Screen", + $csp_violation: "CSP violation", + $pageleave: "Pageleave", +}; + +function eventDisplayName(raw: string): string { + return EVENT_DISPLAY_NAMES[raw] ?? raw; +} + +function EventHistoryTable({ events }: { events: SessionProblemEventEntry[] }) { + return ( + + + Events around the problem + + + + + {events.map((entry) => ( + + + + {entry.current_url ? ( + + ) : null} + + ))} + +
+ {entry.timestamp} + + + {eventDisplayName(entry.event)} + + {entry.event_type ? ( + + [{entry.event_type}] + + ) : null} + {entry.interaction_text ? ( + + "{entry.interaction_text}" + + ) : null} + + {entry.current_url} +
+
+
+ ); +} + +function SessionProblemSignalCard({ + signal, + extra, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + extra: SessionProblemExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { + const problemInfo = extra.problem_type + ? (PROBLEM_TYPE_LABELS[extra.problem_type] ?? { + label: extra.problem_type.replace(/_/g, " "), + color: "orange" as const, + }) + : null; + const hasEventHistory = extra.event_history && extra.event_history.length > 0; + + return ( + + + + + {extra.session_id && ( + + )} + {hasEventHistory && ( + + + + )} + + + {problemInfo && ( + + {problemInfo.label} + + )} + {extra.distinct_id && ( + + {extra.distinct_id.slice(0, 10)}… + + )} + {extra.start_time && extra.end_time && ( + <> + · + + {extra.start_time} – {extra.end_time} + + + )} + + + + + ); +} + +function SessionRecordingVideo({ + exportedAssetId, + sessionId, +}: { + exportedAssetId?: number; + sessionId: string; +}) { + const projectId = useAuthStateValue((state) => state.projectId); + const videoRef = useRef(null); + const videoQuery = useAuthenticatedQuery( + ["export-video", projectId, exportedAssetId, sessionId], + async (client) => { + if (!projectId) return null; + let assetId: number | null = exportedAssetId ?? null; + // If no asset ID in the signal, look up the export by session_id + if (assetId == null) { + assetId = await client.findExportBySessionRecordingId( + projectId, + sessionId, + ); + if (assetId == null) return null; + } + return client.getExportContentUrl(projectId, assetId); + }, + { enabled: !!projectId, staleTime: Infinity }, + ); + + if (videoQuery.isError || videoQuery.data === null) return null; + if (videoQuery.isLoading || videoQuery.data === undefined) { + return ( + + Loading recording… + + ); + } + + return ( + + + ); +} + function ErrorTrackingSignalCard({ signal, verified, @@ -609,6 +861,21 @@ export function SignalCard({ const codePaths = finding?.relevant_code_paths ?? []; const dataQueried = finding?.data_queried ?? ""; + if ( + signal.source_product === "session_replay" && + signal.source_type === "session_problem" && + isSessionProblemExtra(extra) + ) { + return ( + + ); + } if ( signal.source_product === "error_tracking" && isErrorTrackingExtra(extra) diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index 9b2065b40..cf3fdfa3b 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -24,6 +24,7 @@ export const OAUTH_SCOPES = [ "event_definition:read", "event_definition:write", "experiment:read", + "export:read", "experiment:write", "feature_flag:read", "feature_flag:write", @@ -41,7 +42,7 @@ export const OAUTH_SCOPES = [ "external_data_source:write", ]; -export const OAUTH_SCOPE_VERSION = 3; +export const OAUTH_SCOPE_VERSION = 4; export const REGION_LABELS: Record = { us: "🇺🇸 US Cloud",