Browse docs All docs
Docs / Reading logs reference
Guidedeploymill://guides/logs

Reading logs

Don't know what you're looking for yet? Start with get_timeline, not here. get_logs returns lines; it can't tell you when something changed or what else happened at the same time. get_timeline({ applicationId }) puts deploys, health-gate verdicts, sleeps, request rate/latency and clustered log signatures on one time axis, then hands you a signature id you pass straight back to get_logs to read the real lines behind the one error that matters. See the observability guide.

Debugging a broken app? Reach for debug_app first. debug_app({ applicationId }) is a read-only bundle that returns, in one call: the full health snapshot with pod diagnostics (CrashLoopBackOff / OOMKilled / ImagePullBackOff reasons, storage warnings, same as get_app's health block with diagnostics:true), filtered build and runtime logs (default warn+), env key names + their sources (like list_env_vars), the health-gate verdicts of recent deploys, and the recent per-app audit entries (who deployed / rolled back / changed env). It saves 5+ sequential round-trips. Tune it with logTail, buildLogLevel/runtimeLogLevel, since (an ISO timestamp to window everything to an incident), and includeRuntimePrevious:true (the last-terminated container's crash output, useful for CrashLoopBackOff). Every signal is best-effort: one unavailable signal comes back null/empty rather than failing the call. Use the targeted get_logs below when you already know you only need one log stream.

get_logs returns an app's logs in two flavors, selected with source: build/deploy logs (source: "build", the default) and runtime container logs (source: "runtime"). Both come back as raw logs text plus parsed, filterable entries.

  • source: "build" is the first thing to reach for when a deploy comes back with status: "error": that bare status tells you the build failed, and the build log tells you why. Check the response's failure block first, though — when the failure matched a known class, deploy classifies it for you and reading the log is unnecessary (see below).
  • source: "runtime" is what you want when the deploy succeeded but the app misbehaves once it's live (500s, crashes on a request). It's the running container's own stdout/stderr.

What source: "build" covers

  • Build logs are docker build / buildpack output: dependency installs, compile errors, missing files, failed RUN steps.
  • Deploy logs cover the platform pushing the image and starting the service: the steps the platform runs around your container.

The build runs as an isolated job whose output is captured and persisted onto the deployment record, so it stays readable after the build job itself is gone. That has two consequences worth knowing:

  • During a build, what you read is a recent snapshot, not a live tail. The accumulated output is written every few seconds, so a get_logs call mid-build can lag the build by a moment. Poll rather than expecting a stream.
  • History is bounded. Only the most recent deployments keep their build output, and each is capped to its trailing portion. If you need to compare against an older build, read it soon after it happens — list_deployments will still show the deployment long after its log has aged out.

What source: "runtime" covers

  • Runtime / container logs are your app's own stdout/stderr once it's running (request logs, console.log, stack traces from a live request), timestamped. A read addresses one instance at a time — see Reading a specific instance for why that matters during a deploy.

DeployMill reads these straight from the running app and returns parsed runtime entries just like the build source. No extra infrastructure to configure.

  • If the app isn't running (stopped / never deployed), you get a service_not_found-style note and empty entries. Check get_app (its health block), then deploy or start it.
  • If the backend is momentarily unreachable, you get an unreachable note rather than an error. Build logs (source: "build") still work, and you can fall back to the edge-probe signal from deploy/rollback (the edges / edgeNote fields) plus a readiness route (probePath).

Usage

get_logs({ applicationId })                            → latest deployment, last 200 lines, parsed
get_logs({ applicationId, tail: 1000 })                → last 1000 lines of the latest deployment
get_logs({ applicationId, deploymentId, tail: 500 })   → a specific historical deployment
get_logs({ applicationId, level: "error+" })           → only error/fatal entries
get_logs({ applicationId, grep: "npm ERR|ENOENT" })    → lines matching a regex (case-insensitive)
get_logs({ applicationId, grep: "out of memory", grepRegex: false }) → literal substring
get_logs({ applicationId, since: "2026-05-30T23:00:00Z" })          → entries at/after an instant
get_logs({ applicationId, source: "runtime" })         → running container stdout/stderr, last 200 lines
get_logs({ applicationId, source: "runtime", level: "error+", tail: 1000 }) → runtime errors only
get_logs({ applicationId, source: "runtime", previous: true })      → the LAST-TERMINATED container's crash output (CrashLoopBackOff)
get_logs({ applicationId, source: "runtime", instance: "previous" }) → the OUTGOING instance during a rolling deploy (its shutdown output)
get_logs({ applicationId, source: "runtime", instance: "all" })      → every instance, each entry tagged with its `instanceId`
  • Omit deploymentId to read the most recent deployment (the usual case right after a failed deploy). (deploymentId is ignored for source: "runtime", which reads the live service.)
  • All the filters (grep/level/since) and tail work identically across both sources, using the same parser.
  • tail bounds how many trailing lines are fetched (default 200, max 10000) before filtering. Build logs can be long; start small and raise tail if the error is scrolled off the top.
  • Get a specific deploymentId from list_deployments when you want an older build (e.g. comparing the last good deploy against the broken one).
  • previous (source: "runtime" only, default false) reads the last-terminated container's logs, the crash output from before the most recent restart. Reach for it when an app is stuck in a CrashLoopBackOff and the live logs only show the newest (re)start. If nothing prior exists, or the previous container's logs already rotated away (common in a fast crash loop), it falls back to the current container and sets a previous_logs_unavailable_fell_back_to_current note. Ignored for source: "build".

