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:
2026-06-09 17:41:51 +03:00
parent 5903151692
commit e8e9771759
3 changed files with 128 additions and 0 deletions

View File

@@ -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([]),

View File

@@ -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()