Skip to main content
GrafanaPrometheusSlackDiscordTelegram
Every app gets CPU, memory, network, disk IO and request metrics without any setup. You also get full-text log search with live tail, and alert rules that can notify you by email, Slack, Discord, or a webhook. Metrics and logs stay in each node’s local store and the control plane queries them on demand, so there is no separate observability stack to install.

What this covers

  • internal/telemetry: storage and query
  • internal/alerting: rule evaluation and notification
  • internal/api/metrics.go, node_metrics.go, database_metrics.go: metrics handlers
  • logs.go, live_logs.go, logs_download.go: log handlers
  • app_resource_usage.go, alerts.go, notification_channels.go: supporting handlers

Quick start

1

Query one app's CPU over the last hour, in 5-minute buckets

Response:
2

Search that app's logs for an error

3

Connect a Slack channel and test it

4

Create a threshold alert that notifies through the channel

A rule created without --channel-id (and without --notify-url) is saved but notifies nobody. The CLI prints a warning, the API returns it in warnings, and the dashboard dialog shows a notice.
5

Watch it fire

levelrail-cli apps alerts list my-app shows FIRING=true once the condition holds for 5 minutes. A delivery row appears under levelrail-cli channels deliveries <channel-id>.

Investigating a spike

GET /api/v1/apps/{name}/investigate?from=&to= returns everything that explains one time window in a single response. The window can be at most APP_INVESTIGATE_MAX_SPAN (default 24h).
  • summary and baseline: request count, rate, 4xx and 5xx share, p50, p95 and p99 for the window and for the equal-length window before it.
  • top_routes and status_codes: the ten busiest route templates with share, 5xx share and mean latency, and the exact status code counts.
  • timeline: one ascending list of deploys, rollbacks, config, env, secret, domain, scale and load balancer changes (from the same sources as What changed before an alert, with the most recent effective change flagged likely_cause), container restarts (a burst collapses into one entry with a count), and resource saturation (CPU or memory held at or above a threshold for several samples). It looks back APP_INVESTIGATE_LOOKBACK before the window, because the cause usually comes before the symptom.
On the app’s Metrics tab, click anywhere on a chart to select that moment. The investigation panel opens with the same data. Deploy markers on every chart open the deploy (status, image, commit) on click, and “View logs at this time” opens the Logs tab with the window already set.

Failure context

GET /api/v1/apps/{name}/failure-context tells you why an app is down without opening the log viewer. For a crashloop the response carries the last APP_FAILURE_CONTEXT_LINES (default 200) lines of the most recently active container, with the total available. For a failed build it carries the tail of the build output. For any other failed deploy it carries the app’s runtime log tail. The app overview page shows it automatically when the state is not healthy, and levelrail-cli logs failure <app> prints it. Notifications carry the same context. A firing crashloop alert attaches the last 200 lines, and so does a failed deploy notification. Chat channels get the last 10 lines (capped at 1200 characters). The generic webhook payload gets log_lines with up to 200 and the attempt_id.

Dashboard pages

Per-app, database and node metrics

  • /apps/$name/metrics (MetricsDashboard): CPU, memory, disk IO, network IO, request rate, error rate, p50/p95/p99 latency and restarts. A range picker (15 minutes to 30 days, or a custom window, kept in the URL so it is a permalink), a “compare to previous period” toggle, and deploy and restart markers on every chart. Series are downsampled server side. Click a deploy marker for its status, image and commit, or click the chart to open the investigation panel for that moment.
  • /databases/$name/metrics (DatabaseMetricsDashboard): the same charts scoped to a managed database.
  • Node metrics (NodeMetricsDashboard): the sum-across-placed-services view plus real disk and patch readings.
/apps/$name/overview shows AppHealthTimeline, a compact 24h/7d strip answering “what happened to this app, and when”. It plots:
  • Deploy markers: green succeeded, red failed, blue running.
  • Container restarts: amber, bucketed so a burst reads as one marker with a count.
  • Shaded error windows: a failed deploy from start to finish, or a crashloop of 3 or more restarts with under 15 minutes between them.
