-
Notifications
You must be signed in to change notification settings - Fork 523
feat(cli): add experimental stack logs #6510
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1d965de
feat(cli): add experimental stack logs
jgoux c02bc5b
fix(cli): refine experimental stack logs
jgoux 7a86e20
fix(cli): refine experimental stack logs reporting
jgoux 3f6b37b
fix(cli): clarify experimental stack log ownership guidance
jgoux c79aed7
test(cli): complete logs stack api mock
jgoux 60ea626
fix(cli): polish experimental stack logs
jgoux 22e4179
chore(cli): annotate stack logs fixtures
jgoux ee53c9d
fix(cli): record shorthand log follow usage
jgoux File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
apps/cli/src/commands/experimental/stack/logs/SIDE_EFFECTS.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| # `supabase experimental stack logs` | ||
|
|
||
| This command reads retained logs from the managed stack identified by the current project, | ||
| an optional `--stack` name, or `--stack-id`. It calls the public `@supabase/stack` logs | ||
| and followLogs APIs without loading `supabase/config.toml`, starting the stack, stopping it, | ||
| or changing its owner lifecycle. | ||
|
|
||
| ## Files read and written | ||
|
|
||
| The stack package reads its normal durable state under `<SUPABASE_HOME or ~/.supabase>` and | ||
| the selected stack's persisted log state. The CLI reads the current workdir from its normal | ||
| settings resolution. This command writes no project files, stack state, credentials, or | ||
| runtime resources. | ||
|
|
||
| ## Output | ||
|
|
||
| Text mode writes one line per retained or followed entry. JSON mode writes one bounded result; | ||
| `--follow` is rejected with `--output-format json`; use the default text mode or | ||
| `--output-format stream-json`, which emits one `log-entry` event per line. Each event has | ||
| `type: "log-entry"`, `timestamp`, `service`, `stream`, `line`, and `source`; `stream` is | ||
| `stdout`, `stderr`, or `internal`, and `source` is `history` or `live`. | ||
| Follow prints the retained history first and then resumes from its returned cursor. If the stack | ||
| is already stopped, it prints the retained history and exits successfully. With no `--stack` or | ||
| `--stack-id`, an absent default stack prints a successful empty result. A missing named stack | ||
| fails with status `1`. The legacy `-o`/`--output` flag is rejected; use `--output-format`. | ||
|
|
||
| Successful reads, including an absent default stack and a stopped stack, exit with status `0`. | ||
| Invalid flags, missing named stacks, and stack read failures exit with status `1`. Interrupting | ||
| follow cancels the log reader, exits with status `130`, and leaves the managed stack owner | ||
| untouched. | ||
|
|
||
| ## Telemetry | ||
|
|
||
| The command uses the standard command instrumentation wrapper. Stack log contents and messages | ||
| are not sent as custom telemetry properties. |
60 changes: 60 additions & 0 deletions
60
apps/cli/src/commands/experimental/stack/logs/logs.command.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { Command, Flag } from "effect/unstable/cli"; | ||
| import { CAPABILITY_NAMES } from "@supabase/stack/effect"; | ||
| import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; | ||
| import { withLegacyCommandInstrumentation } from "../../../../telemetry/legacy-command-instrumentation.ts"; | ||
| import { legacyExperimentalStackLogs } from "./logs.handler.ts"; | ||
|
|
||
| const MAX_TAIL = 10_000; | ||
|
|
||
| const config = { | ||
| stack: Flag.string("stack").pipe( | ||
| Flag.withDescription( | ||
| "Select an existing stack by name (defaults to the current project stack).", | ||
| ), | ||
| Flag.optional, | ||
| ), | ||
| stackId: Flag.string("stack-id").pipe( | ||
| Flag.withDescription("Read logs from an existing stack by id."), | ||
| Flag.optional, | ||
| ), | ||
| service: Flag.choice("service", CAPABILITY_NAMES).pipe( | ||
| Flag.withDescription("Limit logs to one stack service."), | ||
| Flag.optional, | ||
| ), | ||
| tail: Flag.integer("tail").pipe( | ||
| Flag.filter( | ||
| (value) => value >= 0 && value <= MAX_TAIL, | ||
| (value) => `Expected --tail between 0 and ${MAX_TAIL}, got ${value}`, | ||
| ), | ||
| Flag.withDescription( | ||
| "Number of retained log entries to print. Use 0 with --follow to skip retained history.", | ||
| ), | ||
| Flag.withDefault(100), | ||
| ), | ||
| follow: Flag.boolean("follow").pipe( | ||
| Flag.withAlias("f"), | ||
| Flag.withDescription("Continue printing new log entries until interrupted."), | ||
| Flag.withDefault(false), | ||
| ), | ||
|
jgoux marked this conversation as resolved.
|
||
| } as const; | ||
|
|
||
| export const legacyExperimentalStackLogsCommand = Command.make("logs", config).pipe( | ||
| Command.withDescription("Read logs from a managed local Supabase stack."), | ||
| Command.withShortDescription("Read managed local stack logs"), | ||
| Command.withExamples([ | ||
| { | ||
| command: "supabase experimental stack logs --service database --tail 50", | ||
| description: "Print the latest database logs", | ||
| }, | ||
| { | ||
| command: "supabase experimental stack logs --follow --output-format stream-json", | ||
| description: "Stream new stack logs as structured events", | ||
| }, | ||
| ]), | ||
| Command.withHandler((flags) => | ||
| legacyExperimentalStackLogs(flags).pipe( | ||
| withLegacyCommandInstrumentation({ flags, config, aliases: { f: "follow" } }), | ||
| withJsonErrorHandling, | ||
| ), | ||
| ), | ||
| ); | ||
30 changes: 30 additions & 0 deletions
30
apps/cli/src/commands/experimental/stack/logs/logs.errors.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import { Data } from "effect"; | ||
| import { | ||
| actionability, | ||
| type CliErrorActionabilityDeclaration, | ||
| ErrorActionabilityId, | ||
| } from "../../../../shared/telemetry/error-actionability.ts"; | ||
|
|
||
| export class LegacyExperimentalStackLogsError extends Data.TaggedError( | ||
| "LegacyExperimentalStackLogsError", | ||
| )<{ | ||
| readonly reason: "flags" | "invalid-config" | "lifecycle" | "impossible-state" | "unknown"; | ||
| readonly message: string; | ||
| readonly suggestion?: string; | ||
| readonly cause?: unknown; | ||
| }> { | ||
| get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { | ||
| switch (this.reason) { | ||
| case "flags": | ||
| return actionability.provideFlags; | ||
| case "invalid-config": | ||
| return actionability.invalidConfig; | ||
| case "lifecycle": | ||
| return actionability.invalidConfig; | ||
| case "impossible-state": | ||
| return actionability.impossibleState; | ||
| case "unknown": | ||
| return actionability.unknown; | ||
| } | ||
| } | ||
| } |
166 changes: 166 additions & 0 deletions
166
apps/cli/src/commands/experimental/stack/logs/logs.handler.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import { Effect, Match, Option, Stream } from "effect"; | ||
| import { | ||
| isStackError, | ||
| isStackId, | ||
| StackIdSchema, | ||
| type CapabilityName, | ||
| type StackLogEntry, | ||
| } from "@supabase/stack/effect"; | ||
| import { Output } from "../../../../shared/output/output.service.ts"; | ||
| import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; | ||
| import { LegacyCliSettings } from "../../../../config/legacy-cli-settings.service.ts"; | ||
| import { LegacyExperimentalStackApi } from "../stack.shared.ts"; | ||
| import { LegacyExperimentalStackLogsError } from "./logs.errors.ts"; | ||
|
|
||
| type LegacyExperimentalStackLogsFlags = { | ||
| readonly stack: Option.Option<string>; | ||
| readonly stackId: Option.Option<string>; | ||
| readonly service: Option.Option<CapabilityName>; | ||
| readonly tail: number; | ||
| readonly follow: boolean; | ||
| }; | ||
|
|
||
| const logsError = (error: unknown): LegacyExperimentalStackLogsError => { | ||
| const stackError = isStackError(error) ? error : undefined; | ||
| const classification = | ||
| stackError === undefined | ||
| ? ("unknown" as const) | ||
| : Match.value(stackError).pipe( | ||
| Match.tag("StackNotFoundError", "InvalidStackIdentityError", () => ({ | ||
| reason: "flags" as const, | ||
| })), | ||
| Match.tag("InvalidLogCursorError", () => ({ reason: "impossible-state" as const })), | ||
| Match.tag("StackNotRunningError", () => ({ | ||
| reason: "lifecycle" as const, | ||
| suggestion: "Run supabase experimental stack start before reading logs.", | ||
| })), | ||
| Match.tag("StackOwnershipConflictError", () => ({ | ||
| reason: "lifecycle" as const, | ||
| suggestion: | ||
| "Run supabase experimental stack status to inspect ownership; retry if the stack is shutting down.", | ||
| })), | ||
| Match.tag("StackLifecycleConflictError", () => ({ | ||
| reason: "lifecycle" as const, | ||
| suggestion: "The stack owner is shutting down; retry shortly.", | ||
| })), | ||
| Match.tag("StackUpgradeRequiredError", () => ({ | ||
| reason: "lifecycle" as const, | ||
| suggestion: "Upgrade the CLI to a compatible stack version before reading logs.", | ||
| })), | ||
| Match.tag("StackStateInvalidError", "StackStateFormatUnsupportedError", () => ({ | ||
| reason: "invalid-config" as const, | ||
| })), | ||
| Match.tag("InvalidProjectRootError", () => ({ reason: "invalid-config" as const })), | ||
| Match.orElse(() => ({ reason: "unknown" as const })), | ||
|
jgoux marked this conversation as resolved.
|
||
| ); | ||
| return new LegacyExperimentalStackLogsError({ | ||
| reason: typeof classification === "string" ? classification : classification.reason, | ||
| message: stackError?.message ?? String(error), | ||
| ...(typeof classification !== "string" && "suggestion" in classification | ||
| ? { suggestion: classification.suggestion } | ||
| : {}), | ||
| cause: error, | ||
| }); | ||
| }; | ||
|
|
||
| const renderEntry = (entry: StackLogEntry) => | ||
| `${entry.timestamp} ${entry.source}/${entry.stream}: ${entry.message}\n`; | ||
|
|
||
| const eventForEntry = (entry: StackLogEntry, source: "history" | "live") => ({ | ||
| type: "log-entry" as const, | ||
| timestamp: entry.timestamp, | ||
| service: entry.source, | ||
| stream: entry.stream, | ||
| line: entry.message, | ||
| source, | ||
| }); | ||
|
|
||
| export const legacyExperimentalStackLogs = Effect.fn("legacy.experimental.stack.logs")(function* ( | ||
| flags: LegacyExperimentalStackLogsFlags, | ||
| ) { | ||
| const output = yield* Output; | ||
| const settings = yield* LegacyCliSettings; | ||
| const stackApi = yield* LegacyExperimentalStackApi; | ||
| const legacyOutput = yield* Effect.serviceOption(LegacyOutputFlag); | ||
| if (Option.isSome(legacyOutput) && Option.isSome(legacyOutput.value)) | ||
| return yield* new LegacyExperimentalStackLogsError({ | ||
| reason: "flags", | ||
| message: "The legacy -o/--output flag is not supported here; use --output-format json.", | ||
| suggestion: "Use --output-format json, --output-format text, or --output-format stream-json.", | ||
| }); | ||
| if (Option.isSome(flags.stack) && Option.isSome(flags.stackId)) | ||
| return yield* new LegacyExperimentalStackLogsError({ | ||
| reason: "flags", | ||
| message: "--stack and --stack-id cannot be used together", | ||
| }); | ||
| if (flags.follow && output.format === "json") | ||
| return yield* new LegacyExperimentalStackLogsError({ | ||
| reason: "flags", | ||
| message: "--follow cannot be combined with --output-format json.", | ||
| suggestion: "Use --output-format stream-json for follow mode, or omit --follow.", | ||
| }); | ||
|
|
||
| const id = Option.isSome(flags.stackId) ? flags.stackId.value : undefined; | ||
| const targetOption = | ||
| id === undefined | ||
| ? yield* stackApi | ||
| .findStack({ | ||
| projectRoot: settings.workdir, | ||
| ...(Option.isSome(flags.stack) ? { name: flags.stack.value } : {}), | ||
| }) | ||
| .pipe(Effect.mapError(logsError)) | ||
| : yield* isStackId(id) | ||
| ? Effect.succeed(Option.some({ id: StackIdSchema.make(id) })) | ||
| : Effect.fail( | ||
| new LegacyExperimentalStackLogsError({ | ||
| reason: "flags", | ||
| message: "--stack-id must be a lowercase SHA-256 stack id", | ||
| }), | ||
| ); | ||
| if (Option.isNone(targetOption)) { | ||
| if (Option.isSome(flags.stack)) | ||
| return yield* new LegacyExperimentalStackLogsError({ | ||
| reason: "flags", | ||
| message: `No managed stack named "${flags.stack.value}" was found for this project.`, | ||
| }); | ||
| yield* output.success("No managed stack found for this context.", { | ||
| found: false, | ||
| entries: [], | ||
| }); | ||
| return; | ||
| } | ||
| const stack = yield* stackApi.openStack(targetOption.value.id).pipe(Effect.mapError(logsError)); | ||
| const query = { | ||
| ...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}), | ||
| tail: flags.tail, | ||
| }; | ||
| const batch = yield* stack.logs(query).pipe(Effect.mapError(logsError)); | ||
| const emitEntries = (source: "history" | "live", entries: ReadonlyArray<StackLogEntry>) => | ||
| output.format === "stream-json" | ||
| ? Effect.forEach(entries, (entry) => output.event(eventForEntry(entry, source)), { | ||
| discard: true, | ||
| }) | ||
| : Effect.forEach(entries, (entry) => output.raw(renderEntry(entry)), { discard: true }); | ||
| if (!flags.follow) { | ||
| if (output.format === "json") { | ||
| yield* output.success("", { found: true, id: stack.id, ...batch }); | ||
| } else { | ||
| yield* emitEntries("history", batch.entries); | ||
| } | ||
| return; | ||
| } | ||
|
jgoux marked this conversation as resolved.
|
||
| yield* emitEntries("history", batch.entries); | ||
| if (!batch.running) return; | ||
| const followQuery = { | ||
| ...(Option.isSome(flags.service) ? { capabilities: [flags.service.value] } : {}), | ||
| cursor: batch.cursor, | ||
| }; | ||
| yield* stack.followLogs(followQuery).pipe( | ||
| Stream.mapError(logsError), | ||
| Stream.runForEach((entry) => | ||
| output.format === "stream-json" | ||
| ? output.event(eventForEntry(entry, "live")) | ||
| : output.raw(renderEntry(entry)), | ||
| ), | ||
| ); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.