What this covers
For contributors: where this lives in the source
For contributors: where this lives in the source
internal/telemetry: storage and queryinternal/alerting: rule evaluation and notificationinternal/api/metrics.go,node_metrics.go,database_metrics.go: metrics handlerslogs.go,live_logs.go,logs_download.go: log handlersapp_resource_usage.go,alerts.go,notification_channels.go: supporting handlers
Quick start
Query one app's CPU over the last hour, in 5-minute buckets
Search that app's logs for an error
Connect a Slack channel and test it
Create a threshold alert that notifies through the channel
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).
summaryandbaseline: request count, rate, 4xx and 5xx share, p50, p95 and p99 for the window and for the equal-length window before it.top_routesandstatus_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 flaggedlikely_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 backAPP_INVESTIGATE_LOOKBACKbefore the window, because the cause usually comes before the symptom.
- Dashboard
- CLI
- API
Failure context
GET /api/v1/apps/{name}/failure-context tells you why an app is down without opening the log viewer.
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
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.
App health timeline
App health timeline
/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.
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.Logs and slow queries
Logs and slow queries
/apps/$name/logs: two tabs,Live(LiveLogViewer, default) andSearch(LogSearchPanel). Search filters by text, level, stream, container and structured JSON fields (key=value,key!=value,key~text,key>n), takesfrom/tofrom 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/logsis 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.
Fleet overview and alerts
Fleet overview and alerts
- Dashboard home:
FleetUtilizationSummaryshows a compact fleet-wide CPU/memory/disk card (backed byGET /api/v1/nodes/resource-usage, polled every 30 seconds), aboveTopResourceConsumers(ranks every app by latest CPU, memory and network reading, backed byGET /api/v1/apps/resource-usage) andFleetResourceChart(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 sameGET /api/v1/nodes/resource-usagesnapshot, 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

Live tail details
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
LogCollectorreceives it from Docker. - The handler subscribes to the live broadcaster before running the backfill query, so there are no gaps.
Stored search parameters
Stored search parameters
- Field filters look like
field=status=500,field=route~/pay,field=latency>=250. Dotted keys such asreq.user.idreach nested objects. A line that is not JSON, or lacks the key, matches only!=. levelkeeps lines at or above it, using the JSONlevelorseverityfield 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
containerid and, when detected, alevel. The response lists thecontainersthat matched (before the container filter) with line counts, andtotal(matches beforelimit).
Compact query for agents
Compact query for agents
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 and database logs
Download and database logs
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.Reading lines in the log viewer (client side)
Reading lines in the log viewer (client side)
- Each row gets a subtle level tag (ERR, WRN, INF, DBG) detected from JSON
levelorseveritykeys,level=WARNstyle key/value pairs, or a leadingERROR,WARN,INFOorDEBUGprefix. - 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.
APP_DATABASE_SLOW_QUERY_THRESHOLD_MS (default 1000). Changing it takes effect on the database’s next reconcile (container recreate).Why MySQL reads a file instead of the log stream
Why MySQL reads a file instead of the log stream
/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.
node:<id>): GET /api/v1/nodes/{id}/metrics returns two kinds of reading.
- 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/agenthas no host-level stats collection).memory_limit_bytesis excluded to avoid multiplying the host’s approximate total memory by N containers. - 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 byHostDiskCollectorandHostPatchCollector.
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.
Request metrics (RED)
Every proxy and static route in the embedded Caddy goes through a smallrequest_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.
- Dashboard
- CLI
- API
- MCP and Go
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.
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=NonGET /api/v1/apps/{name}/metricsand/requestswidens 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=previousaddsprevious_points, the equal-length window beforefrom, with timestamps shifted forward so it overlays the current series./requestsalso returnsprevious_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
LatestByMetriccall 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,bto 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_percentandmemory_usage_bytesare the sum of every placed service’s latest sample (sum of containers, not a true host read).memory_total_bytes,disk_used_bytesanddisk_total_bytesare 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 inother_containers) andsystem_memory_bytessplit a node’s used memory, and always sum with the placed-services figure to the used total (memory_total_bytesminusmemory_available_bytes).
How remote nodes report memory
How remote nodes report memory
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.fleet rollup sums every node’s numbers.
total_cpu_percentis a raw sum, not a percentage of fleet capacity (no node reports its core count).memory_used_percentanddisk_used_percentare only computed from nodes that reported a capacity figure.nodes_with_memory_capacityandnodes_with_disk_capacitysay how many ofnode_countthat 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.
- 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.
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
Rule kinds and their configuration
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.
Backup missing rule logic
Backup missing rule logic
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 firingcrashloop 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(default5s), doubling up toAPP_RESTART_BACKOFF_MAX(default2m). - The delay starts over once the container has stayed up for
APP_RESTART_BACKOFF_RESET(default10m). - While waiting, the app’s
Readycondition isFalsewith reasonCrashLoopBackOffand a message such askeeps exiting (2 restarts), next restart in 9s.levelrail-cli apps status <name>shows it. - Set
APP_RESTART_BACKOFF_BASE=0to restore restart-immediately behavior. - The backoff is in memory, so a control plane restart clears it.
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.- On crashloop
- On SLO burn
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.- 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.Silences
Silences
- 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, defaultwarning, orcritical) andlabelsfor 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.
levelrail-cli alerts silence <app> <rule-id> --for 4h, or with the silence_alert_rule MCP tool.Maintenance windows and node-down inhibition
Maintenance windows and node-down inhibition
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, grouping and rate limits
Flapping, grouping and rate limits
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.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.
- 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.
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.
- Notifications
- API
- CLI and MCP
- Dashboard
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
Aslo_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.
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 300for 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 theget_slo_statusMCP 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 bychannel_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:
emailsends through the control plane’s SMTP sender (Settings -> Email, orAPP_SMTP_HOST,APP_SMTP_PORT,APP_SMTP_USERNAME,APP_SMTP_PASSWORD,APP_SMTP_FROM).webpushignoresnotify_url: the destination is every browser subscription registered under Settings -> Notification channels -> Browser push.
Destination checks and visibility
- Save-time checks. Creating or editing a channel whose
notify_urlis not an absolutehttporhttpsURL, or points at loopback, a private range, link-local orlocalhost, is refused with a400that 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=truein 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,resendandwebpushhold an address or key rather than a URL, so they are not checked this way. - Who sees the destination. A caller without
read:sensitive(theviewerrole, or a plainreadtoken) getshttps://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.emailaddresses stay visible. For an encrypted channel every caller,operatorandadminincluded, 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 ownnotify_urlinstead of achannel_idis 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 listandrevoke <id>. There is noregistersubcommand, 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, includingemail, retries up to 3 times on a transient failure with the same 500ms-then-1s backoff, through two send paths.
POST /api/v1/notification-channels/test: before saving (kind andnotify_urlin the body).POST /api/v1/notification-channels/{id}/test: against an existing channel.
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.
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 samemodernc.org/sqlite used elsewhere in the control plane). The control plane queries agents on demand instead of ingesting continuously.
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:
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_BUCKETSclosed 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 toAPP_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.
runMetricsRetentionSweepandrunLogsRetentionSweep(incmd/levelrail/main.go) delete anything older than the window once per hour. The sweep interval itself is not configurable.
API reference
CLI
Metrics and resource usage
Metrics and resource usage
Logs and slow queries
Logs and slow queries
Alert rules
Alert rules
Silences, maintenance windows and history
Silences, maintenance windows and history
Channels and browser push
Channels and browser push
--kind accepts the 18 channel kinds listed under Notification channels.Not built yet (deliberate gaps)
Metrics collectors
Metrics collectors
- 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
MetricsSourceinterface but has only been exercised in process, since remote nodes do not run their own ingress yet.
Node metrics
Node metrics
- 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}/metricsandGET /api/v1/nodes/resource-usageboth sum already-collected per-container samples. A remote node’smemory_total_bytesandmemory_available_bytescome from its agent’s host report onnodes/resource-usageonly (not as time series), and itsdisk_used_bytesanddisk_total_bytesstay absent rather than wrong. A future per-node agent writing host samples under the samenode:<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_percentis a raw sum, not a percentage of fleet CPU capacity. - Databases placed on a node do not appear in summed CPU and memory metrics.
CLI and API
CLI and API
- No
levelrail-cli apps logs downloadwrapper (the endpoint exists and works viacurl). - 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 aDETAILor 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-contextreturns 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.
Fixed configuration and scope
Fixed configuration and scope
- 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
readorwriteability can list or create them for any app.