Reading logs
Don't know what you're looking for yet? Start with
get_timeline, not here.get_logsreturns 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 asignatureid you pass straight back toget_logsto read the real lines behind the one error that matters. See the observability guide.
Debugging a broken app? Reach for
debug_appfirst.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 asget_app'shealthblock withdiagnostics:true), filtered build and runtime logs (defaultwarn+), env key names + their sources (likelist_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 withlogTail,buildLogLevel/runtimeLogLevel,since(an ISO timestamp to window everything to an incident), andincludeRuntimePrevious: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 targetedget_logsbelow 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 adeploycomes back withstatus: "error": that bare status tells you the build failed, and the build log tells you why. Check the response'sfailureblock first, though — when the failure matched a known class,deployclassifies 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, failedRUNsteps. - 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_logscall 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_deploymentswill 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-stylenoteand empty entries. Checkget_app(itshealthblock), then deploy or start it. - If the backend is momentarily unreachable, you get an
unreachablenoterather than an error. Build logs (source: "build") still work, and you can fall back to the edge-probe signal fromdeploy/rollback(theedges/edgeNotefields) 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
deploymentIdto read the most recent deployment (the usual case right after a failed deploy). (deploymentIdis ignored forsource: "runtime", which reads the live service.) - All the filters (
grep/level/since) andtailwork identically across both sources, using the same parser. tailbounds how many trailing lines are fetched (default200, max10000) before filtering. Build logs can be long; start small and raisetailif the error is scrolled off the top.- Get a specific
deploymentIdfromlist_deploymentswhen you want an older build (e.g. comparing the last good deploy against the broken one). previous(source: "runtime"only, defaultfalse) 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 aprevious_logs_unavailable_fell_back_to_currentnote. Ignored forsource: "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 withinstance_not_foundwhen 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 aninstanceIdso you can tell the streams apart.- an instance id — an opaque
idfrom a previous response'sreadState.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:
grepis matched against the raw line, case-insensitive. A regex by default. An invalid pattern silently falls back to a substring match. SetgrepRegex: falseto force a literal substring.levelkeeps only entries whose level was parsed off the line. Accepts a single level (trace/debug/info/warn/error/fatal) orwarn+/error+for "that level or worse". Lines with no detectable level are dropped when this is set.since/untilkeep entries inside an ISO window. Lines with no parseable timestamp are kept (we can't prove they fall outside it). They filter the lines thetailreturned — they don't reach further back than the tail did.signaturekeeps only the lines matching one message template, using an id fromget_timeline'slogs.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 withgrep(passing both fails withconflicting_log_filter); composes freely withlevel/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
}
logsis 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 anoteflagging that.entriesis the parsed, filtered view: one object per non-blank line, with a best-efforttsandlevelwhen they can be detected, andmessage(the line minus any leading timestamp). Branch on these instead of regexinglogs.totalis how many entries were parsed from the fetched window;matchedis how many passed the filters (and equalsentries.length).truncatedistruewhen the fetched window was completely full (total >= tail), i.e. older lines almost certainly exist, so raisetailto see them. (The REST surface gives no exact line total, so this is a heuristic.)redactedistruewhen the redaction pass replaced at least one secret-shaped span in this response (see Secret redaction below);falsewhen nothing matched. Whentrue, thenotealso says so.- If the app has no deployments yet,
deploymentIdisnull,entriesis[], and anotesays 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:
- 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***. - 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, andpassword=/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: truemeans 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 carriesretryAfterSeconds(wait that long, then re-run the identical deploy) andresource(which axis ran out — usuallymemory). 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
failureblock on anerrormeans the build itself failed. Run the loop above.
Troubleshooting
logsis empty right after triggering a deploy → the build hasn't written to the file yet. Wait fordeployto 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 anunreachablenote, the backend is momentarily unavailable, so fall back to the edge probe and your app's readiness route.