Skip to content

Observability and Debugging

TypoKit treats observability as a first-class concern, not an afterthought. Every request automatically gets:

  1. A trace ID — A unique identifier that correlates logs, spans, and errors across the entire request lifecycle.
  2. Structured logs — JSON-formatted log entries with automatic context enrichment (route, phase, adapter).
  3. OpenTelemetry spans — Distributed traces covering middleware, validation, handler execution, and serialization.
  4. AI-friendly error context — Structured error responses that include schema references, validation failures, and repair suggestions.

Every handler and middleware receives a logger via ctx.log. This logger automatically includes the current request’s traceId, route, and lifecycle phase in every log entry.

interface Logger {
trace(message: string, data?: Record<string, unknown>): void;
debug(message: string, data?: Record<string, unknown>): void;
info(message: string, data?: Record<string, unknown>): void;
warn(message: string, data?: Record<string, unknown>): void;
error(message: string, data?: Record<string, unknown>): void;
fatal(message: string, data?: Record<string, unknown>): void;
}
"POST /users": async ({ body, ctx }) => {
ctx.log.info("Creating user", { email: body.email });
const user = await userService.create(body, ctx);
ctx.log.info("User created", { userId: user.id });
return user;
}

Every log entry emitted by ctx.log is a structured JSON object:

{
"level": "info",
"message": "Creating user",
"timestamp": "2025-03-01T10:15:32.123Z",
"traceId": "a1b2c3d4e5f6...",
"route": "POST /users",
"phase": "handler",
"requestId": "req-xyz-789",
"serverAdapter": "fastify",
"data": {
"email": "alice@example.com"
}
}

The fields traceId, route, phase, requestId, and serverAdapter are automatically injected — you never need to pass them manually.

Configure logging behavior in createApp():

createApp({
logging: {
level: "info", // "trace" | "debug" | "info" | "warn" | "error" | "fatal"
redact: [
"*.password",
"*.token",
"authorization",
],
},
})

The StructuredLogger emits to multiple sinks simultaneously:

SinkDestinationWhen Active
StdoutSinkJSON to consoleAlways
OtelLogSinkOTLP /v1/logs endpointWhen telemetry is enabled
DebugSidecar/_debug/logs endpointWhen debug plugin is active

All sinks receive the same enriched log entries, ensuring consistency across your observability stack.

TypoKit provides native OpenTelemetry support via the @typokit/otel package. Enable it in your app configuration to get distributed traces and metrics with zero manual instrumentation.

createApp({
telemetry: {
tracing: true,
metrics: true,
exporter: "otlp", // "otlp" | "console"
endpoint: "http://localhost:4318", // OTLP collector endpoint
serviceName: "my-api",
},
})

TypoKit creates spans for every phase of the request lifecycle:

[Root Span: POST /users]
├─ [middleware:logging] 1ms
├─ [middleware:auth] 12ms
├─ [validation:params] 0ms ✓
├─ [validation:query] 0ms ✓
├─ [validation:body] 2ms ✓
├─ [handler] 45ms
├─ [serialization] 1ms
└─ [server:write] 0ms

Each span includes:

interface SpanData {
traceId: string; // 32-char hex trace ID
spanId: string; // 16-char hex span ID
parentSpanId?: string; // Links to parent span
name: string; // e.g., "middleware:auth", "handler"
kind: "server" | "internal";
startTime: string; // ISO 8601
endTime?: string;
durationMs?: number;
status: "ok" | "error" | "unset";
attributes: Record<string, string | number | boolean>;
}

TypoKit populates these attributes automatically on every root span:

AttributeExampleDescription
http.methodPOSTHTTP method
http.route/users/:idRoute pattern
http.status_code200Response status
error.message"User not found"Present when status ≥ 400

You can add custom attributes in handlers:

ctx.span?.setAttribute("user.tier", "premium");
ExporterUse Case
ConsoleSpanExporterDev mode — outputs JSON to stdout
OtlpSpanExporterProduction — POST to OTLP-compatible collector
NoopSpanExporterTesting — silently discards spans

The OtelLogSink bridges ctx.log entries into OTLP log records, attaching trace context (traceId, spanId) so logs and traces correlate in your observability backend (Jaeger, Grafana, Datadog, etc.).