Markers are keyboard focusable with aria labels and show details on hover or focus. Enter or click opens the deploy’s logs (deploys) or the app’s logs (restarts). It reads only existing data (GET /api/v1/apps/{name}/deploy-attempts and the container_restart_count metric), so there is no new API or CLI surface: the same data is available from levelrail-cli apps deploys list and the metrics API. If telemetry is not configured, only deploys are shown.
  • /apps/$name/logs: two tabs, Live (LiveLogViewer, default) and Search (LogSearchPanel). Search filters by text, level, stream, container and structured JSON fields (key=value, key!=value, key~text, key>n), takes from/to from the URL (the investigation panel links here), and “Copy permalink” copies the exact filter and time range. The live tail can be paused without losing lines.
  • /apps/$name/deploys/$deployId/logs is separate: it tails a specific deploy attempt’s output.
  • /databases/$name/logs: the same live/search pair (LiveDatabaseLogViewer, DatabaseLogSearchPanel) for managed databases.
  • /databases/$name/slow-queries (DatabaseSlowQueriesPanel): the slow query log for Postgres and MySQL, sortable by duration or timestamp with a minimum-duration filter.
  • Dashboard home: FleetUtilizationSummary shows a compact fleet-wide CPU/memory/disk card (backed by GET /api/v1/nodes/resource-usage, polled every 30 seconds), above TopResourceConsumers (ranks every app by latest CPU, memory and network reading, backed by GET /api/v1/apps/resource-usage) and FleetResourceChart (a 30-minute rolling history of total CPU and memory across all apps, polled every 30 seconds from the same endpoint). All three render nothing when telemetry is unconfigured or no samples exist.
  • Nodes list (/nodes): the CPU, Memory and Disk columns read the same GET /api/v1/nodes/resource-usage snapshot, keyed by node ID.
  • /apps/$name/alerts (AlertRulesPanel): lists, creates, edits and deletes alert rules, and shows each rule’s current firing state.
  • Settings -> Notification channels (NotificationChannelTable): connect, edit, delete, test, and view delivery history for channels.

Live tailing vs stored search vs download

Levelrail live log viewer with full-text search Live tail, stored search and download are three ways to read the same log store. Log collection resumes where it stopped. After a control plane restart, or when a container exits and its log stream reconnects, collection continues from the newest stored line for that container. It does not re-read Docker’s whole buffer, so lines are not duplicated.

Live tail details

  • Opens with a short backfill: the last 5 minutes, capped at 200 lines, oldest first. If that window is empty, it widens to 1 hour, then 24 hours, then 7 days, so a quiet container still shows its last output.
  • Streams every new line as LogCollector receives it from Docker.
  • The handler subscribes to the live broadcaster before running the backfill query, so there are no gaps.
  • Field filters look like field=status=500, field=route~/pay, field=latency>=250. Dotted keys such as req.user.id reach nested objects. A line that is not JSON, or lacks the key, matches only !=.
  • level keeps lines at or above it, using the JSON level or severity field or a level token near the start of a plain line. Lines with no detectable level are dropped when it is set.
  • Each entry carries its container id and, when detected, a level. The response lists the containers that matched (before the container filter) with line counts, and total (matches before limit).
levelrail-cli logs query <app> and the MCP query_logs tool filter by level, time window (--since and --until, a duration or RFC3339), deploy attempt (--deploy, the window from that attempt’s start to the next attempt’s start) and text (--text).It returns an excerpt of the newest matching lines, never a full dump: at most --max-lines lines (default 100) and a byte cap (default 8 KB, set with --max-bytes or APP_MCP_LOG_MAX_BYTES), plus counts and a notice such as showing 40 of 1,812 matching lines (newest), use since/until or level to narrow. It uses stored search with level and limit. --json prints the excerpt as one object.
Download takes the same from, to and q filters as stored search and returns a plain-text attachment (not JSON), capped at 5,000 lines. There is no CLI command today: use the browser download, or curl with query params and a bearer token.Database logs mirror the app endpoints exactly (same query params, same SSE shape) and use the resource ID prefix database: instead of service:. There is no download endpoint for databases yet.
  • Each row gets a subtle level tag (ERR, WRN, INF, DBG) detected from JSON level or severity keys, level=WARN style key/value pairs, or a leading ERROR, WARN, INFO or DEBUG prefix.
  • The level chips (All, Errors, Warnings, Info, Debug) filter by detected level. Errors also includes every stderr line.
  • Click a row to expand it: JSON lines are pretty printed, other lines wrap in full, and Copy line copies the raw text.
  • “Jump to first error” scrolls to the first error or stderr line in the current view.

Database slow query log

GET /api/v1/databases/{name}/slow-queries returns structured slow-query entries (timestamp, duration, query text, rows examined where the engine reports it). Default limit is 100.
The threshold is control-plane-wide, not per database: APP_DATABASE_SLOW_QUERY_THRESHOLD_MS (default 1000). Changing it takes effect on the database’s next reconcile (container recreate).
MySQL 8’s FILE log sink cannot reliably open /dev/stderr from inside a container (it fails with Could not use /dev/stderr for logging), so there is no working Docker-log-stream source for this engine. The handler uses the same docker.Runtime.Exec primitive backups already use to run pg_dump and mysqldump. Redis’s SLOWLOG is a live-server command against an in-memory ring buffer, not something that appears in log output, so it is not planned for this endpoint.

