feat(observability): session-replay + in-app feedback on catalog UX failures

The two things only the browser can add on top of the backend catalog-degradation
reporting:

- Replay-on-failure: when the catalog UI renders empty-tree (decoded vehicle, no
  categories) or a drill loadError, capture a browser Sentry warning and flush the
  Session Replay → you can WATCH the user hit the dead-end (serkan's session,
  reproducible). Per-session deduped (one replay/session covers the whole journey).
- In-app feedback: a "Çalışmadı mı? Bildir" button on the empty-parts, empty-tree
  and loadError states opens the Sentry feedback dialog pre-tagged with the
  vehicle/category (+ session replay) — turns a parts shop's complaint into a
  structured, triageable report instead of an email.

Browser events are fingerprinted source="browser" so they form their own
"what users actually saw" issues (carrying replays) next to the server-side
detections. tsc + biome + web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:07:49 +03:00
parent a89eac1820
commit d6c88ede73
7 changed files with 247 additions and 28 deletions

View File

@@ -0,0 +1,63 @@
import { useTranslation } from "@/lib/i18n";
import {
type CatalogIssueContext,
type CatalogIssueKind,
openCatalogFeedback,
reportCatalogDegradation,
} from "@/lib/sentry";
import { Button } from "@sase/ui";
import { Flag } from "lucide-react";
import { useEffect } from "react";
/**
* Fire-and-forget: when `active` becomes true, report the catalog degradation the
* user is looking at to Sentry (with a flushed session replay so it's watchable).
* Deduped per session in the lib, so re-renders are safe.
*/
export function useReportCatalogDegradation(
kind: CatalogIssueKind,
active: boolean,
ctx: CatalogIssueContext,
): void {
const { vehicleId, categoryId, vehicleLabel, categoryName, source, vin } = ctx;
useEffect(() => {
if (!active) return;
void reportCatalogDegradation(kind, {
vehicleId,
categoryId,
vehicleLabel,
categoryName,
source,
vin,
});
}, [active, kind, vehicleId, categoryId, vehicleLabel, categoryName, source, vin]);
}
/**
* "Çalışmadı mı? Bildir" — opens the Sentry feedback dialog pre-tagged with this
* vehicle/category. Shown on empty/error catalog states so a parts shop can
* report a missing/wrong catalog in one click (with the session replay attached)
* instead of emailing.
*/
export function ReportCatalogIssueButton({
ctx,
className,
}: {
ctx: CatalogIssueContext;
className?: string;
}) {
const { t } = useTranslation();
return (
<Button
type="button"
variant="ghost"
size="sm"
className={className}
onClick={() => void openCatalogFeedback(ctx)}
data-faro-user-action-name="report-catalog-issue"
>
<Flag className="mr-1.5 h-4 w-4" />
{t("vehicle.reportIssue")}
</Button>
);
}

View File