Severity mapping follows the OpenTelemetry specification:

Log LevelOTel Severity Number
trace1
debug5
info9
warn13
error17
fatal21

Every request produces a complete lifecycle trace — a JSON object that records the timing and result of each processing phase:

{
"traceId": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
"route": "POST /users",
"server": "fastify",
"lifecycle": [
{ "phase": "received", "timestamp": "2025-03-01T10:15:32.100Z", "durationMs": 0 },
{ "phase": "server:normalize", "timestamp": "2025-03-01T10:15:32.101Z", "durationMs": 1 },
{ "phase": "middleware:logging", "timestamp": "2025-03-01T10:15:32.102Z", "durationMs": 1 },
{ "phase": "middleware:auth", "timestamp": "2025-03-01T10:15:32.103Z", "durationMs": 12 },
{ "phase": "validation:params", "timestamp": "2025-03-01T10:15:32.115Z", "durationMs": 0, "result": "pass" },
{ "phase": "validation:query", "timestamp": "2025-03-01T10:15:32.115Z", "durationMs": 0, "result": "pass" },
{ "phase": "validation:body", "timestamp": "2025-03-01T10:15:32.115Z", "durationMs": 2, "result": "pass" },
{ "phase": "handler", "timestamp": "2025-03-01T10:15:32.117Z", "durationMs": 45 },
{ "phase": "serialization", "timestamp": "2025-03-01T10:15:32.162Z", "durationMs": 1 },
{ "phase": "server:write", "timestamp": "2025-03-01T10:15:32.163Z", "durationMs": 0 },
{ "phase": "response", "timestamp": "2025-03-01T10:15:32.163Z", "durationMs": 0, "status": 200 }
],
"totalMs": 63
}

The debug sidecar is a lightweight HTTP server that runs alongside your application, exposing introspection endpoints for route tables, middleware chains, error logs, performance stats, and more.

import { debugPlugin } from "@typokit/plugin-debug";
createApp({
plugins: [
debugPlugin({
port: 9800, // default sidecar port
production: false, // dev-only by default
}),
],
})
FeatureDev Mode (default)Production Mode
AuthenticationNoneAPI key via X-Debug-Key header
IP AllowlistDisabledConfigurable CIDR ranges
Bind Address0.0.0.0127.0.0.1 (internal only)
Rate LimitingDisabledConfigurable per-IP limits
Field RedactionDisabledActive for sensitive fields
AccessFullRead-only endpoints only

Production configuration:

debugPlugin({
port: 9800,
production: true,
security: {
apiKey: process.env.DEBUG_API_KEY,
allowlist: ["127.0.0.1", "10.0.0.0/8"],
hostname: "127.0.0.1",
redact: ["*.password", "authorization"],
rateLimit: 100,
rateLimitWindow: 60000, // 1 minute
},
})

All endpoints return JSON and are served on the sidecar port (default 9800):

EndpointDescription
GET /_debug/routesAll registered routes with middleware, validators, and serializers
GET /_debug/routes/:method/:pathSingle route details
GET /_debug/middlewareFull middleware chain with execution priorities
GET /_debug/depsService dependency graph
GET /_debug/schema/:nameType definition and where it’s used (routes, migrations, tests)
GET /_debug/errors?since=1h&limit=100Recent errors with traceId, status, route, and phase
GET /_debug/performance?route=/users&window=24hLatency percentiles (p50/p95/p99) and throughput
GET /_debug/healthServer health: connections, memory, event loop lag
GET /_debug/serverActive adapter, platform, port, status
GET /_debug/build-pipelineRegistered build hooks and execution order
GET /_debug/spans?since=1mRecent OTel spans (structured)
GET /_debug/logs?since=1m&level=errorRecent log entries with full context

Example: Inspecting a route

Terminal window
curl http://localhost:9800/_debug/routes/POST/users
{
"method": "POST",
"path": "/users",
"ref": "users#create",
"middleware": ["logging", "auth"],
"validators": {
"body": "CreateUserInput"
},
"serializer": "User"
}

Example: Checking recent errors

Terminal window
curl http://localhost:9800/_debug/errors?since=5m&limit=5
[
{
"timestamp": "2025-03-01T10:15:32.123Z",
"code": "VALIDATION_FAILED",
"status": 400,
"message": "Request body validation failed",
"route": "POST /users",
"phase": "validation:body",
"details": { "field": "email", "expected": "string & format:email" }
}
]

