feat(observability): report silent catalog UX degradations to Sentry
The catalog failures that hurt UX — a drill/parts fetch that fails into an empty
"couldn't load" panel, or a decoded vehicle whose category tree comes back empty
("model var ama parça yok") — all return HTTP 200 with a degraded body. Nothing
throws, so the global Sentry exception filter never sees them and they go
unnoticed (serkan's complaint was exactly this class). Report them explicitly.
- new common/catalog-degradation.ts: reportCatalogDegradation(kind, ctx),
fingerprinted by kind+source+brand so each failure mode collapses into one
countable Sentry issue (e.g. "drill-load-error · pl24/Ford — N events, M users").
- categories.service: capture on getCategoryWithParts loadError and on an empty
getCategoryTree, Redis-deduped to <=1 event/hour per category/vehicle so a
broken catalog can't flood the stream; telemetry never throws into the request.
tsc + biome clean, categories suite 10/10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ function createService(db: any) {
|
||||
const redis = {
|
||||
getJson: vi.fn().mockResolvedValue(null),
|
||||
setJson: vi.fn().mockResolvedValue(undefined),
|
||||
// Used by the Sentry degradation dedup; default to "not seen" so the path runs.
|
||||
exists: vi.fn().mockResolvedValue(false),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const pl24Service = {
|
||||
getCategories: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
type CatalogDegradationKind,
|
||||
reportCatalogDegradation,
|
||||
} from "../common/catalog-degradation";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
||||
@@ -438,6 +442,19 @@ export class CategoriesService {
|
||||
// it self-heals on the next request after the source recovers, while still
|
||||
// throttling re-decode attempts during a real outage.
|
||||
await this.redis.setJson(cacheKey, tree, tree.length > 0 ? 3600 : 60);
|
||||
|
||||
// Decoded vehicle but the catalog tree is empty — "model geldi ama kategori
|
||||
// yok". Another silent (HTTP 200, no throw) failure; surface persistent
|
||||
// upstream/seed gaps to Sentry so they get triaged instead of overlooked.
|
||||
if (tree.length === 0) {
|
||||
await this.reportDegradationOnce("empty-tree", vehicleId, {
|
||||
vehicleId,
|
||||
vin: vehicle.vin,
|
||||
brand: vehicle.brandName,
|
||||
model: vehicle.model,
|
||||
source: vehicle.source,
|
||||
});
|
||||
}
|
||||
return tree;
|
||||
}
|
||||
|
||||
@@ -899,9 +916,59 @@ export class CategoriesService {
|
||||
// Attach the full ancestor trail so the client can render a complete,
|
||||
// reliable breadcrumb regardless of what's in its tree cache.
|
||||
const ancestors = await this.getAncestors(categoryId);
|
||||
// A loadError means the drill / parts fetch failed and the user is staring
|
||||
// at an empty "couldn't load" panel instead of parts — a silent UX failure
|
||||
// (HTTP 200, nothing thrown) the exception filter never sees. Surface it.
|
||||
if ((result as { loadError?: boolean }).loadError) {
|
||||
await this.reportDegradationOnce("drill-load-error", categoryId);
|
||||
}
|
||||
return { ...result, ancestors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a silent catalog UX failure to Sentry, deduped to at most once per
|
||||
* hour per vehicle/category (Redis) so a broken catalog can't flood the issue
|
||||
* stream. With no ctx, dedupId is treated as a categoryId and the vehicle is
|
||||
* looked up for context. Never throws into the request path.
|
||||
*/
|
||||
private async reportDegradationOnce(
|
||||
kind: CatalogDegradationKind,
|
||||
dedupId: string,
|
||||
ctx?: Parameters<typeof reportCatalogDegradation>[1],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const dedupKey = `sentry:cat-degraded:${kind}:${dedupId}`;
|
||||
if (await this.redis.exists(dedupKey)) return;
|
||||
await this.redis.set(dedupKey, "1", 3600);
|
||||
|
||||
if (ctx) {
|
||||
reportCatalogDegradation(kind, ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const [cat] = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.id, dedupId))
|
||||
.limit(1);
|
||||
const [veh] = cat?.vehicleId
|
||||
? await this.db.select().from(vehicles).where(eq(vehicles.id, cat.vehicleId)).limit(1)
|
||||
: [];
|
||||
reportCatalogDegradation(kind, {
|
||||
vehicleId: cat?.vehicleId ?? null,
|
||||
vin: veh?.vin ?? null,
|
||||
brand: veh?.brandName ?? null,
|
||||
model: veh?.model ?? null,
|
||||
source: cat?.source ?? null,
|
||||
categoryId: dedupId,
|
||||
categoryName: cat?.name ?? null,
|
||||
linkPath: cat?.linkPath ?? null,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never break the request.
|
||||
}
|
||||
}
|
||||
|
||||
private async getCategoryWithPartsInner(categoryId: string) {
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
|
||||
58
apps/api/src/common/catalog-degradation.ts
Normal file
58
apps/api/src/common/catalog-degradation.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as Sentry from "@sentry/nestjs";
|
||||
|
||||
/**
|
||||
* A *silent* catalog UX failure: the request succeeds (HTTP 200) but the user
|
||||
* gets a degraded result — an empty parts panel / retryable load error, or an
|
||||
* empty category tree on a vehicle that decoded fine. Because nothing throws,
|
||||
* the global exception filter never sees these, so they go unnoticed — exactly
|
||||
* how serkan's "şase girdim, model var ama parça yok" sat invisible. We report
|
||||
* them to Sentry explicitly so they surface and get triaged like real errors.
|
||||
*/
|
||||
export type CatalogDegradationKind =
|
||||
| "empty-tree" // vehicle decoded but the category tree came back empty
|
||||
| "drill-load-error"; // a category drill/parts fetch failed → empty/retry panel, not parts
|
||||
|
||||
export interface CatalogDegradationContext {
|
||||
vehicleId?: string | null;
|
||||
vin?: string | null;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
source?: string | null;
|
||||
categoryId?: string | null;
|
||||
categoryName?: string | null;
|
||||
linkPath?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a catalog degradation to Sentry as a warning. Fingerprinted by
|
||||
* kind + source + brand so every instance of one failure mode collapses into a
|
||||
* single, countable Sentry issue ("drill-load-error · pl24/Ford — 1.2k events,
|
||||
* 80 users") rather than thousands of unique events. Call sites must dedup
|
||||
* (e.g. a short Redis TTL per vehicle/category) before invoking this, and must
|
||||
* never let it throw into the request path.
|
||||
*/
|
||||
export function reportCatalogDegradation(
|
||||
kind: CatalogDegradationKind,
|
||||
ctx: CatalogDegradationContext,
|
||||
): void {
|
||||
const source = ctx.source ?? "unknown";
|
||||
const brand = ctx.brand ?? "unknown";
|
||||
Sentry.captureMessage(`catalog degraded: ${kind} (${source}/${brand})`, {
|
||||
level: "warning",
|
||||
tags: {
|
||||
catalog_degradation: kind,
|
||||
catalog_source: source,
|
||||
catalog_brand: brand,
|
||||
},
|
||||
// Group by failure mode, not by individual vehicle/category.
|
||||
fingerprint: ["catalog-degradation", kind, source, brand.toLowerCase()],
|
||||
extra: {
|
||||
vehicleId: ctx.vehicleId ?? null,
|
||||
vin: ctx.vin ?? null,
|
||||
model: ctx.model ?? null,
|
||||
categoryId: ctx.categoryId ?? null,
|
||||
categoryName: ctx.categoryName ?? null,
|
||||
linkPath: ctx.linkPath ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user