@@ -1,3 +1,4 @@
import { ReportCatalogIssueButton } from "@/components/catalog/report-catalog-issue";
import type { Part } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
@@ -204,23 +205,30 @@ export function PartsPanel({
{parts.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
<p>Bu kategori için parça bulunamadı.</p>
<Button
type="button"
variant="outline"
onClick={() => {
// Demand/abandonment signal from an empty "0 parça" panel — lets
// us prioritise which catalogs to backfill, with readable context.
capture("empty_catalog_cta_clicked", {
vehicle_id: vehicleId,
category_id: categoryId,
vehicle_label: vehicleLabel,
category_name: categoryName,
});
window.history.back();
}}
>
Geri dön
</Button>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
// Demand/abandonment signal from an empty "0 parça" panel — lets
// us prioritise which catalogs to backfill, with readable context.
capture("empty_catalog_cta_clicked", {
vehicle_id: vehicleId,
category_id: categoryId,
vehicle_label: vehicleLabel,
category_name: categoryName,
});
window.history.back();
}}
>
Geri dön
</Button>
{/* User-driven report: a parts shop knows if this category SHOULD
have parts. One click → structured Sentry feedback + replay. */}
<ReportCatalogIssueButton
ctx={{ vehicleId, categoryId, vehicleLabel, categoryName }}
/>
</div>
</div>
) : (
<table className="w-full text-sm">

View File

@@ -10,6 +10,9 @@
import type { init as SentryInit } from "@sentry/react";
let initialized = false;
// Set once init succeeds so the catalog-degradation helpers below can use the
// already-loaded SDK without re-awaiting the dynamic import on every call.
let sentryApi: typeof import("@sentry/react") | null = null;
export async function initSentry() {
if (initialized) return;
@@ -46,6 +49,9 @@ export async function initSentry() {
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
// Contextual in-app feedback (no global floating button — opened from the
// empty/error catalog states via openCatalogFeedback).
Sentry.feedbackIntegration({ autoInject: false, showBranding: false }),
],
// Performance: 10% trace sample rate (low volume site, can raise later).
tracesSampleRate: 0.1,
@@ -75,8 +81,95 @@ export async function initSentry() {
denyUrls: [/iabjs:\/\//],
});
initialized = true;
sentryApi = Sentry;
console.log("[sentry] browser SDK initialized");
} catch (err) {
console.warn("[sentry] init failed:", (err as Error).message);
}
}
// ─── Catalog UX-degradation reporting (browser) ───
// Two things only the browser can add on top of the backend reporter
// (apps/api/src/common/catalog-degradation.ts): a Session Replay of the user
// hitting the dead-end, and a one-click structured feedback path. Browser events
// are fingerprinted with source "browser" so they form their own "what users
// actually saw" issues (carrying replays), distinct from the server-side
// detections — same kinds, complementary views.
export type CatalogIssueKind = "empty-tree" | "empty-parts" | "drill-load-error";
export interface CatalogIssueContext {
vehicleId?: string;
vehicleLabel?: string;
categoryId?: string;
categoryName?: string;
source?: string;
vin?: string;
}
// Per-session dedup: re-renders or browsing many empty categories shouldn't spam
// the issue stream, and one replay upload already covers the whole journey.
const reportedThisSession = new Set<string>();
let replayFlushedThisSession = false;
const brandOf = (label?: string) => (label?.trim().split(/\s+/)[0] || "unknown").toLowerCase();
/**
* Report the catalog failure the user is currently looking at and flush the
* Session Replay so it's watchable in Sentry. Best-effort; never throws.
*/
export async function reportCatalogDegradation(
kind: CatalogIssueKind,
ctx: CatalogIssueContext,
): Promise<void> {
try {
const Sentry = sentryApi;
if (!Sentry) return;
const dedupKey = `${kind}:${ctx.categoryId ?? ctx.vehicleId ?? ""}`;
if (reportedThisSession.has(dedupKey)) return;
reportedThisSession.add(dedupKey);
const brand = brandOf(ctx.vehicleLabel);
Sentry.captureMessage(`catalog degraded: ${kind} (browser/${brand})`, {
level: "warning",
tags: { catalog_degradation: kind, catalog_source: ctx.source ?? "unknown" },
fingerprint: ["catalog-degradation", "browser", kind, brand],
extra: { ...ctx },
});
if (!replayFlushedThisSession) {
replayFlushedThisSession = true;
await Sentry.getReplay()?.flush();
}
} catch {
// telemetry must never break the UI
}
}
/**
* Open the Sentry feedback dialog pre-tagged with the vehicle/category the user
* is reporting from, so "bu araç çalışmadı" becomes a structured report with the
* session replay attached — instead of an email.
*/
export async function openCatalogFeedback(ctx: CatalogIssueContext): Promise<void> {
try {
const Sentry = sentryApi;
if (!Sentry) return;
Sentry.setTag("catalog_source", ctx.source ?? "unknown");
Sentry.setContext("catalog_issue", { ...ctx });
const feedback = Sentry.getFeedback();
if (!feedback) return;
const form = await feedback.createForm({
formTitle: "Bu araç / parça çalışmadı mı?",
messagePlaceholder:
"Hangi araç ve parça eksik veya yanlış? (şase ve kategori bilgisi otomatik eklenir)",
submitButtonLabel: "Gönder",
cancelButtonLabel: "Vazgeç",
// The session replay + the catalog_issue context above ride along with this.
});
form.appendToDom();
form.open();
} catch {
// feedback is best-effort
}
}