The typokit inspect family of commands lets you query framework state from the terminal — useful for debugging, CI pipelines, and AI agent introspection. All commands support a --json flag for machine-readable output.

Terminal window
typokit inspect routes [--json] # All registered routes
typokit inspect route "GET /users/:id" [--json] # Single route with full details
typokit inspect middleware [--json] # Middleware chain with priorities
typokit inspect schema User [--json] # Type definition + usages
typokit inspect deps [--json] # Service dependency graph
typokit inspect errors [--last 10] [--json] # Recent errors
typokit inspect performance [--json] # Latency percentiles per route
typokit inspect build-pipeline [--json] # Hook registration order
typokit inspect server [--json] # Active adapter info
Terminal window
typokit inspect schema User --json
{
"name": "User",
"properties": {
"id": { "type": "string", "tags": { "id": true, "format": "uuid", "generated": true } },
"email": { "type": "string", "tags": { "format": "email", "unique": true } },
"name": { "type": "string", "tags": { "maxLength": "100" } },
"role": { "type": "string", "tags": { "default": "member" } },
"createdAt": { "type": "Date", "tags": { "generated": true } }
},
"usedIn": [
"GET /users",
"GET /users/:id",
"POST /users",
"PUT /users/:id",
"migration:001_create_users"
]
}
Terminal window
typokit inspect performance --json
[
{ "route": "GET /users", "p50": 12, "p95": 45, "p99": 120, "count": 1842 },
{ "route": "POST /users", "p50": 35, "p95": 89, "p99": 210, "count": 523 },
{ "route": "GET /users/:id", "p50": 8, "p95": 22, "p99": 55, "count": 4210 }
]

TypoKit’s error responses are designed to be machine-readable — not just for human developers, but for AI agents that need to understand what went wrong and how to fix it.

Every error returns a consistent JSON structure:

interface ErrorResponse {
error: {
code: string; // e.g., "VALIDATION_FAILED", "USER_NOT_FOUND"
message: string; // Human-readable description
details?: Record<string, unknown>;
traceId?: string; // For distributed trace correlation
};
}

When the debug sidecar is active or in development mode, errors include additional context specifically designed for AI agent consumption:

{
"error": "VALIDATION_FAILED",
"traceId": "a1b2c3d4e5f67890",
"route": "POST /users",
"phase": "request_validation",
"schema": "CreateUserInput",
"server": "fastify",
"failures": [
{
"path": "$.email",
"expected": "string & format:email",
"received": "number (42)",
"suggestion": "The 'email' field expects a valid email string."
}
],
"timestamp": "2025-03-01T10:15:32.123Z",
"requestId": "req-xyz-789",
"serverAdapter": "fastify"
}

Key fields for AI agents:

FieldPurpose
schemaThe TypeScript type that validation ran against
failures[].pathJSONPath to the invalid field
failures[].expectedWhat the schema expects (type + constraints)
failures[].receivedWhat was actually received
failures[].suggestionHuman/AI-readable fix suggestion
traceIdCorrelate with logs and spans for full context
phaseWhich lifecycle phase failed (validation, handler, middleware)

All observability signals are correlated via traceId — every log, span, and error from the same request shares a single trace identifier:

Handler Code (ctx.log.info("..."))
[StructuredLogger]
├→ StdoutSink (JSON to console)
├→ OtelLogSink (OTLP /v1/logs endpoint)
└→ DebugSidecar (/_debug/logs endpoint)
[Tracer / Spans]
├→ ConsoleSpanExporter (JSON to console in dev)
├→ OtlpSpanExporter (OTLP /v1/traces endpoint)
└→ DebugSidecar (/_debug/spans endpoint)
[AppError thrown]
├→ ErrorMiddleware (catches → serializes ErrorResponse)
├→ ctx.log.error (logs error with full context)
├→ Span.setError (marks span as errored)
└→ DebugSidecar (/_debug/errors endpoint)

This unified flow means you can:

  1. Start from a trace ID in an error response → find every log and span for that request.
  2. Start from a log entry → jump to the parent trace to see the full lifecycle.
  3. Start from a span → see related logs within that phase and any errors that occurred.