Fundamentals
Monitoring
Log and observe your capabilities at runtime.
Capability-level logging
Use tap(log()) anywhere in a capability to emit a structured log of the current exchange without altering it. Use tap(debug()) for verbose output you only want visible at debug level. Both can also be used as a final destination with .to().
import { craft, simple, log, debug } from '@routecraft/routecraft'
export default craft()
.id('order-pipeline')
.from(simple({ orderId: '123' }))
.tap(debug()) // debug-level: verbose, filtered out by default
.transform(enrichOrder)
.tap(log()) // info-level: visible in normal operation
.to(log()) // log the final exchange as the destination
Each log entry includes contextId, routeId, exchangeId, and correlationId for end-to-end tracing in your log aggregator.
To set the log level, pass --log-level to the CLI:
craft start --log-level debug
Events to alarm on
The runtime emits an event for everything that happens inside it. Three of them are the ones an operator watches:
The runtime already logs each of them. To page someone, forward them to your alerting from a plugin, a file in plugins/ that craft start loads on its own. context:error also fires for both of the others (an unhandled exchange failure, and a dead source whose capability then fails to run), so forwarding it alone sends one alert per failure. Set ALERT_WEBHOOK_URL in the environment to your alerting tool's incoming webhook:
// plugins/alerting.ts
import type { CraftContext, CraftPlugin } from '@routecraft/routecraft'
async function alert(summary: string, routeId: string | undefined, error: unknown) {
const url = process.env.ALERT_WEBHOOK_URL
if (!url) throw new Error('ALERT_WEBHOOK_URL is not set')
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ summary, routeId, error: String(error) }),
})
if (!response.ok) throw new Error(`alert webhook answered ${response.status}`)
}
export default {
name: 'alerting',
apply(ctx: CraftContext) {
// An alert that cannot be delivered is logged, never allowed to crash the instance.
const send = (summary: string, routeId: string | undefined, error: unknown) =>
alert(summary, routeId, error).catch((err) => ctx.logger.warn({ err }, 'Alert not sent'))
ctx.on('context:error', ({ details: { error, route } }) => {
void send('Routecraft failure', route?.definition.id, error)
})
},
} satisfies CraftPlugin
Events covers subscribing from craft.config.ts without a plugin, and the Events reference lists every event with its payload. Plugins covers the plugin lifecycle.
Health endpoints
Logs and events tell you what happened. An orchestrator needs a different thing: an answer, right now, to whether this instance should be restarted, sent traffic, or paged about. That is the ops plugin.
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
servers: { ops: { host: '0.0.0.0', port: 9090 } },
ops: { server: 'ops' },
})
No application code is needed. Route lifecycle, circuit-breaker position and the context's own serving state are derived from the same events this page describes, so the surface is truthful the moment you enable it.
Three signals, separated by what acting on the answer does:
All three answer GET and HEAD, so a monitor can take the status code without pulling the body, which on the aggregate is the whole component map. Everything else is refused with 405 and Allow: GET, HEAD.
Point a restart trigger only at /health/live. It carries no dependency state at all, so a third party going down can never restart every replica in a loop.
An exchange error never changes a route's status. A refused caller or a validation failure is a healthy route behaving correctly, so escalating it would turn every scope gap into an incident. Repeated failure reaches a route's status through a circuit breaker, which is the thing that actually stops it serving.
Indicators are the deliberate exception: an indicator bound to a probe route reports down when that route's exchange fails, because for a probe the exchange is the check. Bind probe routes only, never business routes, or every expected refusal would report the dependency down.
Telemetry
The telemetry config key instruments the framework with OpenTelemetry traces and persists data to a local SQLite database for craft tui.
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
telemetry: {},
})
The database is written to .routecraft/telemetry.db in the current working directory. The SQLite sink uses Bun's built-in bun:sqlite under Bun and the optional peer better-sqlite3 under Node. On Node without that peer, the sink disables itself with a warn log and only the OpenTelemetry path runs; configure a tracerProvider with an OTLP exporter for production telemetry.
Configuration
import { defineConfig } from '@routecraft/routecraft'
export const craftConfig = defineConfig({
telemetry: {
sqlite: {
dbPath: './logs/telemetry.db', // custom path (default .routecraft/telemetry.db)
eventBatchSize: 100, // events buffered before flush (default 50)
eventFlushInterval: 2000, // max time between flushes (default 1000)
maxExchanges: 50_000, // rows to retain (default 50000, 0 to disable)
maxEvents: 100_000, // rows to retain (default 100000, 0 to disable)
},
},
})
Exporting traces to an external provider
Because telemetry uses OpenTelemetry, you can export traces to any OTel-compatible backend alongside the local SQLite database. Install the OTel SDK and an OTLP exporter:
bun add @opentelemetry/sdk-trace-base @opentelemetry/exporter-trace-otlp-http
Then configure a TracerProvider and pass it as tracerProvider. Here is an example using Better Stack:
import { defineConfig } from '@routecraft/routecraft'
import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
const tracerProvider = new BasicTracerProvider()
tracerProvider.addSpanProcessor(
new BatchSpanProcessor(
new OTLPTraceExporter({
url: 'https://in-otel.logs.betterstack.com/traces',
headers: { Authorization: 'Bearer <YOUR_SOURCE_TOKEN>' },
})
)
)
tracerProvider.register()
export const craftConfig = defineConfig({
telemetry: { tracerProvider },
})
This sends OTel traces to Better Stack while keeping the local SQLite database for the TUI. The same pattern works with Grafana Tempo, Datadog, Jaeger, or any backend that accepts OTLP. Just change the exporter URL and headers.
To disable the SQLite backend entirely (external only):
defineConfig({ telemetry: { tracerProvider, disableSqlite: true } })
What gets traced
Telemetry creates OTel spans for:
- Route lifecycle: registration, start, stop (long-lived spans)
- Exchange lifecycle: start, complete, fail, drop (per-message spans with duration)
- Step execution: each adapter operation as a child span (from, to, process, filter, etc.)
Span attributes use the routecraft.* namespace (routecraft.route.id, routecraft.exchange.id, routecraft.correlation.id, etc.) so you can filter and query traces in your provider's UI.
Terminal UI
Once telemetry is on, launch the terminal UI in a separate terminal to browse routes, exchanges, and the live event stream:
craft tui
See the Terminal UI guide for navigation and options.
Related
Events
Subscribe to runtime events from craft.config.ts or a plugin, and filter them by identity.
Plugins
Write and register plugins, and the lifecycle phases they run in.
Terminal UI
Browse routes, exchanges, and live events from the terminal.