Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
d4e37d5
feat(cli): prompt for worker name if not provided
johnstonmatt Aug 29, 2026
edc7199
fix(cli): gate the workers new prompts on stdin too
johnstonmatt Aug 29, 2026
93c7aa6
feat(cli): prompt for worker name if not provided
johnstonmatt Aug 29, 2026
5c84df0
fix(cli): gate the workers new prompts on stdin too
johnstonmatt Aug 29, 2026
0af0f0a
chore(workers): describe behaviour rather than its history in comments
johnstonmatt Aug 31, 2026
a74e3f9
feat(workers): bring the command family's output onto one shape
johnstonmatt Aug 29, 2026
548a185
test(cli): guard every legacy boolean flag against a required default
johnstonmatt Aug 29, 2026
1d7e06b
chore(workers): describe behaviour rather than its history in comments
johnstonmatt Aug 31, 2026
1a2236d
feat(workers logs): add `supabase workers logs`
johnstonmatt Aug 31, 2026
c4080de
feat(workers logs): print timestamps in local time
johnstonmatt Aug 31, 2026
b7c9864
feat(workers logs): add `--follow` to keep printing new lines
johnstonmatt Aug 31, 2026
2f68ad6
feat: show stream tags in worker logs when multiple sources
johnstonmatt Aug 31, 2026
c66065d
chore(workers): describe behaviour rather than its history in comments
johnstonmatt Aug 31, 2026
88e4832
Merge branch 'FUNC-840/select-workers-new-name' of https://github.com…
johnstonmatt Sep 1, 2026
9ae1f38
fix(cli): point the workers new retry at the experimental path
johnstonmatt Sep 1, 2026
12c4d1c
fix(cli): stop telling users to run a command that does not exist
johnstonmatt Sep 1, 2026
94d1e89
Merge FUNC-840/select-workers-new-name into FUNC-851/general-output-p…
johnstonmatt Sep 1, 2026
4f05bc5
Merge FUNC-851/general-output-polish into FUNC-853/workers-logs-command
johnstonmatt Sep 1, 2026
1f103f9
fix(workers): restore the experimental segment in worker span names
johnstonmatt Sep 1, 2026
de59392
fix(workers): stop echoing an empty --project-ref into retry suggestions
johnstonmatt Sep 1, 2026
d99e6b5
fix(workers): keep push's per-worker progress out of structured formats
johnstonmatt Sep 1, 2026
71af01d
docs(workers): mark the status and delete trailers as text-only
johnstonmatt Sep 1, 2026
11395a3
refactor(workers): rename workers logs --source to --kind
johnstonmatt Sep 1, 2026
6f8770d
docs(workers): mark the logs no-logs hint as text-only
johnstonmatt Sep 1, 2026
46321b4
fix(workers logs): tie the --kind choices to the stream map
johnstonmatt Sep 1, 2026
396498b
fix(workers logs): give -o priority over --output-format
johnstonmatt Sep 1, 2026
d949afd
fix(workers logs): flush telemetry when the project ref cannot resolve
johnstonmatt Sep 1, 2026
335b62a
fix(workers logs): let a Ctrl+C'd tail run its finalizers
johnstonmatt Sep 1, 2026
2597a39
fix(workers logs): stop --tail capping the follow poll
johnstonmatt Sep 1, 2026
f8575ee
fix(workers logs): make --tail 0 --follow mean what it says
johnstonmatt Sep 1, 2026
a00a02c
fix(workers logs): sanitise every rendered field, not just the guest …
johnstonmatt Sep 1, 2026
d06c876
refactor(workers logs): prefix the exported log-level type
johnstonmatt Sep 1, 2026
7009bd6
docs(workers logs): document the SIGINT exit code
johnstonmatt Sep 1, 2026
af1f813
fix(workers logs): bound ts_ms to a representable instant
johnstonmatt Sep 1, 2026
fa318ee
fix(workers logs): retry only the poll failures worth another request
johnstonmatt Sep 1, 2026
468d9e2
fix(workers logs): carry the composed line into stream-json
johnstonmatt Sep 1, 2026
008f160
Merge branch 'develop' of https://github.com/supabase/cli into FUNC-8…
johnstonmatt Sep 1, 2026
b3ea7df
Merge branch 'FUNC-840/select-workers-new-name' of https://github.com…
johnstonmatt Sep 1, 2026
c8977d0
fix(workers logs): green the checks the logs command turned red
johnstonmatt Sep 1, 2026
993fa7e
Merge remote-tracking branch 'origin/FUNC-851/general-output-polish' …
johnstonmatt Sep 1, 2026
b772491
test(config): stop a broken pipe preempting the exit-code diagnosis
johnstonmatt Sep 1, 2026
26722c0
Merge remote-tracking branch 'origin/develop' into FUNC-853/workers-l…
johnstonmatt Sep 3, 2026
21b144d
refactor(workers logs): drop the unused window seams and test followW…
johnstonmatt Sep 3, 2026
57f6249
fix(workers logs): report a burst the follow drain could not reach
johnstonmatt Sep 3, 2026
213f804
Revert "test(config): stop a broken pipe preempting the exit-code dia…
johnstonmatt Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# `supabase experimental workers logs <name>`