External log drains

PUT /api/v1/apps/{name}/log-drain forwards an app’s container log stream to an external HTTP endpoint or syslog target. The drain is in addition to, never instead of, the node-local store: it taps the same LogBroadcaster a live SSE viewer subscribes to.
  • Configure it from the app’s log-drain card in the dashboard, or with levelrail-cli apps log-drain set.
  • Clearing a drain (DELETE) stops the external forward only. Historical search and live tail on this control plane are unaffected either way.

What gets collected

Per app or database (service:<name> and database:<name>): the collector writes 7 metrics, matching web/src/types/metrics.ts’s MetricName. Per node (node:<id>): GET /api/v1/nodes/{id}/metrics returns two kinds of reading.
  1. Sum across placed services for cpu_percent, memory_usage_bytes, network_rx_bytes, network_tx_bytes, disk_read_bytes, disk_write_bytes. This is a sum of already-collected per-container samples, not true host utilization (internal/agent has no host-level stats collection). memory_limit_bytes is excluded to avoid multiplying the host’s approximate total memory by N containers.
  2. Real per-node readings for disk usage and OS patch counts (disk_used_bytes, disk_total_bytes, os_patches_available, os_security_patches_available), collected directly by HostDiskCollector and HostPatchCollector.
The response includes resource_count: how many placed services contributed a sample (for summed metrics), or 1 or 0 for whether host data exists (for real per-node metrics). Request metrics (RED), measured at the ingress with no app changes: see Request metrics.
Container restart count, build duration and deploy frequency are recorded as discrete events or computed client-side from deploy-attempt history, not as continuous collectors.

Request metrics (RED)

Every proxy and static route in the embedded Caddy goes through a small request_stats handler (internal/ingress/request_stats.go). It times the request and counts it into fixed in-memory counters per route host. Idle cost is near zero and nothing is written to log files.
  • Query strings and client addresses are never read.
  • Request paths are reduced to a bounded route template (see Route and status breakdown), so cardinality stays bounded.
  • The sampler drains the counters every 15 seconds, maps hosts to apps through the ingress route table, and writes only non-zero values under the app’s service:<name> resource id. Platform routes (dashboard, registry, models) are not attributed to an app.
  • Requests rejected by the WAF or rate limiter count as 4xx. Load balancer and proxy failures (502, 503, 504 raised by the reverse proxy) also count under http_upstream_errors.
Metrics are per-tick counter deltas, and a tick with no traffic writes nothing. Percentiles are estimated from the summed histogram buckets (linear interpolation inside a bucket), so they merge correctly across time buckets, rollup tiers and nodes.
The Metrics tab of an app.

Route and status breakdown

