Validation Telemetry
When an input payload fails validation today, you get one of two bad outcomes:
the safeParse throws (no span, no metric, no alert) or a handler swallows it
silently. Either way the mismatch is invisible to the telemetry an agent reads
at 3am.
autotel/validate connects validation to telemetry at the boundary where bad
data enters. Declare the expected shape once; every mismatch becomes a
validation.* span attribute and a counter increment.
Two modes
Section titled “Two modes”| Mode | Behaviour |
|---|---|
reject (default) | Record telemetry, then throw a 400-shaped structured error so the boundary fails cleanly |
observe | Record telemetry, return the raw input so the handler continues — measure real-world drift before you enforce it |
import { z } from 'zod';import { defineValidator } from 'autotel/validate';
const OrderBody = defineValidator( 'POST /orders', z.object({ items: z.array(z.object({ sku: z.string(), qty: z.number().int() })), }), { boundary: 'http', toJsonSchema: (s) => z.toJSONSchema(s) },);
// reject mode (default): records the mismatch, then throws a 400 structured errorapp.post('/orders', (req, res) => { const order = OrderBody.parse(req.body); // throws → your error middleware maps to 400 return createOrder(order);});
// observe mode: records, never throws — handler decides what to doconst result = OrderBody.safeParse(req.body);if (!result.success) { // result.issues is [{ path, code, expected? }] — safe to log metrics.noteDrift(result.issues);}parse() applies the mode; safeParse() always returns a discriminated result
and never throws.
What gets recorded
Section titled “What gets recorded”On the active span:
| Attribute | Example |
|---|---|
validation.name | POST /orders |
validation.boundary | http |
validation.mode | reject |
validation.issue.count | 2 |
validation.issue.paths | items.0.qty,items.1.sku |
validation.issue.codes | invalid_type,too_small |
validation.hash | sha256 of the declared shape (when toJsonSchema is given) |
Plus the counter autotel.validation.mismatches, labelled
{ boundary, validation, mode }.
The attribute keys are exported as VALIDATION_ATTR from
autotel/validation-attributes (dependency-free) for dashboards, CLI filters,
and alert rules.
Alerting — reuse what you have
Section titled “Alerting — reuse what you have”Validation telemetry is first-class on its own metric — not a security event. A malformed body is usually a bug or version skew, not an attack. So:
- Metrics / paging: alert on
autotel_validation_mismatches_total(e.g. a spike on onevalidationlabel after a deploy). - Agent triage: filter spans by
validation.name/validation.boundary.
If a specific validator genuinely is a security signal (injection-shaped input), escalate it explicitly — never automatically:
import { onValidationMismatch } from 'autotel/validate';
// opt in, e.g. forward to autotel-audit's securityEvent for the cases you chooseconst off = onValidationMismatch((m) => { if (m.name === 'POST /login') securityEvent({ /* ... */ });});There is no package-presence-driven magic: nothing escalates unless you register a handler.
Applying it at each boundary
Section titled “Applying it at each boundary”defineValidator is the primitive. Wire it wherever input enters:
- HTTP: call
.parse(req.body)in the handler (reject) or branch on.safeParse()(observe). Your existing error middleware maps the thrown 400. - Events: validate before
track(). - Messaging: validate the decoded message; on
reject, route to your DLQ.
- Not Zod-specific: any object with a
safeParse(input)returning{ success, data }or{ success: false, error }works. - Fail-open: a bug in the recorder never breaks the validated boundary.
- Backward compatible:
defineEventis unchanged — it still throws on a bad payload.defineValidatoris the new, observable path.
Related
Section titled “Related”- Configuration —
init()sets up the meter the counter writes to. - Security Observability — where you’d route the narrow subset of mismatches that are genuinely security signals.