feat(telemetry): enrich VIN decode events for Süper Panel observability
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Süper Panel VIN Decode Observability Module (SP-VIN-001) prerequisite.
The dashboard needs provider chain + cache + fallback context the
frontend can't see — emit those events from the backend.

VehiclesService.decodeVin now emits, at every terminal path:

1. provider_response_received (one per provider that ran)
   - provider, response_time_ms, status (success/no_data), cached,
     vin_brand, vin_sanitized, attempt_in_chain
   - Derived from ctx.timings.{pcat,emex,pl24,vin_api}; only providers
     that actually executed get an event.

2. provider_fallback_triggered (between consecutive attempts)
   - from_provider, to_provider, reason (timeout if budget aborted,
     else no_data), auto, attempt_number.

3. vin_decode_succeeded — winning provider, cache_hit, cache_source,
   response_time_ms, partial_result, fallback_used, provider_attempts,
   pl24_circuit_open, vin_sanitized.

4. vin_decode_failed — error_code (BUDGET_EXCEEDED | UNKNOWN_VIN),
   error_message, provider_attempted, response_time_ms, vin_sanitized.

Wired at three terminal points:
- DB cache hit (existing vehicle, no chain run)
- Unknown VIN failure (chain returned null)
- Full chain success (savedVehicle return)

Notes:
- VINs are sanitized (`WAUZZZ8K****`) before leaving the backend.
- Emission is wrapped in try/catch; a PostHog hiccup never breaks a
  user-facing decode.
- Frontend's legacy `vin_decoded` / `vin_decode_success` /
  `vin_decode_error` events stay as-is. The new backend events live
  alongside them with richer props.
- ML decoder fields default to false — VAG ML pipeline lands later.

Spec updated to pass the new PostHogService mock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 10:55:47 +03:00
parent b7a5b6996b
commit 4645c2700e
2 changed files with 147 additions and 3 deletions

View File

