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:
63
apps/web/src/components/catalog/report-catalog-issue.tsx
Normal file
63
apps/web/src/components/catalog/report-catalog-issue.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user