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.