View File

@@ -738,6 +738,7 @@
"noCategoriesTitle": "No categories for this vehicle",
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
"drillHint": "Parts live inside the subcategories — open a category to drill down to its part lists.",
"reportIssue": "Not working? Report it",
"noAttrs": "No detailed info available for this vehicle.",
"labelModel": "Model",
"labelYear": "Model year",

View File

@@ -738,6 +738,7 @@
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
"drillHint": "Parçalar alt kategorilerin içinde yer alır. Bir kategoriye girerek ilerleyin — her grup, içindeki parça listesine kadar açılır.",
"reportIssue": "Çalışmadı mı? Bildir",
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
"labelModel": "Model",
"labelYear": "Model yılı",

View File

@@ -1,3 +1,7 @@
import {
ReportCatalogIssueButton,
useReportCatalogDegradation,
} from "@/components/catalog/report-catalog-issue";
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid";
@@ -61,7 +65,9 @@ function VehicleCategoryPage() {
const { data: vehicle } = useQuery({
queryKey: ["vehicle", id],
queryFn: () =>
api.get<{ brandName?: string; model?: string; year?: number }>(`/vehicles/${id}`),
api.get<{ brandName?: string; model?: string; year?: number; source?: string; vin?: string }>(
`/vehicles/${id}`,
),
enabled: !!id,
});
@@ -97,6 +103,17 @@ function VehicleCategoryPage() {
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
: t("vehicle.title");
// A loadError = the drill/parts fetch failed and the user sees a "couldn't load"
// panel instead of parts — report it (with a session replay) so it surfaces.
useReportCatalogDegradation("drill-load-error", !!data?.loadError, {
vehicleId: id,
categoryId,
categoryName: data?.name,
vehicleLabel,
source: vehicle?.source,
vin: vehicle?.vin,
});
return (
<div className="space-y-4">
<CategoryBreadcrumb
@@ -188,16 +205,28 @@ function VehicleCategoryPage() {
<p className="font-medium text-destructive">{t("vehicle.catalogUnavailableTitle")}</p>
<p className="mt-1 text-muted-foreground">{t("vehicle.catalogUnavailableHint")}</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
data-faro-user-action-name="category-loaderror-retry"
>
{isFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
data-faro-user-action-name="category-loaderror-retry"
>
{isFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
<ReportCatalogIssueButton
ctx={{
vehicleId: id,
categoryId,
categoryName: data?.name,
vehicleLabel,
source: vehicle?.source,
vin: vehicle?.vin,
}}
/>
</div>
</div>
) : (
<Suspense fallback={<SchemaViewerFallback />}>

View File

@@ -1,3 +1,7 @@
import {
ReportCatalogIssueButton,
useReportCatalogDegradation,
} from "@/components/catalog/report-catalog-issue";
import { CatalogSearch } from "@/components/categories/catalog-search";
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
import { CategoryColumns } from "@/components/categories/category-columns";
@@ -105,6 +109,16 @@ function VehicleDetailPage() {
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
: t("vehicle.title");
// An empty category tree on a decoded vehicle is a silent failure ("model var,
// kategori yok") — report it to Sentry with a session replay so it surfaces.
const treeEmpty = !categoriesLoading && Array.isArray(categoryTree) && categoryTree.length === 0;
useReportCatalogDegradation("empty-tree", treeEmpty, {
vehicleId: id,
vehicleLabel,
source: vehicle?.source,
vin: vehicle?.vin,
});
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
// so agents have the car context for part-compatibility questions.
useEffect(() => {
@@ -344,6 +358,16 @@ function VehicleDetailPage() {
<p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
{t("vehicle.noCategoriesHint")}
</p>
<div className="mt-3 flex justify-center">
<ReportCatalogIssueButton
ctx={{
vehicleId: id,
vehicleLabel,
source: vehicle?.source,
vin: vehicle?.vin,
}}
/>
</div>
</div>
) : viewMode === "grid" ? (
<CategoryGrid categories={categoryTree} vehicleId={id} hideFilter />