Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,132 @@
# `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** — 6 requests 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 429 mid-tail is retried on a spaced schedule
rather than ending the tail.

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 |
Comment on lines +78 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the SIGINT exit code

Add exit code 130 for --follow interrupted by SIGINT: the handler explicitly calls processControl.exit(130) and the new integration test asserts that result, but this compatibility table documents only codes 0 and 1. Leaving it out makes the command's required side-effect record inaccurate and can mislead the E2E coverage derived from it.

AGENTS.md reference: apps/cli/AGENTS.md:L359-L366

Useful? React with 👍 / 👎.


## 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` |

`--source` 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 | as above |
| `--output-format stream-json` | the same result as a single terminal event | as above |
| `-o json` / `yaml` / `toml` | the same payload in that encoding, and nothing else | as above |
| `-o pretty` / `table` / `csv` | the text rendering — these fall through rather than encoding | as above |
| `-o env` | refused before any request; the payload nests a `logs` array a flat `KEY=value` list cannot express | the error |

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`.

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_POLL_SECONDS } from "../../../../../shared/workers/worker-logs.sql.ts";
import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts";
import { legacyWorkersLogs } from "./logs.handler.ts";

/** The `--source` words, mapped to stream names in `worker-logs.sql.ts`. */
const SOURCE_VALUES = ["app", "requests", "builds"] as const;

/**
* 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,
),
source: Flag.choice("source", SOURCE_VALUES).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 --source 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`: `--source` 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