Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4cb9cd4
feat(stack): log gateway requests and ship them to Studio's API Gateway
avallete Oct 1, 2026
0b33f5c
fix(stack): redact referer credentials and settle reset gateway requests
avallete Oct 1, 2026
0cd037d
Merge branch 'avallete/stack-logs-files' into avallete/stack-gateway-…
avallete Oct 1, 2026
f8afda8
fix(stack): redact OAuth codes and tokens in gateway access lines
avallete Oct 1, 2026
d525414
fix(stack): tighten gateway access timing, upgrade status and redaction
avallete Oct 1, 2026
12bfb02
fix(stack): redact userinfo in URLs nested in gateway query values
avallete Oct 1, 2026
04ed943
fix(stack): detect credentials in multiply-encoded gateway query values
avallete Oct 1, 2026
0cb3e90
refactor(stack): simplify gateway access recording and redaction
avallete Oct 1, 2026
8c4678b
Merge branch 'avallete/stack-logs-files' into avallete/stack-gateway-…
avallete Oct 1, 2026
d588564
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 1, 2026
a267a43
fix(stack): redact S3 presigned URL signatures in gateway access lines
avallete Oct 1, 2026
61e77a0
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
9cf70f4
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
670e223
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
0eecf97
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
36149bb
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
ab9bed4
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
9145878
fix(stack): redact Edge Function websocket JWTs in gateway logs
avallete Oct 2, 2026
1413698
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
84125ac
refactor(stack): move gateway credential redaction into its own module
avallete Oct 2, 2026
3d7be50
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
0ebf20b
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
af9ff0c
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
cc87184
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 2026
e756e1e
fix(stack): record the handshake status on proxy upgrade spans
avallete Oct 2, 2026
7a4f067
Merge remote-tracking branch 'origin/avallete/stack-logs-files' into …
avallete Oct 2, 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
15 changes: 8 additions & 7 deletions apps/cli/docs/stack-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,14 @@ lint transaction (always rolled back). It does not launch a client binary.

## Reading stack logs

`supabase stack logs` prints the retained stdout/stderr of composition members and
exits; `-f/--follow` then streams new lines until interrupted. Select `--stack <name>`
or `--stack-id <id>`; the repeatable `--service <kind-or-instance-id>` can include
standalone services too. History is read from the persisted log files, so it works
while the stack is stopped; `--follow` requires a running owner and fails before
printing anything without one. Neither mode starts an owner or service, and Ctrl-C
leaves services running.
`supabase stack logs` prints the retained stdout/stderr of composition members, plus
the `gateway` request lines of the shared API port, and exits; `-f/--follow` then
streams new lines until interrupted. Select `--stack <name>` or `--stack-id <id>`; the
repeatable `--service <kind-or-instance-id>` can include standalone services too, and
`--service gateway` reads only the request lines. History is read from the persisted
log files, so it works while the stack is stopped; `--follow` requires a running owner
and fails before printing anything without one. Neither mode starts an owner or
service, and Ctrl-C leaves services running.

