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
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:
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user