Skip to content

Observability

View as Markdown

Log history and log drains

Runtime logs (what your containers write to stdout and stderr) are stored on the server that runs them and can be shipped from that server to Loki, OpenTelemetry, syslog or any HTTP intake. The control plane stores no runtime log line: it asks the server's agent.

Log history#

Every agent captures the output of every managed service container, continuously. A viewer is not needed: closing the Logs tab discards nothing. Build and deploy output are separate: they are stored per deployment (10,000 lines), see deployments.

Where <state-dir>/logs/<serviceId>/ on the server (/var/lib/nebulactrl-agent/logs by default), compressed, nowhere else
How long 7 days by default (1–90)
How much at most 500 MB per service and 5 GB per server, compressed on disk; the oldest lines go first (whole segments of at most 8 MB / 6 hours)
What stdout and stderr of replicas of services; not task containers, helper containers or the proxy
Configurable per server: server page → Settings → Log history, or PUT /api/v1/servers/{serverId}/log-settings (scope servers:write)

Limits take effect at once; lowering them deletes the oldest segments within a minute. Turning capture off stops storing new lines; what is stored stays until it expires, and turning capture on again continues from where Docker still has the lines.

A container's output from before the agent first saw it is taken from Docker's own log (at most the retention window), so history starts with what Docker still has. Docker's own log rotation (max-size on the container) is independent of this store.

Reading it#

Service → Logs and Environment → Observability have a Live | History switch and a time range (last 15 minutes … 7 days, or a custom range). History uses the same search box as the live view: the query is sent to the server and evaluated there, so searching 7 days does not move 7 days of log over the wire. The query language is the one documented in the search box: free text, "phrases", level:error, -service:web, @status:[400 TO 499], @path.to.field:value for JSON and logfmt lines, OR/NOT/parentheses, wildcards. The Go implementation on the agent is checked against the web one with a shared fixture (tests/fixtures/logquery/cases.json), so the same query matches the same lines in both.

API: GET /api/v1/services/{serviceId}/logs/history and GET /api/v1/environments/{environmentId}/logs/history (scope projects:read):

Parameter
from, to RFC 3339; from inclusive, to exclusive. Default: everything the server keeps.
q the search query
limit 1–2000, default 500
order desc (default, newest first) or asc
cursor nextCursor of the previous page

The answer has items (ts, line, serviceId, serviceName, replica, deploymentId, stream), nextCursor when more may follow, oldest/newest (what the server still has), capturing, and partial: true when the agent stopped scanning before the page was full (a rare query over a lot of log; keep paging with the cursor). Secret values are masked like in the live view.

History is unavailable while the server is offline. The service endpoint answers 503 server_offline and the UI says "History is stored on <server> and is unavailable while it is offline"; the environment endpoint lists the affected services in unavailable and still returns the others. An agent older than this feature answers 409 agent_outdated.

A service that moved to another server keeps its older history on the old one; the API reads the server the service is placed on now.

Downloading logs#

To attach a log to a bug report or keep a copy, download it as a file. Secret values are masked exactly as in the log views, and every download is audited (service.logs.download, deployment.logs.download); the audit entry records the range and format, never the content.

Service runtime log Deployment build and deploy log
API (scope logs:read) GET /api/v1/services/{serviceId}/logs/download GET /api/v1/deployments/{deploymentId}/logs/download
UI Download in the service's Logs tab (Live and History; History sends its time range and search) Download above the log of an opened deployment
CLI nebulactrl services download-logs <service> nebulactrl deployments download-logs <deployment-id>
Source the server's stored history (agent; 503 server_offline while it is offline, 409 history_disabled when capture is off) the persisted lines (at most 10,000; streams=all adds the 500-line debug trace)

Query parameters: format (txt, default, one <time> <line> per line, with [build], [deploy] or [debug] added to deployment lines (runtime lines carry none); or ndjson, one {ts, stream, line, …} object per line), from and to (RFC 3339, from inclusive, to exclusive), q (the log search language, service logs only), component (Compose stacks, service logs only), streams (deployment logs only). Service lines come oldest first. A service download holds at most 200,000 lines: a longer range ends with a # download truncated … line (text) or {"note": …} object (NDJSON), so narrow the range. A read error after the first page ends the file with the same kind of note, because the bytes already sent cannot become an error response.

sh
nebulactrl services download-logs acme/production/web --from 24h -o web.log
nebulactrl services download-logs web --format ndjson --grep 'level:error' > errors.ndjson
nebulactrl deployments download-logs 6f1c0d5e-… --streams all -o deploy.log

--from/--to take an RFC 3339 time, a date, or a duration ago (90m, 24h). Without -o the file goes to stdout; with it the file is created with mode 0600, and only once the server answered, so a refusal leaves nothing behind.

