Observability & operations
Eventboat exposes three operations surfaces that share one implementation:
the MCP tools, the Admin REST endpoints (plus SSE and an embedded read-only
UI), and the --json CLI outputs are all thin shells over
internal/ops.Service — REST, MCP and CLI emit the same Go structs and JSON
shapes. Telemetry is OpenTelemetry with dual export (Prometheus pull +
OTLP/HTTP push); the metric set is fixed and enumerated in
internal/obs/obs.go.
Metrics
One MeterProvider, two readers (internal/obs/obs.go): Prometheus
exposition at /metrics and, when telemetry.otlp_endpoint is set, an
OTLP/HTTP push reader (10s interval). Fully disabled telemetry costs nothing
(noop providers; every Obs helper is nil-receiver safe). Every instrument
carries the eventboat_ prefix (the static set below, plus the per-source
counters created lazily on first use):
Counters
| Metric | Labels | Counts |
|---|---|---|
eventboat_messages_in_total | pipeline, source | messages accepted into the spool |
eventboat_messages_committed_total | pipeline | messages reaching a terminal state |
eventboat_dead_letter_total | pipeline, node, reason_class | dead-lettered messages |
eventboat_dlq_write_failures_total | pipeline | failed dead-letter writes (commit blocked) |
eventboat_cel_eval_errors_total | pipeline, edge, lang | predicate evaluation errors (treated as not-passed); lang is cel or cesql, the name keeps “cel” for M2 continuity |
eventboat_fanout_no_match_total | pipeline, node | messages filtered by zero matching edges (incl. zero transform outputs) |
eventboat_delivery_retries_total | pipeline, node | delivery retry attempts |
eventboat_optional_drops_total | pipeline, edge | drops on failed required: false edges |
eventboat_decode_errors_total | pipeline, source | decode failures at entry |
eventboat_spool_failures_total | pipeline | spool append failures (message not delivered) |
eventboat_backpressure_events_total | pipeline, source | admissions blocked by the high watermark |
eventboat_script_step_budget_exhausted_total | pipeline, node | Starlark executions hitting the 100k step budget |
eventboat_wasm_timeouts_total | pipeline, node | WASM invocations killed by timeout_ms |
eventboat_jobs_started_total | pipeline, trigger | job runs started |
eventboat_jobs_overlap_skipped_total | pipeline | triggers rejected by overlap: skip |
eventboat_jobs_catchup_skipped_total | pipeline | catch-up episodes whose missed ticks fell outside catchup_window (counted once per episode: the catch-up locates the newest in-window tick by bisection and never walks the missed list — candidate 08) |
eventboat_jobs_completed_total | pipeline, status | runs completed by terminal status |
eventboat_job_rows_read_total | pipeline | rows read by job runs |
eventboat_job_rows_delivered_total | pipeline | rows delivered by job runs |
eventboat_plugin_restarts_total | plugin | supervisor respawns of crashed gRPC plugin processes (grpc.restart: restart) |
eventboat_source_<counter>_total | pipeline, node | per-source health counters, created lazily on first use: the file source’s lines_read, lines_skipped, lines_truncated, rotations and multiline_merges (ops diffs the plugin’s monotonic totals on each status snapshot and writes only positive deltas; a source restart re-baselines) |
reason_class is the dead-letter class the engine recorded with the
message (store.DeadLetter.Class) — decode, codec, encode,
delivery, canceled, or a transform failure kind (steps, timeout,
guest, compile, runtime, other). It is never re-derived from the
reason text: candidate 07 deleted the prefix-matching obs.ReasonClass, so
the label values changed ("script" became the failure kind, "encoder"
became codec, and an "encode:" failure is now encode, never other).
Histograms (unit: seconds)
| Metric | Labels | Observes |
|---|---|---|
eventboat_script_duration_seconds | pipeline, node | Starlark script execution |
eventboat_wasm_transform_duration_seconds | pipeline, node | WASM invocations (killed calls never reach it) |
eventboat_sink_write_duration_seconds | pipeline, node | sink batch writes (each attempt) |
eventboat_job_duration_seconds | pipeline, status | job run wall-clock |
eventboat_commit_latency_seconds | pipeline | accept-to-commit latency |
eventboat_spool_append_seconds | pipeline | durable spool append latency, group-commit wait included (P1) |
Gauges
| Metric | Labels | Reports |
|---|---|---|
eventboat_in_flight_messages | pipeline | uncommitted messages in execution |
eventboat_spool_depth | pipeline | spooled messages beyond the checkpoint (arrivedMax - committedThrough) |
eventboat_pipeline_paused | pipeline | 1 when paused, else 0 |
Gauges are pushed on every ops.Status() snapshot (the SSE stream and UI
poll status; /metrics scrapes the recorded values).
Tracing
- One span per job run:
eventboat.job.run, attributespipeline/run_id/trigger, ended with the terminal status and recorded errors (internal/jobs). Script backtraces ride dead-letter records and span error events. - Per-message spans are opt-in:
telemetry.span_sample_rate(default 0 = none, zero cost; 1 = all). Roots are namedeventboat.messagewithpipeline/message_id/source/spool_seqattributes and end at a terminal state —eventboat.terminal_state=committedordead_letter(internal/engine/engine.go,startMessageSpan/finishSpan). Rationale (review R16): no per-message spans at routing rates. The span context is deliberately not propagated through the DAG — correlation rides the attributes. - Trace export goes through OTLP/HTTP when
telemetry.otlp_endpointis configured, sampled bytelemetry.sample_ratio(default 0.1); otherwise the global noop tracer.
OTel wiring
obs.Setup(ctx, cfg) builds the providers from the Runtime config
(internal/runtimecfg): telemetry.prometheus (default true) registers the
Prometheus reader served by the admin listener at /metrics;
telemetry.otlp_endpoint adds the OTLP metric reader (push every 10s) and
the trace exporter. Single-pipeline eventboat run gets OTLP only (no HTTP
surface); run --config-dir and mcp --http serve both readers.
The admin surface
internal/admin serves the whole surface on one mux — Admin REST + SSE +
UI, /metrics, /mcp — behind one security middleware. Auth: an optional
bearer token resolved as --admin-token flag > EVENTBOAT_ADMIN_TOKEN env
admin.tokenin the Runtime config (internal/cli/mcp.go,internal/runtimecfg). With a token set, every request needsAuthorization: Bearer <token>(the?token=query form exists only for the UI’s EventSource and first navigation — it then appears in browser history/proxy logs; use the header from curl/agents). A non-loopbackadmin.listenrefuses to start without a token — the write surface can deploy pipeline YAML whose grpc plugincommand:executes on the host (internal/admin/security.go). Loopback binds additionally enforce a Host allowlist (DNS-rebinding defense); wildcard binds skip it and rely on the mandatory token. JSON request bodies are capped at 8 MiB (maxBodyBytes,internal/admin/admin.go).
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/status.json | status snapshot of every deployed pipeline (mode, per-node list, in-flight, checkpoint, counters, msg/s, recent runs) |
| GET | /admin/jobs/{pipeline}?limit= | job run history |
| GET | /admin/tail/{node}?n= | recent sampled deliveries for one node |
| GET | /admin/dlq/{pipeline}?since=&where= | dead-letter query (since duration, where CEL predicate over {payload, meta} compiled with the pipeline’s constants); response is redacted |
| GET | /admin/catalog.json | the plugin catalog with versions and schemas |
| POST | /admin/deploy | body {"config": <yaml>}; verify-first, deploy rejected on any verify error |
| POST | /admin/trigger/{pipeline} | body {"parameters": {...}, "wait": bool}; fires a job run |
| POST | /admin/replay | body {"pipeline", "ids", "at"}; re-injects dead letters |
| POST | /admin/drain/{pipeline} | stop sources, wait for in-flight commit; stays deployed |
| POST | /admin/pause/{pipeline} | stop the instance (resume from persisted states) |
| POST | /admin/resume/{pipeline} | restart a paused pipeline |
| GET | /admin/sse | Server-Sent Events: deploy/job/status events plus a full status snapshot every second (one payload shape per event type: status always carries the snapshot) |
| GET | /live | liveness: 200 ok while the process serves; token-exempt for kubelet probes |
| GET | /ready | readiness: 200 ready, 503 stopping once shutdown began; token-exempt for kubelet probes |
| GET | /metrics | Prometheus exposition |
| GET/POST/DELETE | /mcp | MCP Streamable HTTP — registered only when mcp.enable is true (candidate 06); the explicit eventboat mcp --http command always serves it |
| GET | /admin/ | the embedded read-only UI (/admin redirects) |
The admin UI is a single embedded HTML page (internal/admin/ui.go):
read-only status console fed by the SSE stream; on a 401 it serves a sign-in
prompt that verifies the token against /admin/status.json and keeps it in
sessionStorage.
The same operations exist as 14 MCP tools (internal/mcpserver):
catalog, verify, test, explain, status, jobs, tail,
dlq_query, deploy, trigger, dlq_replay, drain, pause, resume —
over stdio (eventboat mcp --stdio) or HTTP (--http). verify returns the
same composition the CLI runs (candidate 05: one diagnostic sequence, one
strict verdict), and explain accepts at — the entry node behaves
identically on CLI, MCP and Admin.
Log tailing
ops.Tail samples the most recent deliveries per sink node (bounded
ring of 100 entries, payloads truncated at 512 bytes, is_replay flagged)
via a sink wrapper installed on every managed engine. Entries show the
payload document as delivered (msg.Out, falling back to Raw). Consume it
via GET /admin/tail/{node}, the MCP tail tool, or the UI.
Redaction
The pipeline-level telemetry.redact section lists dot-separated field
paths where every segment is a path.Match glob (payload.user.email,
meta.authorization). Compilation and matching live in
internal/ops/redact.go. Coverage is deliberately presentation-only:
- Tail entries are masked (
"***") before the 512-byte truncation; - DLQ display masks both roots — payload patterns against the raw
document,
meta.*patterns against the meta map — before letters cross the admin REST or MCPdlq_querysurface (ops.redactDeadLetters).
The stored rows stay raw: the spool, dead letters and deliveries are the
data path and are never altered, so DeadLetterReplay re-injects the
original bytes. Non-JSON tails pass through unmasked. A redact pattern that
cannot compile is a verify error (telemetry_redact_pattern) — a bad
pattern must not silently never match.
Engine counters (the non-OTel layer)
Beside the OTel instruments, the engine keeps atomic counters in
engine.Metrics (internal/engine/engine.go): MessagesIn,
CommittedCount (split from the checkpoint pointer, review R5),
CheckpointPtr, DeadLettered, CelEvalErrors, NoMatch, Retries,
DlqFailures, OptionalDrops, DecodeErrors, TransformRuns,
Backpressured, SpoolFailures. These are the M1 bookkeeping layer and the
source of the --json CLI/status output — the OTel instruments record the
same events for Prometheus/OTLP consumers. ops.Status() reads them per
pipeline (messages_in, committed, dead_lettered, checkpoint,
in_flight, messages_per_sec as a delta over the last snapshot window).
Run outcomes and exit codes
Every runner derives its terminal state from one engine decision point
(candidate 02): Engine.Wait classifies the run — completed (quiesced
cleanly), partial (quiesced with dead letters), failed (the engine
stopped itself: worker-fatal or a source failure) or interrupted (the
caller canceled) — and each process maps that onto its own contract:
| Runner | completed | partial | failed | interrupted |
|---|---|---|---|---|
run --config (batch) | 0 | 1 | 1 | 1 |
run --config (continuous) | 0 | 0 | 1 | 0 (SIGTERM/SIGINT) |
trigger (one-shot job) | 0 (success) | 1 (partial) | 1 (failed) | 1 (canceled) |
job scheduler (run, long-lived) | — | — | — (a failed run is history, not a process exit) | 0 (SIGTERM/SIGINT) |
| daemon status | completed | completed | failed + error | shutdown owns the status |
The exit-code split follows the process shape, not the status: a one-shot
run that did not complete exits non-zero, while a long-lived process treats
SIGTERM as a graceful stop. A failed source stops the engine in every
mode (no silent “alive but not consuming”); restart resumes from the source
watermarks — duplicate delivery, never loss. The daemon’s failed status
carries the outcome’s failure text (worker-fatal first, then the first
source error); a batch that fails no longer sits at running with an error.
SSE event types
GET /admin/sse emits hello on connect, then typed events — deploy
(pipeline deployed, replaced instance), job (job run state), status
(pause/resume/drain and a full status snapshot every second, which is also
what pushes the gauges). Slow consumers drop events; snapshots repeat.
HTTP server posture
The admin listener (admin.Serve) sets ReadHeaderTimeout 10s,
ReadTimeout 60s, IdleTimeout 120s — and a deliberately loose
WriteTimeout (15m) because SSE responses are long-lived streams; it caps
their lifetime instead of erroring mid-request (the UI’s EventSource
reconnects).
Operations runbook pointers
- Status polling:
GET /admin/status.jsonor watch/admin/sse; job runs viaGET /admin/jobs/{pipeline}(the status embeds the 5 most recent runs, surfacingrun:<status>for job pipelines). - A wedged WASM worker (fast mode) shows as one transform node stalled with a slow-call watchdog log line; restart clears it.
- Backpressure is visible as
eventboat_backpressure_events_totalclimbing witheventboat_in_flight_messagespinned at the high watermark. - Checkpoint lag is
eventboat_spool_depth; a growing lag with a failing store indicates checkpoint writes failing (replay window widening — the engine keeps committing in memory and retries durability).