> **No live test yet.** The other `workers` commands skip live coverage because they
> run against the v2 Management API, which the supabase/cli-e2e-ci supabox stack is
> not expected to serve. This one reads the v1 analytics endpoint, which that stack
> may well serve — but a meaningful assertion needs a deployed worker that has
> actually emitted log lines, which the stack cannot provide. Revisit alongside the
> rest of the family.

## Files Read

| Path | Format | When |
| --------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- |
| `<SUPABASE_HOME or ~/.supabase>/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` is unset and the keyring holds no credential |
| `<workdir>/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project |
| `<SUPABASE_HOME or ~/.supabase>/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` |
| `<SUPABASE_PROFILE>` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command |

The project config is **not** read. Unlike `status` and `delete`, nothing in this
command's output depends on local state — there is no source path to report — so
`config.toml` is never opened and an unparseable one cannot block a log read.

## Files Written

| Path | Format | When |
| ----------------------------------------------- | ------ | --------------------------------------------------------------- |
| `<SUPABASE_HOME or ~/.supabase>/telemetry.json` | JSON | always — flushed on success and on failure |
| `<workdir>/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it |

## API Routes

| Method | Path | Auth | Request | Response (used fields) |
| ------ | --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | ---------------------- |
| `GET` | `/v1/projects/{ref}/analytics/endpoints/logs` | Bearer token | `sql`, `iso_timestamp_start`, `iso_timestamp_end` as query parameters | `result[]`, `error` |
| `GET` | `/v2/projects/{ref}/workers/{name}` | Bearer token | none — **only when the log query returned no rows**, to tell "not deployed" from "deployed and quiet" | presence only |
| `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker |

Requires the `analytics_logs_read` permission, and the project must be on the
Workers private-alpha allow-list — an unenrolled project answers 404.

### The query

SQL in **ClickHouse dialect** against the project's unified `logs` table, filtered
on `log_attributes['worker']` and `log_attributes['source']`. It does **not** filter
the top-level `source` column: worker rows carry an empty string there, because the
Workers Logflare source is not enrolled as a category in the generic logs path.

### The window

Both `iso_timestamp_start` and `iso_timestamp_end` are always sent, spanning just
under 24 hours. This is not optional:

- one bound alone yields a **one-minute** window, server-side and silently;
- neither bound is an outright error;
- a span over 24 hours is **silently clamped** to `start + 24h`, which returns an
older slice than the one requested rather than a truncated one.

### Rate limits

The v1 analytics endpoints allow **10 requests per 60 seconds**, and the server
applies a 30-second query timeout. One bounded invocation spends one request, or
two when the result is empty.

`--follow` polls every **10 seconds**. A quiet tail spends one request per poll —
6 a minute, leaving room for the history query, the deployed-worker check, and a
retry inside the same window. The interval is set by that limit, not by
responsiveness: a 2-second poll would spend the allowance in ten seconds.

A poll that finds more than one page of new rows drains the burst over up to
**5** requests, so a sustained backlog can reach **30 requests a minute** and will
be rate limited. That is deliberate rather than budgeted for: a 429 mid-tail is
retried on a spaced schedule rather than ending the tail, so the effect is a
throttled tail, not a dropped one.

The drain bound is also a **lossy** one. It walks backwards from the newest rows,
so exhausting it leaves the oldest part of the burst unfetched while the cursor
advances past it — a burst above **5000 lines in one poll interval** loses its
middle. The run says so once on stderr, in every output format, since a
`stream-json` consumer cannot infer the hole from the events it receives.

Each poll re-asks for a window starting 60 seconds behind the newest line already
printed, because guest lines arrive late and out of order. Overlap is therefore
guaranteed and is deduplicated on the Logflare-minted `id`.

## Exit Codes

| Code | Condition |
| ----- | ------------------------------------------------------------ |
| `0` | success, including "no logs in the last 24 hours" |
| `1` | invalid worker name |
| `1` | nothing deployed under that name |
| `1` | the log query failed (rejected, or the server's 30s timeout) |
| `1` | log usage exceeded (402), or rate limited (429) |
| `1` | API error, or project not enrolled in the alpha |
| `130` | `--follow` interrupted with SIGINT |

## Environment Variables

| Variable | Purpose | Required? |
| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- |
| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) |
| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) |
| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) |
| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) |
| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) |

## Telemetry Events Fired

| Event | When | Notable properties / groups |
| ---------------------- | ------------------------------------------ | ----------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` |

