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

@@ -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 />