DEV Community

Vin Lookup
Vin Lookup

Posted on

OpenTelemetry-Style Spans for VIN Decode Latency and Upstream Attribution

A free VIN decode looks like one button click. Under the hood it is normalize, validate, cache lookup, optional proxy hop, NHTSA DecodeVinValues, field mapping, and render. When p95 climbs, "the API is slow" is not a diagnosis -- you need spans that show which stage burned the budget and whether the wait was yours or upstream.

This post sketches a small OpenTelemetry-style span pattern in TypeScript: one parent decode span, child spans for cache and upstream, and attributes that stay useful without logging full VINs in clear text everywhere.

Why a single timer lies

Wrapping fetch in Date.now() deltas answers one question: how long did the handler take. It does not answer:

  • Was the miss path slow because of DNS, TLS, or vPIC itself?
  • Did single-flight waiters inflate "decode duration" while only one call ran?
  • Did normalization or check-digit work dominate on invalid input?
  • Did a cache SET after success add unexpected tail latency?
  • Did your edge proxy retry once and double the user-visible wait?

Without child spans, dashboards conflate product bugs with NHTSA weather. Support hears "decode is broken" when half the latency is a cold cache and a patient upstream.

Span model

Use a parent span vin.decode per user-facing request. Children:

  • vin.normalize -- charset, length, check digit (cheap, still worth counting failures)
  • vin.cache.get / vin.cache.set -- local or Redis
  • vin.upstream.vpic -- the outbound DecodeVinValues call
  • vin.map -- field shaping for the UI

Keep naming stable. Renaming spans every sprint destroys week-over-week charts. Prefer adding attributes to extending the name zoo.

Attributes that help attribution without turning logs into a VIN dump:

export type SpanStatus = "ok" | "error";

export type SpanTracer = {
  startSpan(
    name: string,
    attrs?: Record<string, string | number | boolean>,
  ): {
    setAttribute(key: string, value: string | number | boolean): void;
    end(status?: SpanStatus): void;
  };
};

const VIN_HASH_ATTR = "vin.sha256_12";

export async function decodeWithSpans(
  tracer: SpanTracer,
  rawVin: string,
  deps: {
    normalize: (raw: string) => { vin: string; ok: boolean; reason?: string };
    cacheGet: (vin: string) => Promise<Record<string, string> | null>;
    cacheSet: (vin: string, fields: Record<string, string>) => Promise<void>;
    fetchVpic: (vin: string) => Promise<Record<string, string>>;
    hashVin: (vin: string) => string;
  },
): Promise<Record<string, string> | { error: string }> {
  const parent = tracer.startSpan("vin.decode");
  const normSpan = tracer.startSpan("vin.normalize");
  const norm = deps.normalize(rawVin);
  if (!norm.ok) {
    normSpan.setAttribute("normalize.ok", false);
    normSpan.setAttribute("normalize.reason", norm.reason ?? "invalid");
    normSpan.end("error");
    parent.setAttribute("outcome", "invalid_input");
    parent.end("error");
    return { error: norm.reason ?? "invalid VIN" };
  }
  normSpan.setAttribute("normalize.ok", true);
  normSpan.end("ok");

  const vinHash = deps.hashVin(norm.vin).slice(0, 12);
  parent.setAttribute(VIN_HASH_ATTR, vinHash);

  const cacheSpan = tracer.startSpan("vin.cache.get", {
    [VIN_HASH_ATTR]: vinHash,
  });
  const cached = await deps.cacheGet(norm.vin);
  cacheSpan.setAttribute("cache.hit", Boolean(cached));
  cacheSpan.end("ok");
  if (cached) {
    parent.setAttribute("outcome", "cache_hit");
    parent.end("ok");
    return cached;
  }

  const up = tracer.startSpan("vin.upstream.vpic", {
    [VIN_HASH_ATTR]: vinHash,
    "upstream.system": "nhtsa_vpic",
  });
  try {
    const fields = await deps.fetchVpic(norm.vin);
    up.setAttribute("http.ok", true);
    up.end("ok");

    const mapSpan = tracer.startSpan("vin.map");
    mapSpan.setAttribute("field_count", Object.keys(fields).length);
    mapSpan.end("ok");

    const setSpan = tracer.startSpan("vin.cache.set");
    await deps.cacheSet(norm.vin, fields);
    setSpan.end("ok");

    parent.setAttribute("outcome", "upstream_ok");
    parent.end("ok");
    return fields;
  } catch (err) {
    up.setAttribute("http.ok", false);
    up.setAttribute(
      "error.type",
      err instanceof Error ? err.name : "unknown",
    );
    up.end("error");
    parent.setAttribute("outcome", "upstream_error");
    parent.end("error");
    return { error: "upstream decode failed" };
  }
}
Enter fullscreen mode Exit fullscreen mode

Wire SpanTracer to OpenTelemetry's API when you are ready; the shape above stays readable in unit tests with an in-memory recorder that pushes { name, durationMs, attrs } rows.

What to chart

  • Parent duration by outcome (cache_hit, upstream_ok, upstream_error, invalid_input)
  • vin.upstream.vpic duration alone -- your NHTSA SLA proxy
  • Cache hit ratio from cache.hit on vin.cache.get
  • Error rate on upstream spans, not on parents that include validation rejects
  • Map + cache set as a separate slice so post-processing regressions show up without blaming vPIC

Avoid putting the full VIN in span attributes in shared backends. A short hash prefix is enough to correlate support tickets when you already store the VIN in an access-controlled store.

Sampling and single-flight

If you collapse concurrent decodes for one VIN, record vin.singleflight.waited=true on waiters and keep the upstream span only on the leader. Otherwise every waiter looks like a full NHTSA round trip in naive timers. Sample aggressively on cache hits if volume is high; always keep upstream error spans -- they are rare and actionable.

Privacy and cardinality

Span attributes are not a free dump of request context. High-cardinality VIN strings blow up backend metric series and leak identifiers into vendor UIs. Prefer a truncated hash, a boolean cache.hit, and a small outcome enum. If you need the raw VIN for a support replay, store it in your own audit table keyed by request id -- not in every span exporter.

When you add HTTP status from a proxy, keep it on vin.upstream.vpic only. Putting status on the parent makes cache hits look like "200" forever and hides upstream 429 storms.

Takeaway

Treat VIN decode as a trace, not a stopwatch. Parent spans for the user journey, child spans for cache and vPIC, and attributes that name the outcome without inventing gear or trim claims. When latency spikes, you will know whether to tune your cache -- or wait on NHTSA.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)