Merge pull request 'feat(telemetry): enrich VIN decode events for Süper Panel observability' (#32) from dev into main

This commit was merged in pull request #32.
This commit is contained in:
2026-05-18 07:56:02 +00:00
2 changed files with 147 additions and 3 deletions

View File

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

View File

@@ -29,6 +29,7 @@ import { PL24Service } from "../integrations/pl24/pl24.service";
import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
import { PostHogService } from "../posthog/posthog.service";
import { RedisService } from "../redis/redis.service";
/**
@@ -73,8 +74,121 @@ export class VehiclesService {
private emexService: EmexService,
private partsCatalogsService: PartsCatalogsService,
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) {
const startTime = Date.now();
const ctx: ResolveContext = {
@@ -99,16 +213,25 @@ export class VehiclesService {
await this.ensureUserVehicleLink(userId, existing.id);
ctx.timings.cache_source = "db_hit";
ctx.timings.result_kind = "vehicle";
const durationMs = Date.now() - startTime;
await this.logQuery(
userId,
vin,
existing.brandId,
"cache",
true,
Date.now() - startTime,
durationMs,
undefined,
ctx.timings,
);
this.emitDecodeTelemetry(userId, vin, ctx, {
kind: "success",
vehicleId: existing.id,
brandName: existing.brandName,
source: "cache",
durationMs,
partial: !existing.brandName,
});
return existing;
}
@@ -120,16 +243,23 @@ export class VehiclesService {
const errMsg = ctx.timings.aborted
? `Decode budget exceeded (${VehiclesService.RESOLVE_BUDGET_MS}ms)`
: "Unknown VIN/brand";
const durationMs = Date.now() - startTime;
await this.logQuery(
userId,
vin,
null,
finalSource,
false,
Date.now() - startTime,
durationMs,
errMsg,
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.");
}
@@ -222,16 +352,25 @@ export class VehiclesService {
await this.schedulePrefetch(savedVehicle.id, source as PrefetchSource);
}
const durationMs = Date.now() - startTime;
await this.logQuery(
userId,
vin,
brandId,
source,
true,
Date.now() - startTime,
durationMs,
undefined,
ctx.timings,
);
this.emitDecodeTelemetry(userId, vin, ctx, {
kind: "success",
vehicleId: savedVehicle.id,
brandName: savedVehicle.brandName,
source,
durationMs,
partial: !savedVehicle.brandName || !savedVehicle.model,
});
return savedVehicle;
}