Log Endpoints¶
There is one unified log endpoint. It is what backs mecca logs <app>.
| Method | Path | Scope | Description |
|---|---|---|---|
GET |
/api/logs |
read | Query logs across every source, with filters and full-text search |
Build-specific and deployment-specific log views live on their own resources
rather than under /api/logs:
GET /api/builds/id/{build_id}/logs— see BuildsGET /api/deployments/{deployment_id}/logs— see Deployments
HTTP polling (not WebSocket)¶
Log reads are plain HTTP. A follow-mode client re-issues the request with a
narrower since window rather than holding a connection open — simpler and
more reliable through any proxy or firewall, and trivial to implement in any
HTTP client.
GET /api/logs¶
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
app_name |
string | — | Filter by app (slug or canonical name). Omit and a tenant sees only their own group's apps. |
source |
string | — | agent, deploy, build, container, or backup |
service_name |
string | — | Filter by service |
deployment_id |
string | — | Filter by deployment |
build_id |
string | — | Filter by build |
action_id |
string | — | Filter by queue action |
level |
string | — | info, warning, error |
search |
string | — | Full-text search over the message |
since |
string | 1h |
Relative time range, e.g. 1h, 24h, 7d |
limit |
integer | 100 |
Max rows (1–1000) |
offset |
integer | 0 |
Pagination offset |
branch |
string | — | Only scopes the access check — the log data itself is branch-free |
Response: 200 OK
{
"logs": [
{
"id": 918273,
"timestamp": "2026-03-15T09:12:22.123000+00:00",
"source": "deploy",
"level": "info",
"app_name": "my-web-app",
"service_name": "web",
"deployment_id": "abc12345",
"build_id": null,
"action_id": "abc12345",
"instance_id": "…",
"message": "phase=health_check target InService",
"metadata": {}
}
],
"total": 1,
"has_more": false
}
An error key is added — with logs: [] — when the log store itself could
not be reached, so "the query failed" is never silently rendered as "there is
nothing to show".
Supplying an id is enough¶
deployment_id, build_id and action_id are globally unique, so supplying
one identifies the owning app by itself: the API resolves the owner, checks
your access against that, and ignores whatever app_name / branch you
typed. You do not need to know which group or branch the action belongs to.
An id you are not allowed to see behaves exactly like an id that does not exist — the endpoint is not an existence oracle. Id filters must be hex-or-hyphen shaped; a wildcard-looking value is rejected rather than matched loosely.
Log source filter (source)¶
The source filter selects the origin of the rows. Each resolves against
a different backing store:
source |
Origin | Resolution |
|---|---|---|
deploy |
Platform log store | Deployment-lifecycle rows — matched as (source = 'deploy' OR (source = 'agent' AND deployment_id IS NOT NULL)) |
build |
builds table |
Build output read directly from the build records (capped at the 5 most recent builds per query) |
container |
Platform log collector | Your container's stdout/stderr |
backup |
Platform log store | Backup / restore / point-in-time-restore rows — exact source = 'backup' |
agent |
Platform log store | The host agent's own rows — exact source = 'agent' |
Why deploy matches two shapes¶
The agent originally wrote every row tagged as coming from the agent, even when the row carried a deployment_id (the deploy context). A naive exact match on source = 'deploy' therefore returned zero rows. The filter also matches the legacy shape, so it resolves for both:
- historical rows written with
source = 'agent'but adeployment_id, and - newly-tagged rows — the agent derives
sourcefrom context (an explicit override wins, elsedeployment_id→deploy,build_id→build, elseagent).
The legacy arm is pinned to source = 'agent'. It was originally the unqualified deployment_id IS NOT NULL, which matched any row carrying a deployment_id whatever its source — so it silently swallowed source = 'container' crash-tail rows, and would swallow source = 'backup' rows (which keep deployment_id = action_id so correlating by deployment still works). source = 'agent' AND deployment_id IS NOT NULL is exactly the legacy shape the widening was introduced to rescue, so pinning it is behaviour-preserving.
Container logs merge two sources¶
source=container reads your container's stdout/stderr from the platform log collector and merges in the crash tail the agent captures directly. When a container dies before its output is flushed, the last 30 lines are still stored, so the very output you need to debug a crash-on-boot is not the output that goes missing.
Empty container-log results are classified¶
An empty source=container response carries a reason explaining why it is empty, so "nothing deployed here" is distinguishable from "the app is quiet":
| Field | Meaning |
|---|---|
reason: "no_log_group" |
No log stream matched. One is created before the container starts, so a deployed service always has one — this means nothing has deployed for this app/service/branch. A wider since cannot help. |
reason: "no_output_yet" |
Streams exist and read cleanly, but no events in the window. A wider since may help. |
error: "…" |
Every stream failed to read. The pipeline is broken — this is deliberately not reported as an empty result. |
error and reason are deliberately distinct: error means something is wrong; reason means nothing is wrong and here is why there is nothing to show. Neither appears on a non-empty response — if the crash-tail merge supplied rows, you get the rows — and a partial failure (one stream errors, another returns rows) is not an error.