Log drains#

A drain ships lines from each agent, from its own host, to a destination. Drains are configured per team (Settings → Log drains, scope admin), optionally narrowed to projects, services and a query.

Kind Sends Credentials
Loki the push API (…/loki/api/v1/push), JSON, grouped into streams by service, project, environment, server, level plus your static labels; X-Scope-OrgID for a tenant basic auth or bearer token, extra headers
OpenTelemetry (OTLP/HTTP) logs in OTLP JSON (…/v1/logs), resource attributes service.name, service.namespace, deployment.environment.name, host.name bearer token and/or headers
Syslog RFC 5424 over UDP, TCP or TCP+TLS (octet-counting framing), the service labels in structured data none
HTTP JSON batches of records (timestamp, message, level, service, project, environment, server, …) as a JSON array or NDJSON, to any intake. Field names and static fields are configurable, which covers Datadog (https://http-intake.logs.datadoghq.com/api/v2/logs, header DD-API-KEY, field ddsource), Better Stack (bearer token) and Axiom (bearer token, dataset in the URL) headers, bearer token, or basic auth
  • Credentials are send-time secrets: sealed in the database, never returned by the API (a mask and the header names only), delivered to the agents with the configuration and never persisted in a command. They are masked from every error message.
  • Secrets in lines: before a line leaves the host, the agent masks the secret values of that service (variables, git token, deploy key, registry credentials; 6+ characters, also their URL-escaped and base64 forms) with ••••••. The values come with the configuration and are refreshed at every deploy; a secret you changed and have not deployed yet is not known to the agent. A drain's query is matched against the masked line.
  • Start: a new drain ships what is written after it was saved, not the stored history.
  • Delivery: batches of up to 500 lines or 1 MB, at least once (a crash can repeat the last two seconds). A failing destination is retried with exponential backoff (1 s to 5 min, honouring Retry-After). The log store is the buffer: the drain keeps a position per service and reads from the disk, so a destination that is down for hours loses nothing as long as the lines are still stored and the backlog fits the buffer bound (default 256 MB per server and drain; the oldest lines are skipped and counted as dropped past it). A destination that rejects a batch for good (HTTP 400, 413, 422, other non-retryable 4xx) has that batch dropped and counted, so one bad record cannot block the drain; 401, 403, 408, 425, 429 and 5xx are retried.
  • Health: Settings → Log drains shows, per server, Delivering, Retrying (the last delivery failed), Failing (five failures in a row) or Disabled, the last error (masked), lines sent and dropped, and the backlog on disk. Agents report every 30 seconds.
  • Test: Send a test line delivers one line from a connected server (or the one you pick), which is where reachability and credentials count.
  • A drain ships from every server of the team that hosts matching services. A server that is offline ships nothing until it reconnects; its backlog waits on its disk.

Quick reference#

sh
# Loki with basic auth, only errors of one project
curl -X POST $URL/api/v1/log-drains -H "Authorization: Bearer $TOKEN" -d '{
  "name":"grafana","kind":"loki",
  "config":{"loki":{"url":"https://logs-prod.grafana.net/loki/api/v1/push","tenantId":"12345"}},
  "secrets":{"username":"12345","password":"glc_…"},
  "allProjects":false,"projectIds":["<project-id>"],"query":"level:error"}'

# Datadog through the generic HTTP drain
curl -X POST $URL/api/v1/log-drains -H "Authorization: Bearer $TOKEN" -d '{
  "name":"datadog","kind":"http",
  "config":{"http":{"url":"https://http-intake.logs.datadoghq.com/api/v2/logs","extraFields":{"ddsource":"nebulactrl"}}},
  "secrets":{"headers":{"DD-API-KEY":"…"}}}'

curl -X POST $URL/api/v1/log-drains/<id>/test -H "Authorization: Bearer $TOKEN" -d '{}'

Changes are audited (log_drain.create|update|delete|test, server.log_settings.update); credentials appear only as "changed".

Limits to know#

  • Only stdout/stderr of containers Docker still has: a container that logs to a file inside itself is not captured. Stdout and stderr share one stream (no separate stderr facet).
  • Lines are ordered by arrival at the agent; lines of two replicas can be a few milliseconds out of time order. A page is sorted by time, paging is by arrival.
  • History and drains need an agent of this version: an older one answers history requests with 409 agent_outdated. Update agents with the install script.
  • There is no log search across servers in one scan beyond the environment view (one request per service, merged by time) and no full-text index: a query scans the compressed blocks of its time range (blocks outside the range are skipped by the time index; a scan is bounded to about 256 MB of decompressed log or 4 seconds, then returns a cursor to continue).
  • HTTP access lines of the proxy are not stored here: see traffic analytics.