Reading a specific instance

An app can have more than one instance running at once, and a runtime read addresses one of them at a time. The case that matters is a deploy: the platform starts the replacement before stopping the old instance, so for the length of the old one's shutdown both are alive. If your app does work on SIGTERM — draining in-flight requests, finishing jobs, logging a shutdown report — that output is written by the outgoing instance, and it is readable only while that instance still exists.

instance picks which one you read:

  • "current" (default) — the instance actually serving traffic.
  • "previous" — the outgoing/older one. Fails with instance_not_found when the app has only one instance (which is also your answer: the old one is already gone, and its logs went with it).
  • "all" — every instance, newest first. Each parsed entry carries an instanceId so you can tell the streams apart.
  • an instance id — an opaque id from a previous response's readState.instances.

instance is not the same as previous. previous: true reads the same instance's prior container, from before a restart — the CrashLoopBackOff case. A deploy replaces the whole instance, so previous: true can never reach the outgoing one.

Every runtime response reports what it saw in readState:

{ "readState": {
    "instances": [
      { "id": "…-7f9c", "phase": "Running", "startedAt": "2026-09-04T14:02:11Z", "terminating": false },
      { "id": "…-4b21", "phase": "Running", "startedAt": "2026-09-04T13:44:02Z", "terminating": true }
    ],
    "readInstanceIds": ["…-7f9c"] } }

Anything in instances that is missing from readInstanceIds holds lines this call did not read — and coverage.complete is false while that is true, with an other_instances_not_read note code. terminating: true means that instance is shutting down: read it now or lose it.

If the instance exits between the listing and the read — the normal end of a shutdown — you get the instance_logs_unavailable note code and coverage.complete: false rather than an empty success. The platform does not retain an instance's logs after it is gone, so an old instance's output is only reachable while it drains.

Filtering