To tell you which route made the 3am spike slow, the request handler also counts, per app and minute, requests, 4xx, 5xx and total latency per route template, and requests per exact status code. Only the URL path is read. A path is cut to its first segments, and any segment that looks like an identifier (digits, hex, UUID, long tokens) becomes :id, so /api/users/42/orders/9f86d081... is stored as /api/users/:id/*. Distinct routes per tick are capped, with the rest folded into other. The breakdown is stored in telemetry.db at one minute resolution and pruned with APP_METRICS_RETENTION_1M. It is node-local like the rest of the telemetry and merges across nodes through the same query layer.

Downsampling and period comparison

Every series query bounds its size instead of returning raw 15 second samples for a long range.
  • max_points=N on GET /api/v1/apps/{name}/metrics and /requests widens the bucket to the smallest round step (15s up to 24h) that keeps the series within N points. The dashboard and CLI send 600. APP_METRICS_MAX_POINTS (default 600, at most 5000) is the largest value the server honours. A 7 day range at 15 second resolution is 40,320 raw samples and comes back as 336 points.
  • Each metric point carries max, the largest raw sample in its bucket, so a one minute spike survives averaging into a 30 minute bucket.
  • compare=previous adds previous_points, the equal-length window before from, with timestamps shifted forward so it overlays the current series. /requests also returns previous_summary.
  • Queries pick raw, 1 minute or 1 hour rollup tiers (see Rollups and retention), so a long range never scans raw rows.

Resource-usage ranking

GET /api/v1/apps/resource-usage tells you what every app is doing right now in one call, so a page load does not fire one query per app.
  • Every app that exists is included, including ones with no samples yet (a freshly deployed app appears as a zero-usage row, not missing).
  • Each field (cpu_percent, memory_usage_bytes, memory_limit_bytes, network_rx_bytes, network_tx_bytes) is present only when a sample has been recorded.
  • One LatestByMetric call per metric, not one query per app.

Batched apps overview

GET /api/v1/apps-metrics returns, in one response, every app the caller can read with its latest CPU and memory, a one hour request rate, 5xx error rate, p95 latency, a 12 point request-rate sparkline (5 minute buckets) and the last deploy time.
  • Pass ?names=a,b to limit it.
  • The dashboard apps list and home tiles read only this endpoint, so a page of apps costs one request instead of two per row.
  • The number of apps included is capped by APP_APPS_METRICS_MAX (default 200).
  • CLI: levelrail-cli apps overview [name ...].

Fleet utilization

GET /api/v1/nodes/resource-usage tells you how full your servers are in one call. It is the node-scoped counterpart to apps/resource-usage, read by the node list’s CPU, memory and disk columns and the dashboard’s fleet summary card. The response is { "nodes": [...], "fleet": {...} }. Each node row
  • cpu_percent and memory_usage_bytes are the sum of every placed service’s latest sample (sum of containers, not a true host read).
  • memory_total_bytes, disk_used_bytes and disk_total_bytes are real host reads, but today only the node running the control plane populates the disk fields (see Not built yet). A field is absent, not zero, when nothing has reported it.
  • Memory attribution: levelrail_memory_bytes, other_containers_memory_bytes (with the largest few in other_containers) and system_memory_bytes split a node’s used memory, and always sum with the placed-services figure to the used total (memory_total_bytes minus memory_available_bytes).
The local node reads its own figures. A remote node’s agent sends a periodic host report (memory total and available, its own process RSS, and its running containers that carry no Levelrail instance label, top 5 by memory), and the control plane merges it into the same row, so memory_total_bytes and memory_available_bytes are populated for remote nodes too. Reports are held in memory and expire after 3 minutes. An agent that predates host reports sends none, and its row keeps the placed-services-only shape.
The fleet rollup sums every node’s numbers.
  • total_cpu_percent is a raw sum, not a percentage of fleet capacity (no node reports its core count).
  • memory_used_percent and disk_used_percent are only computed from nodes that reported a capacity figure. nodes_with_memory_capacity and nodes_with_disk_capacity say how many of node_count that covers, so a 3-node fleet where only 1 node has host metrics reads as “1/3 nodes reporting”, never as if the percentage covered the whole fleet.

Capacity forecast

GET /api/v1/nodes/{id}/capacity-forecast estimates how long until disk or memory runs out at the current rate. It is a rough heads-up built from the same disk_used_bytes, disk_total_bytes, memory_total_bytes and memory_available_bytes host samples the fleet views already read, not a second collector. The method (kit/forecast) is an ordinary-least-squares line fit over the lookback window’s samples, projected forward to the resource’s total capacity. It assumes the recent rate of change continues in a straight line, which real usage rarely does (a cleanup, a larger image, a burst of logs all bend the line). Every response carries a plain-English disclaimer in note.
disk and memory are null, not a zero-value object, when there is not enough history (fewer than 6 samples, or samples spanning less than 6 hours) or when the fitted trend is flat or improving (slope <= 0). Read absence as “nothing to warn about”, never as “0 days left”. The node detail page’s forecast card follows the same rule and renders nothing when both are absent.
  • The lookback window defaults to 14 days and is overridable with APP_CAPACITY_FORECAST_LOOKBACK (a Go duration string, e.g. 720h).
  • It is a deterministic, read-and-suggest layer: never an external model, never applied automatically.
  • CLI: levelrail-cli nodes capacity-forecast <id>.

Health & readiness score

GET /api/v1/apps/{name}/health-score combines signals this page otherwise shows one at a time into a pass, warn or fail verdict per category. It is computed live on every call (no new reconciler loop, no cache) and is read only: every category reads data an existing endpoint already exposes. The TLS check reuses alerting.ListCertificates, the same computation behind GET /api/v1/certificates and a cert_expiry rule, scoped to the app’s own domains. The crashloop check reads a crashloop rule’s persisted firing state, not the live in-memory restart tracker. Dashboard: the Health & readiness panel on an app’s Overview tab (AppHealthScorePanel), polling every 30 seconds while open. CLI: levelrail-cli apps health-score <name>.

Alert rules

Rules of every kind live in one table (alert_rules). The evaluation loop (internal/alerting.Engine) runs every 30 seconds (alertEvaluationInterval, fixed, not env-configurable). Each rule tracks its own pending and firing state and notifies only on transitions (firing or resolved), never on every tick it stays in the same state. That way people do not learn to ignore repeated alerts.

Rule kinds and their configuration

Three more domain kinds are planned and not evaluated yet: dns_drift (records differ from the last known state), zone_not_delegated (a zone’s delegation is not active after a window) and proxy_route_error (a proxy route write or reachability check fails). A rule with one of these kinds is rejected today.

Platform-wide rules and thresholds

Platform-wide rules are created through an app’s /apps/{name}/alerts URL, but that URL only decides where the rule is listed. The rule evaluates every certificate, node, or disk across the entire control plane regardless of which app created it. Thresholds have defaults and can be overridden per control plane, not per rule.
The backup_missing rule has no cadence logic of its own. It reads the same backup_schedule cron expression and backup_history rows that internal/backup.Scheduler uses, computes the expected interval from the cron expression (kit/cronexpr), and fires once the last succeeded attempt is older than that interval plus a grace period.
  • A target with attempts but no success in the lookback fires, anchored to its oldest attempt (a silently-failing backup reads the same as one that stopped).
  • A target with no schedule or no history yet stays quiet on day one.

Crashloop handling

Alert attachment. A firing crashloop rule attaches the last 200 lines of the crashlooping container’s logs (from the last 15 minutes, see crashloopLogLines and crashloopLogLookback in internal/alerting/engine.go) to the notification. No API endpoint reconstructs those exact lines after the fact: GET /api/v1/apps/{name}/failure-context returns the current tail, and AlertRulesPanel links to the app’s log view. Restart backoff. The reconciler does not restart a container that keeps exiting on every Docker event.
  • The first restart is immediate. Each further restart of the same container waits twice as long as the last: APP_RESTART_BACKOFF_BASE (default 5s), doubling up to APP_RESTART_BACKOFF_MAX (default 2m).
  • The delay starts over once the container has stayed up for APP_RESTART_BACKOFF_RESET (default 10m).
  • While waiting, the app’s Ready condition is False with reason CrashLoopBackOff and a message such as keeps exiting (2 restarts), next restart in 9s. levelrail-cli apps status <name> shows it.
  • Set APP_RESTART_BACKOFF_BASE=0 to restore restart-immediately behavior.
  • The backoff is in memory, so a control plane restart clears it.
The 2 minute maximum keeps a crashlooping app above the usual crashloop rule threshold (3 restarts in 10 minutes), so the rule keeps firing instead of resolving between restarts.

Auto-rollback

Alerts notify. By default they do not fix anything, and a failed deploy keeps retrying the same bad image until you step in (the reconciler is level-triggered, never edge-triggered). Auto-rollback is the escape hatch: it is opt-in, per app, and off by default.
Dashboard: the app’s Deploys tab has an “Auto-rollback on crashloop” toggle above the deploy history list.Once enabled, the first time a crashloop rule for that app transitions to firing, the control plane redeploys the most recent successful deploy attempt’s image that differs from the current (crashlooping) one. It uses the same trigger path as the dashboard’s “Rollback to this build” button and apps rollback (POST /api/v1/apps/{name}/deploys). The resulting deploy attempt shows in deploy history with source auto_rollback.
Guardrails, same for both:
  • Fires at most once per crashloop (or firing) episode, because it only runs on the rule’s pending-to-firing transition.
  • If there is no older successful image to fall back to (never deployed before, or already at the oldest recorded image), it does nothing and leaves the alert to notify alone.
  • Independent of the rule’s own notification, which fires either way.

Silences, maintenance windows, and noise control

Everything in this section sits between rule evaluation and notification. A rule keeps evaluating, keeps its own firing state, and is always recorded in alert history. These controls decide only whether a notification goes out.
A silence has matchers (rule IDs, apps, nodes, rule kinds, severities, labels), a start and end, a creator and a reason.
  • Every matcher you give must match. A list inside one matcher matches any of its entries.
  • A silence needs at least one matcher, so it can never mute everything by accident.
  • Silences last at most 90 days. One that ends early (End now, alerts silences delete) is kept as history, as are expired ones.
  • App matchers apply to app-scoped rules (threshold, crashloop, scheduled task, domain health, backup missing). Platform-wide rules such as certificate expiry have no app and are matched by rule ID, kind, severity or label.
  • Node matchers apply to apps placed on that node (by node name or ID).
  • Rules carry an optional severity (info, warning, default warning, or critical) and labels for silences to match on.
  • If a rule fires while silenced and is still firing when the silence ends, the held notification is sent then. If it resolves while silenced, no “resolved” message is sent for a firing you never heard about. Both cases are recorded in history.
Quick silence. Silence one rule for 1h, 4h or 24h from the rule’s row, from the dashboard’s Recent alerts card, with levelrail-cli alerts silence <app> <rule-id> --for 4h, or with the silence_alert_rule MCP tool.
Maintenance windows are a recurring silence: a 5-field cron expression for each start, a duration, and an IANA timezone, applied to all alerts, a set of apps, or a set of nodes. The cron is evaluated on the wall clock in that timezone, so “03:00 Europe/Berlin” stays at 03:00 across daylight saving changes and the window keeps its nominal length. Windows are listed with whether they are active now and their next start.Node-down inhibition. While a node is offline, alerts of apps placed on it are held (recorded as inhibited). Held alerts are released if the app is still firing after the node returns. Platform-wide rules, including the node-offline rule itself, are never inhibited.
Flapping. A rule that fires more than flap_threshold times inside flap_window (defaults APP_ALERT_FLAP_THRESHOLD=5, APP_ALERT_FLAP_WINDOW=30m; 0 disables) is marked flapping. It notifies once with a summary, further fires and resolves are held (recorded as flapping), and one “stable” message is sent when its fires drop to half the threshold. Per-rule overrides: flap_threshold, flap_window.Grouping and deduplication. With APP_ALERT_GROUP_WINDOW set (for example 2m; default off), firing alerts for the same delivery target and app are buffered and sent as one message listing every alert with a count. A rule that fires twice in the window counts once, and a fire that resolves before the window closes sends nothing. Buffered alerts are flushed on shutdown.Per-channel rate limit. At most APP_ALERT_CHANNEL_RATE_LIMIT notifications (default 30; 0 disables) per APP_ALERT_CHANNEL_RATE_WINDOW (default 10m) go to one channel. Excess notifications are recorded as ratelimited. Deploy notifications are not counted.
All noise state except silences, windows and history is in memory. A control plane restart forgets pending groups, streaks, flap counters and held notifications. Rule firing state itself is persisted, so a still-firing rule does not re-notify after a restart.

Alert history

Every state change and notification decision is recorded: the rule, app, node, severity, the event (fired, resolved, flapping, flap_ended, plus slo_burn_would_rollback for auto-rollback on SLO burn’s dry_run mode), and the outcome. Where to see it:
  • Dashboard: per app on /apps/{name}/alerts, and globally on /alerts (with outcome and event filters).
  • CLI: levelrail-cli alerts history.
  • API: GET /api/v1/alert-history.
  • MCP: list_alert_history.
Entries are pruned after APP_ALERT_HISTORY_RETENTION. Who created, changed or removed a silence, window or rule is recorded separately in the generic audit log (GET /api/v1/audit-log), which covers every write request.

What changed before an alert

Every firing app alert, its history entry, diagnose and the dashboard alert rows show what changed on the app in the last 30 minutes: deploys (with digest and who), rollbacks, config, domain, scaling and load balancer changes, freeze and maintenance events, and env or secret key names (never values). They come from the app event log, deploy attempts and the audit log, merged newest first by internal/changes. The most recent change that took effect before the alert (a deploy, rollback, config, env, secret, domain, scaling or load balancer change) is tagged Likely cause. It is a heuristic: the nearest earlier change, not a proven culprit, and it never picks a restart, freeze or maintenance event or a failed deploy.
Webhook, Slack, Discord, Telegram, email and the rest carry the top 5 changes, an “and N more” line and a link to the app’s alerts page. The generic webhook adds recent_changes and changes_link fields. Discord and PagerDuty messages are capped to their receiver limits with the changes section kept.

SLO burn-rate alerts

A slo_burn rule watches a request-based SLO over the app’s ingress request metrics: availability (requests without a 5xx) or latency (requests under a limit, rounded down to the nearest histogram bound), with a target such as 99.9 over a 30 day budget window. It uses the multiwindow multi-burn-rate method: a tier fires only when the burn rate is at or above its factor over both its long and its short window, so a brief blip does not page and a real outage does. Burn rate is how many times faster than sustainable the error budget is being spent. An app with no traffic, or fewer than APP_SLO_MIN_REQUESTS requests in a tier’s long window, never fires. An app newer than a window is judged on the traffic it has. Hysteresis, silences and maintenance windows apply as for any rule.
  • Create with levelrail-cli apps alerts create <app> --name NAME --kind slo_burn --slo 99.9 (add --slo-latency-ms 300 for a latency SLO), or from the Create rule dialog, which previews the error budget remaining and the current burn rates live.
  • Preview without creating a rule: levelrail-cli apps alerts slo <app> [--slo 99.9], GET /api/v1/apps/{name}/slo-preview, or the get_slo_status MCP tool.
  • An app with traffic and no SLO rule gets a suggestion on its alerts page to create a default 99.9% availability SLO.

Notification channels

Channels are global destinations you connect once (Settings -> Notification channels). Attach them to alert rules by channel_id instead of retyping webhook URLs on every rule. Supported kinds (18, mapped to payload builders in internal/alerting/notify.go): generic, slack, discord, telegram, email, pushover, pagerduty, teams, resend, ntfy, gotify, mattermost, lark, rocketchat, opsgenie, webex, googlechat, webpush. For most kinds, notify_url is a webhook URL. A few pack credentials into that one field (Pushover’s user key and app token, PagerDuty’s routing key). Two are exceptions:
  • email sends through the control plane’s SMTP sender (Settings -> Email, or APP_SMTP_HOST, APP_SMTP_PORT, APP_SMTP_USERNAME, APP_SMTP_PASSWORD, APP_SMTP_FROM).
  • webpush ignores notify_url: the destination is every browser subscription registered under Settings -> Notification channels -> Browser push.
Both return a “not configured” error if the control plane has no master key set.

Destination checks and visibility

notify_url often is the credential (a Slack webhook URL, a Telegram bot token, a PagerDuty routing key). A channel’s credential (and its interactive approval signing secret) is envelope encrypted under the master key and decrypted only when a message is actually sent; alerting.db holds just a masked placeholder. See Notification channel secrets.
  • Save-time checks. Creating or editing a channel whose notify_url is not an absolute http or https URL, or points at loopback, a private range, link-local or localhost, is refused with a 400 that names the cause. Every send goes through the same SSRF guard and would be rejected anyway.
  • Private networks. A self-hosted target on a private network (a Mattermost, a Gotify, an internal webhook receiver) needs APP_NOTIFY_ALLOW_PRIVATE_NETWORKS=true in the control plane’s environment, set before the channel is created.
  • Hostnames are checked by resolving them when you save. One that does not resolve yet is accepted, and the guard still applies on every send. email, pagerduty, opsgenie, resend and webpush hold an address or key rather than a URL, so they are not checked this way.
  • Who sees the destination. A caller without read:sensitive (the viewer role, or a plain read token) gets https://host/(hidden) or (hidden) instead of the value when listing channels, an app’s deploy notification targets, an app’s alert rules and an app’s log drain. email addresses stay visible. For an encrypted channel every caller, operator and admin included, sees only the placeholder. Saving a channel with the hidden placeholder keeps the stored value, and the test button sends through it. A deploy target or alert rule that carries its own notify_url instead of a channel_id is not encrypted, so attach a channel instead.

Browser push (webpush)

Sends an OS-level notification to every registered browser using the Web Push protocol (RFC 8030/8291) with a VAPID keypair this control plane generates once and stores through internal/secrets (service webpush). Unlike every other kind, it works even when no dashboard tab is open or focused.
  • All of these routes, and any webpush-kind send, return 501 (“not configured”) when the control plane has no master key set.
  • CLI: levelrail-cli push-subscriptions list and revoke <id>. There is no register subcommand, since a subscription’s endpoint and keys only ever come from a real browser’s Push API.

Retries, test-send and delivery history

Every kind, including email, retries up to 3 times on a transient failure with the same 500ms-then-1s backoff, through two send paths. Retrying a request that is wrong to begin with only delays the real error. Test-send sends a test message through the channel’s kind and URL:
  • POST /api/v1/notification-channels/test: before saving (kind and notify_url in the body).
  • POST /api/v1/notification-channels/{id}/test: against an existing channel.
Both run synchronously with a 10-second timeout. Only the existing-channel variant records a delivery-history row. Delivery history. GET /api/v1/notification-channels/{id}/deliveries lists every recorded send for a channel, newest first: test sends plus real deploy-outcome and alert-rule dispatches. Cursor pagination via ?before (RFC3339 timestamp), default 50 rows, capped at 200.
Deleting a channel still attached to a rule or deploy-notify target succeeds. The foreign key’s ON DELETE SET NULL clears the reference instead of failing (unlike deleting a backup target).

Why node-local, not a central store

Most platforms in this category, and hosted observability vendors, centralize: they ship every metric and log line to one place, index it there, and query it there. Every container on every node then writes to that store, and the central index keeps growing whether or not anyone queries it. Levelrail keeps metrics and logs where they are collected, in each node’s own SQLite-backed store (the same modernc.org/sqlite used elsewhere in the control plane). The control plane queries agents on demand instead of ingesting continuously.
Today there is exactly one node (control plane and agent share a process, see internal/agent’s in-memory transport), so a “federated query” fans out to a single source. When a second node appears, nothing changes for you. The same queries and dashboards work against more sources.

How it actually works

Metrics collection. internal/telemetry.Collector samples every running container’s Docker stats every 15 seconds (metricsCollectionInterval in cmd/levelrail/main.go). Samples are stored under resource IDs: The same scheme is used across internal/alerting, the log store and resource-usage ranking, so an app’s metrics, logs and alert rules all sit under the one identifier its reconciler uses. Log collection. internal/telemetry’s log collector reads each container’s Docker log stream directly (not the json-file driver’s raw files), chunks and compresses it, and indexes it for full-text search. A LogBroadcaster sends each line to live SSE subscribers at the same moment it is written to the store, so live tailing and historical search read the same pipe.

Rollups and retention

Raw 15 second samples are rolled up into 1 minute and 1 hour buckets by a background job (telemetry.Maintainer).
  • Each run computes at most APP_METRICS_ROLLUP_MAX_BUCKETS closed buckets per tier and commits them together with a per-tier watermark in one transaction, so an interrupted run resumes where it stopped and recomputing a bucket is idempotent.
  • Counters (http_*) are summed, gauges are averaged.
  • Queries pick a tier by range (raw up to APP_METRICS_RAW_QUERY_MAX_RANGE, 1 minute up to APP_METRICS_MINUTE_QUERY_MAX_RANGE, otherwise 1 hour), move to a coarser tier when the finer one no longer retains the range start, and fill the not yet rolled up tail from the finer tier.
  • Retention uses fixed sweeps, not query-time filtering. runMetricsRetentionSweep and runLogsRetentionSweep (in cmd/levelrail/main.go) delete anything older than the window once per hour. The sweep interval itself is not configurable.
When the database exceeds the size cap, the oldest tenth of the finest tier is deleted, but only rows already covered by the next tier, so raw samples that are not yet rolled up are never dropped for size. The cap measures the whole file, which also holds logs. Only metric tiers are pruned, so a database dominated by logs can stay over the cap (a warning is logged).

API reference

CLI

Metrics and resource usage

--kind accepts the 18 channel kinds listed under Notification channels.

Not built yet (deliberate gaps)

  • Request metrics only cover traffic that passes through this control plane’s embedded Caddy (proxy and static routes). WebSocket and long-lived streams are recorded when they end, so their latency is the connection lifetime.
  • Multi-node request federation reuses the MetricsSource interface but has only been exercised in process, since remote nodes do not run their own ingress yet.
  • True host-level readings (real free/total CPU or memory) for any node other than the one running the control plane. GET /api/v1/nodes/{id}/metrics and GET /api/v1/nodes/resource-usage both sum already-collected per-container samples. A remote node’s memory_total_bytes and memory_available_bytes come from its agent’s host report on nodes/resource-usage only (not as time series), and its disk_used_bytes and disk_total_bytes stay absent rather than wrong. A future per-node agent writing host samples under the same node:<id> resource-ID format would show up in both endpoints automatically.
  • No node reports its own CPU core count, so the fleet-wide total_cpu_percent is a raw sum, not a percentage of fleet CPU capacity.
  • Databases placed on a node do not appear in summed CPU and memory metrics.
  • No levelrail-cli apps logs download wrapper (the endpoint exists and works via curl).
  • No download endpoint for database logs.
  • The slow query threshold is control-plane-wide (APP_DATABASE_SLOW_QUERY_THRESHOLD_MS), not configurable per database.
  • Postgres slow-query parsing only captures the single log line the duration: entry occupies. A query long enough to wrap, or followed by a DETAIL or parameters line, is not stitched back together.
  • The crashloop alert’s attached lines are not stored with the alert. GET /api/v1/apps/{name}/failure-context returns the current tail instead.
  • The route breakdown is a bounded template of the URL path (first 3 segments, ids collapsed), not full paths, and it is not recorded for non-ingress traffic.
  • The investigation timeline does not include alert fire and resolve events yet. Use levelrail-cli alerts history.
  • The alert evaluation interval (30s) is fixed, not env-configurable (unlike per-kind thresholds).
  • No alert-rule-specific change history (visible only in the generic GET /api/v1/audit-log).
  • Grouping, streak, flap and held-notification state is in memory and does not survive a control plane restart.
  • Silences and history are global, not scoped by IAM policy on individual apps: any principal with the base read or write ability can list or create them for any app.

See also

Public status page

Opt-in read-only page with component status, uptime bars and incidents.

API Reference

Full telemetry endpoint documentation.

Feature Catalog

Metrics and logs in the platform overview.

Architecture

Telemetry design decisions and phase 2 rationale.
Last modified on October 12, 2026