`--kind` is a choice flag, so its value is logged verbatim (a closed enum carries
no user data). `--project-ref` is not on this command's safe list, so its value is
redacted. No custom events.

## Output Formats

| Mode | stdout | stderr |
| ----------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| text (default) | one line per entry, oldest first, per-stream layout; `HH:MM:SS` local time; severity as colour | the spinner, and the `status` hint when there are no logs |
| `--output-format json` | one structured result carrying every entry | neither — both are text-only |
| `--output-format stream-json` | the same result as a single terminal event | neither — both are text-only |
| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | neither — both are text-only |
| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | both, as in text |
| `-o env` | refused before any request; the payload nests a `logs` array a flat `KEY=value` list cannot express | the error |

A structured emission is the end of a bounded read: the handler returns at
`legacyEmitWorkersMachineOutput` or at `output.success`, so the no-logs line and
its `status` trailer below them are never reached, and `output.task` is a no-op
in those modes. `-o pretty`, `table` and `csv` are the exception, since they
encode nothing and fall through to the same text branch.

With `--follow`, `stream-json` emits a `log-entry` event per line rather than one
terminal `result` — a tail has no last element. Its `stream` field is `stderr` when
the derived level is error or warn and `stdout` otherwise, and `source` separates
the initial backlog (`history`) from lines that arrived afterwards (`live`).
`--tail 0 --follow` skips the backlog entirely and makes no history request, since
the endpoint rejects `limit 0`.

A bounded read echoes `--kind` back as a top-level `kind` key when the flag was
given. That is a different axis from the per-line `source` above — `kind` is which
stream was asked for, `source` is whether the line came from the backlog or the
tail — so the two never mean the same thing.

Text output prints the time in the reader's own timezone, matching the `--debug`
HTTP logger. Machine payloads carry the unambiguous forms instead — each entry's
`id`, both `timestamp` (ISO-8601 UTC) and `timestamp_ms` (raw epoch), `stream`,
`message`, the derived `level` when one exists, and the raw `attributes` map — whose values are all strings, since the column is a
`Map(String, String)`.