`--tail N` (default 200) keeps the newest lines across the selected services and
`--since` takes a duration (`10m`, `1h30m`), an ISO-8601 time, or `start` for each
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/command-internal/db-config.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
mockTty,
} from "../../tests/helpers/mocks.ts";
import { VALID_TOKEN, mockCommandSettings } from "../../tests/helpers/command-mocks.ts";
import { unusedGateway } from "../../tests/helpers/unused-stack.ts";
import {
DebugFlag,
DnsResolverFlag,
Expand Down Expand Up @@ -489,6 +490,7 @@ describe("dbConfigResolver (db-url under the stack backend)", () => {
},
stop: unused,
destroy: unused,
gateway: unusedGateway,
commands: { run: () => unused },
};
return Layer.succeed(StackApi, {
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/commands/db/dump/dump.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { mockOutput, mockTty, processEnvLayer } from "../../../../tests/helpers/mocks.ts";
import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts";
import {
VALID_REF,
mockCommandSettings,
Expand Down Expand Up @@ -153,6 +154,7 @@ const managedDumpStackApi = (runtime: "native" | "docker") => {
},
stop: Effect.void,
destroy: Effect.succeed({ runtimeCleanup: "complete" as const }),
gateway: unusedGateway,
commands: { run: runCommand },
} satisfies Stack;
return Layer.succeed(StackApi, {
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/commands/db/reset/reset.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
sequentialExecBatch,
transportFailure,
} from "../../../../tests/helpers/command-mocks.ts";
import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts";
import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts";
import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts";
import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts";
Expand Down Expand Up @@ -808,6 +809,7 @@ function mockResetStackApi(opts: {
},
stop: Effect.die("unused"),
destroy: Effect.die("unused"),
gateway: unusedGateway,
commands: { run: () => Effect.die("unused") },
};
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "effect";
import { StackError, type DatabaseInstance, type Stack } from "@supabase/stack/effect";
import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts";
import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts";

import {
alwaysReadyHttpClientLayer,
Expand Down Expand Up @@ -164,6 +165,7 @@ function generateStackApi(workdir: string) {
},
stop: unusedStack,
destroy: unusedStack,
gateway: unusedGateway,
commands: { run: unusedStackFn },
};
const identity = { projectRoot: workdir, branchContext: "main", stackName: "default" };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "effect";

import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts";
import { unusedGateway } from "../../../../../../tests/helpers/unused-stack.ts";
import {
alwaysReadyHttpClientLayer,
defaultLocalResetRoute,
Expand Down Expand Up @@ -166,6 +167,7 @@ function syncStackApi(workdir: string, port: number) {
},
stop: unusedSync,
destroy: unusedSync,
gateway: unusedGateway,
commands: { run: unusedSyncFn },
};
const identity = { projectRoot: workdir, branchContext: "main", stackName: "default" };
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/commands/db/start/start.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
mockProcessControl,
mockRuntimeInfo,
} from "../../../../tests/helpers/mocks.ts";
import { unusedGateway } from "../../../../tests/helpers/unused-stack.ts";
import {
mockCommandSettings,
mockLocalDockerEngineUnavailableLayer,
Expand Down Expand Up @@ -1733,6 +1734,7 @@ describe("db start stack backend", () => {
},
stop: Effect.void,
destroy: Effect.succeed({ runtimeCleanup: "complete" as const }),
gateway: unusedGateway,
commands: { run: () => Effect.die("unused") },
};
return { stack, state };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ starts/stops a service.
## Selection and files

Select the current project/branch/name, `--stack <name>`, or `--stack-id <id>`.
The selectors are mutually exclusive. By default, only composition members are
included. `--service <kind-or-instance-id>` is repeatable and can also select
standalone instances; a value that matches no saved instance fails with status 1.
The selectors are mutually exclusive. By default, composition members are
included, plus `gateway` (the shared API port's request lines) once the stack has
claimed that port. `--service <kind-or-instance-id>` is repeatable and can also select
standalone instances or `gateway`; a value that matches no saved instance, or
`gateway` without the shared API port, fails with status 1.
A missing stack fails with status 1.

Reads saved definitions under `<SUPABASE_HOME or ~/.supabase>/stacks/<id>/` and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const config = {
),
service: Flag.string("service").pipe(
Flag.withDescription(
"Read one service kind or instance ID; repeat to select several. Defaults to composition members.",
"Read one service kind, instance ID, or gateway (shared API requests); repeat to select several. Defaults to composition members and gateway.",
),
Flag.atLeast(0),
),
Expand Down
36 changes: 26 additions & 10 deletions apps/cli/src/commands/experimental/stack/logs/logs.handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Clock, Effect, Option, Path, Stream } from "effect";
import { streamStackLogs, type SavedStack, type StackLogRecord } from "@supabase/stack/effect";
import {
gatewayLog,
streamStackLogs,
type SavedStack,
type StackLogRecord,
} from "@supabase/stack/effect";
import { Output } from "../../../../shared/output/output.service.ts";
import { OutputFlag } from "../../../../command-internal/global-flags.ts";
import { dim } from "../../../../command-internal/colors.ts";
Expand Down Expand Up @@ -50,9 +55,16 @@ const select = (
service: creation.service,
launchId,
});
// The owner records the shared API listener's requests once the stack has claimed its port.
const gateway: ReadonlyArray<Selected> = definition.ports.some(({ key }) => key === "api")
? [{ id: gatewayLog.instanceId, service: gatewayLog.service, launchId: undefined }]
: [];
if (requested.length === 0) {
const members = new Set(definition.composition.members.map(({ id }) => id));
const selected = definition.instances.filter(({ id }) => members.has(id)).map(subject);
const selected = [
...definition.instances.filter(({ id }) => members.has(id)).map(subject),
...gateway,
];
return selected.length === 0
? Effect.fail(
new StackCommandLogsError({
Expand All @@ -65,17 +77,20 @@ const select = (
}
const unmatched = requested.find(
(value) =>
!definition.instances.some(({ id, creation }) => id === value || creation.service === value),
!definition.instances.some(
({ id, creation }) => id === value || creation.service === value,
) && !gateway.some(({ service }) => service === value),
);
if (unmatched !== undefined)
return Effect.fail(
new StackCommandLogsError({ reason: "flags", message: `No service matches ${unmatched}.` }),
);
return Effect.succeed(
definition.instances
return Effect.succeed([
...definition.instances
.filter(({ id, creation }) => requested.includes(id) || requested.includes(creation.service))
.map(subject),
);
...gateway.filter(({ service }) => requested.includes(service)),
]);
};

export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags: StackLogsFlags) {
Expand Down Expand Up @@ -189,7 +204,7 @@ export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags:
]),
);
const missing = selected
.filter(({ id }) => !handles.has(id))
.filter(({ id }) => id !== gatewayLog.instanceId && !handles.has(id))
.map(({ id, service }) => `${service} (${id})`);
if (missing.length === selected.length)
return yield* new StackCommandLogsError({
Expand All @@ -203,13 +218,14 @@ export const stackLogs = Effect.fn("experimental.stack.logs")(function* (flags:
`Not following ${missing.join(", ")}, which the running stack does not serve.`,
);
const streams = selected.flatMap(({ id, service }) => {
const handle = handles.get(id);
if (handle === undefined) return [];
const readLogs =
id === gatewayLog.instanceId ? stack.gateway.readLogs : handles.get(id)?.readLogs;
if (readLogs === undefined) return [];
const from = printed.get(id);
// Without history, the owner pins the start at its end under the writer's lock.
const start = flags.tail === 0 ? { tail: 0 } : from === undefined ? {} : { from };
return [
handle.readLogs({ follow: true, ...start, ...sinceTime }).pipe(
readLogs({ follow: true, ...start, ...sinceTime }).pipe(
Stream.filter(
(record) => isAfter(record, from) && isFromLaunch(record, launches.get(id)),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ const fixture = Effect.fn("StackLogsTest.fixture")(function* (options: {
readonly stateRoot: string;
readonly stackId: string;
}) => ReadonlyArray<ServiceInstance<"mail">>;
readonly ports?: SavedStack["ports"];
readonly gatewayLogs?: (options?: ReadLogsOptions) => Stream.Stream<LogRecord, never>;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -113,6 +115,7 @@ const fixture = Effect.fn("StackLogsTest.fixture")(function* (options: {
const definition: SavedStack = {
...found.value.definition,
instances: options.instances,
ports: options.ports ?? [],
composition: {
members: options.members.map((id) => ({ id, activation: "eager" as const })),
dependencies: [],
Expand Down Expand Up @@ -147,6 +150,7 @@ const fixture = Effect.fn("StackLogsTest.fixture")(function* (options: {
...stack.services,
list: Effect.succeed(options.handles?.({ stateRoot, stackId: stack.id }) ?? []),
},
gateway: { readLogs: options.gatewayLogs ?? stack.gateway.readLogs },
}),
),
}),
Expand Down Expand Up @@ -446,6 +450,111 @@ describe("stack logs", () => {
}).pipe(Effect.scoped, Effect.provide(live)),
);

const apiPort = [{ key: "api", host: "127.0.0.1", port: 54321 }];
const request =
'127.0.0.1 - - [29/Sep/2026:10:00:00 +0000] "GET /rest/v1/ HTTP/1.1" 200 2 "-" "curl/8.7.1" 3ms';

it.live("includes the gateway's requests by default and selects them with --service", () =>
Effect.gen(function* () {
const f = yield* fixture({
instances: [mail("mail-a")],
members: ["mail-a"],
ports: apiPort,
});
yield* f.writeSegment("mail", "mail-a", [launch(t0), line(t0 + 1, "stdout", "mail up")]);
yield* f.writeSegment("gateway", "gateway", [launch(t0), line(t0 + 2, "stdout", request)]);

const all = yield* f.run({});
const gateway = yield* f.run({ service: ["gateway"] }, "stream-json");

expect(lines(all.output.stdoutText).map((value) => value.replace(/\d{2}:\S+ /u, ""))).toEqual(
[
"gateway | --- launch 1 ---",
"mail | --- launch 1 ---",
"mail | mail up",
`gateway | ${request}`,
],
);
expect(eventLines(gateway.output.events)).toEqual([request]);
expect(gateway.output.events.at(-1)).toMatchObject({
service: "gateway",
instance_id: "gateway",
});
}).pipe(Effect.scoped, Effect.provide(live)),
);

it.live("rejects --service gateway when the stack has no shared API listener", () =>
Effect.gen(function* () {
const f = yield* fixture({ instances: [mail("mail-a")], members: ["mail-a"] });
yield* f.writeSegment("mail", "mail-a", [launch(t0), line(t0 + 1, "stdout", "mail up")]);

const selected = yield* f.run({ service: ["gateway"] });
const all = yield* f.run({}, "stream-json");

expect(failure(selected.exit)).toMatchObject({
reason: "flags",
message: "No service matches gateway.",
});
expect(eventLines(all.output.events)).toEqual(["mail up"]);
}).pipe(Effect.scoped, Effect.provide(live)),
);

it.live("follows the gateway's new requests through the owner", () =>
Effect.gen(function* () {
const f = yield* fixture({
instances: [],
members: [],
ports: apiPort,
running: true,
gatewayLogs: () =>
Stream.make({
kind: "stdout" as const,
timestamp: iso(t0 + 100),
launchId: 1,
text: request,
position: { generation: 1, byteOffset: 1_000 },
}),
});

const { exit, output } = yield* f.run({ follow: true, tail: 0 }, "stream-json");

expect(Exit.isSuccess(exit)).toBe(true);
expect(output.events).toEqual([
expect.objectContaining({ service: "gateway", line: request, source: "live" }),
]);
}).pipe(Effect.scoped, Effect.provide(live)),
);

it.live("keeps every owner run's gateway requests with --since start", () =>
Effect.gen(function* () {
const f = yield* fixture({
instances: [{ ...mail("mail-a"), launchId: 2 }],
members: ["mail-a"],
ports: apiPort,
});
yield* f.writeSegment("mail", "mail-a", [
launch(t0, 1),
line(t0 + 1, "stdout", "mail old", 1),
launch(t0 + 10, 2),
line(t0 + 11, "stdout", "mail new", 2),
]);
yield* f.writeSegment("gateway", "gateway", [
launch(t0),
line(t0 + 2, "stdout", "first run request"),
launch(t0 + 10),
line(t0 + 12, "stdout", "second run request"),
]);

const { output } = yield* f.run({ since: Option.some("start") }, "stream-json");

expect(eventLines(output.events)).toEqual([
"first run request",
"mail new",
"second run request",
]);
}).pipe(Effect.scoped, Effect.provide(live)),
);

it.live("follows --since start without delayed records of an earlier launch", () =>
Effect.gen(function* () {
const record = (offset: number, launchId: number, text: string): LogRecord => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
containerEngineSpawner,
type ContainerEngineState,
} from "../../../../../tests/helpers/child-process-spawner.ts";
import { unusedGateway } from "../../../../../tests/helpers/unused-stack.ts";
import {
mockCommandSettings,
mockTelemetryStateTracked,
Expand Down Expand Up @@ -157,6 +158,7 @@ const makeFixture = (root: string, options: FixtureOptions = {}) => {
},
stop: Effect.void,
destroy: Effect.succeed({ runtimeCleanup: "complete" as const }),
gateway: unusedGateway,
commands: { run: () => Effect.die("unused") },
};
const output = mockOutput();
Expand Down
Loading
Loading