All three filters are best-effort and applied server-side over the fetched window:

  • grep is matched against the raw line, case-insensitive. A regex by default. An invalid pattern silently falls back to a substring match. Set grepRegex: false to force a literal substring.
  • level keeps only entries whose level was parsed off the line. Accepts a single level (trace/debug/info/warn/error/fatal) or warn+/error+ for "that level or worse". Lines with no detectable level are dropped when this is set.
  • since / until keep entries inside an ISO window. Lines with no parseable timestamp are kept (we can't prove they fall outside it). They filter the lines the tail returned — they don't reach further back than the tail did.
  • signature keeps only the lines matching one message template, using an id from get_timeline's logs.signatures. This is the intended drill-down: the timeline tells you which error is spiking, the signature fetches its lines, and you never have to write a regex that might get downgraded. Mutually exclusive with grep (passing both fails with conflicting_log_filter); composes freely with level/since/until.

Knowing what you didn't see

Every response carries servedBy and a coverage block:

{ "servedBy": "direct",
  "coverage": { "complete": false, "windowFilled": true,
                "note": "The requested line window filled up, so older lines exist that this read did not see…" } }

coverage.complete is the field to branch on. When it's false, lines exist that this call did not read — because the line window filled up, because the source was unavailable, or (runtime) because another instance of the app exists that this read did not address — so an empty or clean-looking result is not evidence that nothing was logged. Raise tail, narrow with signature/level, or accept the uncertainty explicitly. The same applies when a runtime read comes back empty because the app was stopped or unreachable: that response reports complete: false precisely so it can't be mistaken for silence.

servedBy is "direct" on every path today — read live from the running pod or from the deployment record, with nothing cached in front.

Return shape

{
  "deploymentId": "…",
  "status": "error",
  "title": "…",
  "tail": 200,
  "logs": "…raw build output…",
  "entries": [
    { "line": "2026-05-30T23:12:18Z ERROR npm ERR! missing script: build",
      "ts": "2026-05-30T23:12:18.000Z", "level": "error",
      "message": "ERROR npm ERR! missing script: build" }
  ],
  "total": 200,
  "matched": 1,
  "truncated": true,
  "redacted": false
}
  • logs is the log text, after secret redaction (see below). An empty string means the log file is missing or not yet written (the deploy may still be starting, or the log rotated). The response adds a note flagging that.
  • entries is the parsed, filtered view: one object per non-blank line, with a best-effort ts and level when they can be detected, and message (the line minus any leading timestamp). Branch on these instead of regexing logs.
  • total is how many entries were parsed from the fetched window; matched is how many passed the filters (and equals entries.length).
  • truncated is true when the fetched window was completely full (total >= tail), i.e. older lines almost certainly exist, so raise tail to see them. (The REST surface gives no exact line total, so this is a heuristic.)
  • redacted is true when the redaction pass replaced at least one secret-shaped span in this response (see Secret redaction below); false when nothing matched. When true, the note also says so.
  • If the app has no deployments yet, deploymentId is null, entries is [], and a note says to deploy first.

For source: "runtime" the shape is the same logs/entries/total/matched/truncated/redacted, but instead of deploymentId/status/title you get source: "runtime" and serviceName (the running service read). A note explains the empty case when the app isn't running or the backend is momentarily unreachable.

Secret redaction

Both sources scrub secret-shaped material out of logs and entries before returning. Log output routinely contains the very credentials DeployMill injected (DATABASE_URL, the S3 access keys, bound secrets) and the app's own end-user PII, and get_logs hands its result straight into an agent transcript. This keeps the same names-only posture as list_env_vars: managed secret values aren't supposed to cross back to the client.

Two passes run before the response is built:

  1. Known injected values. DeployMill resolves the secret-shaped env values it set for this app (keys matching *SECRET*, *TOKEN*, *PASSWORD*, *_KEY*, DATABASE_URL, DSNs, …) and replaces any literal occurrence with ***.
  2. Structural shapes. A best-effort scrub of common patterns regardless of the env: connection-string passwords (postgres://user:***@host), AWS access-key ids (AKIA…/ASIA…), Bearer … tokens, and password=/token=/api_key=-style pairs.

When anything was replaced, redacted: true and the note flags it. Limits (best-effort, not a guarantee): it won't catch novel/encoded secrets, a custom credential format DeployMill didn't inject, or arbitrary PII (names, emails, IP addresses) in free-form log text. Treat get_logs output as lower-risk, not secret-free. Don't paste it somewhere it could be indexed without a glance. Short secret values (under 6 characters) are intentionally not literal-scrubbed to avoid shredding unrelated log text. The *** you see in logs is DeployMill's redaction, not your app's output.

The failed-deploy loop

1. deploy({ applicationId })            → status: "error" (+ failNote pointing here)
2. get_logs({ applicationId })          → read the build output, find the failing step
3. fix the cause (Dockerfile, deps, source) and push
4. deploy({ applicationId }) again      → repeat until status: "done"

start_project and deploy both surface this pointer on failure, so an agent that hits an error status knows to call get_logs next instead of guessing.

Not every failure is your code

A failed deploy is not automatically a bug in the app. When the failure matches a class deploymill recognizes, the deploy response (and the matching list_deployments record) carries a structured failure block instead of leaving you to infer it from the log:

{
  "status": "error",
  "failure": {
    "code": "registry_push_failed",
    "retryable": true,
    "message": "The image built, but pushing it to the container registry failed …",
    "nextStep": "Re-run deploy unchanged. If it fails the same way twice …"
  }
}
  • retryable: true means re-run the same deploy unchanged — the loop above does not apply, because there is nothing in the source to fix. There are two such classes today, and both mean the failure was DeployMill's rather than your app's:
    • registry_push_failed — the image built fine and the push hit a registry-side auth or transport error, which commonly clears on an immediate retry. If it repeats identically, it is no longer transient (the push credential lost its scope, or the registry is down) — that is an operator fix, not a code change.
    • cluster_at_capacity — the image built and pushed, and DeployMill had no node with room to run it, so the container was never placed. The block carries retryAfterSeconds (wait that long, then re-run the identical deploy) and resource (which axis ran out — usually memory). Whatever was already serving is untouched and still live. Do not rebuild, edit the Dockerfile, or shrink the app in response: none of that will make the deploy land, because nothing about the app is the problem. If it persists for more than an hour, it's an operator capacity problem, not yours.
  • No failure block on an error means the build itself failed. Run the loop above.

Troubleshooting

  • logs is empty right after triggering a deploy → the build hasn't written to the file yet. Wait for deploy to reach a terminal status (done/error), then read.
  • Error is cut off at the top → raise tail.
  • Deploy succeeded but the app 502s / throws on requests → that's a runtime problem, not a build one. Read get_logs({ applicationId, source: "runtime" }) for the container's own stdout/stderr. If it comes back empty with an unreachable note, the backend is momentarily unavailable, so fall back to the edge probe and your app's readiness route.