@@ -87,6 +87,10 @@ function createService(dbOrOverrides: any = {}) {
add: vi.fn().mockResolvedValue(undefined), add: vi.fn().mockResolvedValue(undefined),
}; };
const posthogService = {
captureForUser: vi.fn(),
};
const service = new VehiclesService( const service = new VehiclesService(
db as any, db as any,
prefetchQueue as any, prefetchQueue as any,
@@ -96,6 +100,7 @@ function createService(dbOrOverrides: any = {}) {
emexService as any, emexService as any,
partsCatalogsService as any, partsCatalogsService as any,
redisService as any, redisService as any,
posthogService as any,
); );
return { return {

View File

@@ -29,6 +29,7 @@ import { PL24Service } from "../integrations/pl24/pl24.service";
import { VinApiService } from "../integrations/vin-api/vin-api.service"; import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { PrefetchSource } from "../jobs/prefetch.types"; import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue"; import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
import { PostHogService } from "../posthog/posthog.service";
import { RedisService } from "../redis/redis.service"; import { RedisService } from "../redis/redis.service";
/** /**
@@ -73,8 +74,121 @@ export class VehiclesService {
private emexService: EmexService, private emexService: EmexService,
private partsCatalogsService: PartsCatalogsService, private partsCatalogsService: PartsCatalogsService,
private redis: RedisService, private redis: RedisService,
private posthog: PostHogService,
) {} ) {}
// ─── Süper Panel observability telemetry ──────────────────
// VIN Decode Observability Module (sp-vin-001) expects enriched events
// emitted from the backend where the provider chain + cache decisions are
// visible. Frontend already captures `vin_decoded` / `vin_decode_success`
// (legacy names, see search.tsx); the events below are richer companions
// with backend-only fields the dashboard depends on.
private static readonly PROVIDER_TIMING_KEYS: ReadonlyArray<{
provider: string;
timingKey: string;
}> = [
{ provider: "parts-catalogs", timingKey: "pcat" },
{ provider: "emex", timingKey: "emex" },
{ provider: "pl24", timingKey: "pl24" },
{ provider: "vin-api", timingKey: "vin_api" },
];
private sanitizeVin(vin: string): string {
const upper = vin.toUpperCase();
return upper.length >= 11 ? `${upper.slice(0, 11)}******` : `${upper.slice(0, 3)}***`;
}
private emitDecodeTelemetry(
userId: string,
vin: string,
ctx: ResolveContext,
final:
| {
kind: "success";
vehicleId: string;
brandName: string | null;
source: string;
durationMs: number;
partial: boolean;
}
| {
kind: "failure";
errorCode: string;
errorMessage: string;
durationMs: number;
},
): void {
try {
const vinSanitized = this.sanitizeVin(vin);
const brand = final.kind === "success" ? final.brandName : null;
const cacheSource = ctx.timings.cache_source;
const cacheHit =
cacheSource === "db_hit" || cacheSource === "redis_positive";
// 1. Per-provider response events from chain timings.
const attempts: string[] = [];
for (const { provider, timingKey } of VehiclesService.PROVIDER_TIMING_KEYS) {
const ms = ctx.timings[timingKey];
if (typeof ms !== "number") continue;
attempts.push(provider);
const wonForThisDecode =
final.kind === "success" && final.source === provider;
this.posthog.captureForUser(userId, "provider_response_received", {
provider,
response_time_ms: ms,
status: wonForThisDecode ? "success" : "no_data",
cached: false,
vin_brand: brand,
vin_sanitized: vinSanitized,
attempt_in_chain: attempts.length,
});
}
// 2. Fallback events between consecutive provider attempts.
for (let i = 1; i < attempts.length; i++) {
this.posthog.captureForUser(userId, "provider_fallback_triggered", {
from_provider: attempts[i - 1],
to_provider: attempts[i],
reason: ctx.timings.aborted ? "timeout" : "no_data",
auto: true,
attempt_number: i + 1,
vin_sanitized: vinSanitized,
});
}
// 3. Terminal vin_decode_{succeeded,failed} with full backend context.
if (final.kind === "success") {
this.posthog.captureForUser(userId, "vin_decode_succeeded", {
vehicle_id: final.vehicleId,
vin_brand: brand,
provider: final.source,
cache_hit: cacheHit,
cache_source: cacheSource ?? null,
response_time_ms: final.durationMs,
partial_result: final.partial,
fallback_used: attempts.length > 1,
provider_attempts: attempts,
ml_decoder_used: false,
vin_sanitized: vinSanitized,
pl24_circuit_open: ctx.timings.pl24_circuit_open ?? false,
});
} else {
this.posthog.captureForUser(userId, "vin_decode_failed", {
error_code: final.errorCode,
error_message: final.errorMessage,
provider_attempted: attempts,
response_time_ms: final.durationMs,
will_auto_retry: false,
vin_sanitized: vinSanitized,
pl24_circuit_open: ctx.timings.pl24_circuit_open ?? false,
});
}
} catch (err) {
// Telemetry must never break the user-facing decode.
this.logger.warn(`emitDecodeTelemetry failed: ${err instanceof Error ? err.message : err}`);
}
}
async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) { async decodeVin(vin: string, userId: string, pcatCarId?: string, emexCarIndex?: number) {
const startTime = Date.now(); const startTime = Date.now();
const ctx: ResolveContext = { const ctx: ResolveContext = {
@@ -99,16 +213,25 @@ export class VehiclesService {
await this.ensureUserVehicleLink(userId, existing.id); await this.ensureUserVehicleLink(userId, existing.id);
ctx.timings.cache_source = "db_hit"; ctx.timings.cache_source = "db_hit";
ctx.timings.result_kind = "vehicle"; ctx.timings.result_kind = "vehicle";
const durationMs = Date.now() - startTime;
await this.logQuery( await this.logQuery(
userId, userId,
vin, vin,
existing.brandId, existing.brandId,
"cache", "cache",
true, true,
Date.now() - startTime, durationMs,
undefined, undefined,
ctx.timings, ctx.timings,
); );
this.emitDecodeTelemetry(userId, vin, ctx, {
kind: "success",
vehicleId: existing.id,
brandName: existing.brandName,
source: "cache",
durationMs,
partial: !existing.brandName,
});
return existing; return existing;
} }
@@ -120,16 +243,23 @@ export class VehiclesService {
const errMsg = ctx.timings.aborted const errMsg = ctx.timings.aborted
? `Decode budget exceeded (${VehiclesService.RESOLVE_BUDGET_MS}ms)` ? `Decode budget exceeded (${VehiclesService.RESOLVE_BUDGET_MS}ms)`
: "Unknown VIN/brand"; : "Unknown VIN/brand";
const durationMs = Date.now() - startTime;
await this.logQuery( await this.logQuery(
userId, userId,
vin, vin,
null, null,
finalSource, finalSource,
false, false,
Date.now() - startTime, durationMs,
errMsg, errMsg,
ctx.timings, ctx.timings,
); );
this.emitDecodeTelemetry(userId, vin, ctx, {
kind: "failure",
errorCode: ctx.timings.aborted ? "BUDGET_EXCEEDED" : "UNKNOWN_VIN",
errorMessage: errMsg,
durationMs,
});
throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor."); throw new BadRequestException("Şase numarası tanınamadı. Marka desteklenmiyor.");
} }
@@ -222,16 +352,25 @@ export class VehiclesService {
await this.schedulePrefetch(savedVehicle.id, source as PrefetchSource); await this.schedulePrefetch(savedVehicle.id, source as PrefetchSource);
} }
const durationMs = Date.now() - startTime;
await this.logQuery( await this.logQuery(
userId, userId,
vin, vin,
brandId, brandId,
source, source,
true, true,
Date.now() - startTime, durationMs,
undefined, undefined,
ctx.timings, ctx.timings,
); );
this.emitDecodeTelemetry(userId, vin, ctx, {
kind: "success",
vehicleId: savedVehicle.id,
brandName: savedVehicle.brandName,
source,
durationMs,
partial: !savedVehicle.brandName || !savedVehicle.model,
});
return savedVehicle; return savedVehicle;
} }