A `worker_guest_logs` message is bytes the tenant's own code printed. Control and
escape sequences are stripped before it reaches a terminal, so a worker cannot
reposition the cursor or forge CLI output; interior newlines and indentation are
preserved so a stack trace survives intact.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { Argument, Command, Flag } from "effect/unstable/cli";
import type * as CliCommand from "effect/unstable/cli/Command";
import { withJsonErrorHandling } from "../../../../../shared/output/json-error-handling.ts";
import { legacyManagementApiRuntimeLayer } from "../../../../shared/legacy-management-api-runtime.layer.ts";
import {
WORKER_LOG_KINDS,
WORKER_LOG_POLL_SECONDS,
} from "../../../../../shared/workers/worker-logs.sql.ts";
import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts";
import { legacyWorkersLogs } from "./logs.handler.ts";

/**
* The endpoint's own ceiling is the SQL `LIMIT`, so this bound is the CLI's
* choice. 1000 is high enough to be a non-issue in practice and low enough that a
* typo cannot ask for a payload nobody wants.
*
* 0 is allowed and means "no history", which only becomes useful alongside
* `--follow`; on its own it prints nothing and makes no request.
*/
const MAX_TAIL = 1000;

const config = {
name: Argument.string("name").pipe(Argument.withDescription("Worker to read logs for.")),
projectRef: Flag.string("project-ref").pipe(
Flag.withDescription("Project ref of the Supabase project."),
Flag.optional,
),
kind: Flag.choice("kind", WORKER_LOG_KINDS).pipe(
Flag.withDescription(
"Limit to one log stream: app (the worker's own output), requests (HTTP access), " +
"builds (deploy lifecycle). Defaults to all three.",
),
Flag.optional,
),
follow: Flag.boolean("follow").pipe(
Flag.withAlias("f"),
Flag.withDescription(
`Keep printing new lines until interrupted, polling every ${WORKER_LOG_POLL_SECONDS} seconds.`,
),
// Required: `Flag.boolean` alone builds a *required* param, which breaks
// invocations that omit the flag. `legacy-boolean-flag-defaults.unit.test.ts`
// walks the command tree and fails any bare boolean.
Flag.withDefault(false),
),
tail: Flag.integer("tail").pipe(
Flag.filter(
(tail) => tail >= 0 && tail <= MAX_TAIL,
(tail) => `Expected --tail between 0 and ${MAX_TAIL}, got ${tail}`,
),
Flag.withDescription(
"Number of log lines to print. Use 0 with --follow to skip history and print only new lines.",
),
Flag.withDefault(100),
),
} as const;

export type LegacyWorkersLogsFlags = CliCommand.Command.Config.Infer<typeof config>;

export const legacyWorkersLogsCommand = Command.make("logs", config).pipe(
Command.withDescription(
"Print a worker's recent logs: its own output, the HTTP requests it served, and its " +
"deploy lifecycle events.\n\n" +
"Covers the last 24 hours, which is the longest window the logs API will answer in one " +
"query. Lines are printed oldest first.\n\n" +
`Use --follow to keep printing new lines as they arrive. The logs API is rate limited, so ` +
`following polls every ${WORKER_LOG_POLL_SECONDS} seconds rather than continuously; new ` +
"lines can take that long to appear.",
),
Command.withShortDescription("Show a worker's logs"),
Command.withExamples([
{
command: "supabase experimental workers logs api",
description: "Print the last 100 log lines across all streams",
},
{
command: "supabase experimental workers logs api --kind requests --tail 20",
description: "Print the 20 most recent HTTP requests the worker served",
},
{
command: "supabase experimental workers logs api --follow",
description: "Print recent logs, then keep printing new lines until interrupted",
},
{
command: "supabase experimental workers logs api --tail 0 --follow",
description: "Skip the backlog and print only lines that arrive from now on",
},
]),
Command.withHandler((flags) =>
legacyWorkersLogs(flags).pipe(
// `config` as well as `flags`: `--kind` is a choice flag, and the wrapper
// treats a command's own declared choices as safe to log verbatim.
withLegacyCommandInstrumentation({ flags, config }),
withJsonErrorHandling,
),
),
Command.provide(legacyManagementApiRuntimeLayer(["experimental", "workers", "logs"])),
);
Loading
Loading