Skip to content

feat(stack): show gateway requests in Studio's API Gateway logs - #6942

Open
avallete wants to merge 26 commits into
avallete/stack-logs-filesfrom
avallete/stack-gateway-logs
Open

avallete wants to merge 26 commits into
avallete/stack-logs-filesfrom
avallete/stack-gateway-logs

Conversation

@avallete

@avallete avallete commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

TL;DR

Studio's Logs → API Gateway page was always empty on the experimental stack, because the stack's API proxy logged nothing per request. The proxy now writes one access line per request, persists it like service logs, and ships it to Analytics in the shape Studio reads, so the page lists every request with its status, method and path. supabase stack logs shows the same lines.

Stacked on #6893.

Before

flowchart LR
  C[Client] --> P["Stack API proxy"]
  P --> S["REST / Auth / Storage / Functions / Realtime"]
  S --> L["Service logs"]
  L --> A[Analytics]
  P -. "no access log" .-> X["Studio API Gateway: empty"]
Loading

After

flowchart LR
  C[Client] --> P["Stack API proxy"]
  P --> S["REST / Auth / Storage / Functions / Realtime"]
  P --> G["gateway log stream<br/>one line per request"]
  G --> F["logs/gateway files"]
  F --> CLI["supabase stack logs --service gateway"]
  F --> A["Analytics cloudflare.logs.prod"]
  A --> U["Studio API Gateway"]
Loading

Why

The legacy supabase start fills the API Gateway page from Kong's access log (source cloudflare.logs.prod). The experimental stack has no Kong: its shared API port is served by the owner's own HTTP proxy, which had no access log, so the page showed "No data" however much traffic the stack served.

What changed

  • The shared API proxy records one access line per request and per websocket upgrade, in nginx combined format plus the duration. The status is the one actually sent, including proxy-generated 404/502/503 and requests that wait for a lazy service to wake; a client that leaves before the response is recorded as 499, and body bytes appear only for responses that finished. Logging never delays a response.
  • apikey, access_token and token query values are replaced by redacted before the line is written; request headers such as Authorization are not logged.
  • The lines are kept as a gateway log stream under the stack's logs/gateway/ directory with the same rotation and retention as service logs, survive owner restarts, and are removed with the stack.
  • While Analytics is running and healthy, gateway lines ship to cloudflare.logs.prod with the Kong request/response metadata Studio's API Gateway list, detail panel and status filter read (the path without its query, the query as search).
  • supabase stack logs includes gateway lines by default when the stack serves the shared API port, and --service gateway selects them; reading works while the stack is stopped.
  • Studio's per-function logs page stays empty: it filters on a function id that local Studio regenerates on every call, and the local edge runtime output does not carry the function name. The legacy start has the same gap.

Terminal captures

Recorded from this branch's source on macOS (Docker runtime): terminal with vhs, Studio in headless Chrome, at the same time against the same stack. Top: stack logs -f --service gateway (the apikey query value is redacted); bottom: requests to REST, Auth, Storage and Functions; right: Studio's API Gateway list filling in, then the error and warning status filter. Before this change the page showed "No data".

stack-gateway-docker

🤖 Generated with Claude Code

avallete and others added 3 commits October 1, 2026 13:47
- The shared API proxy records one access line per request and websocket
  upgrade (nginx combined format plus duration, credentials in the query
  string redacted), persisted as a gateway log stream.
- Gateway lines ship to the cloudflare.logs.prod source with the Kong
  request and response metadata Studio's API Gateway page reads.
- supabase stack logs includes gateway lines and selects them with
  --service gateway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Redact apikey, access_token and token in the Referer's query and in
  fragments, as for the request target.
- Settle a forwarded request when the client closes after the response
  ended but before it finished, so it records once and releases its target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@avallete
avallete marked this pull request as ready for review October 1, 2026 14:26
@avallete
avallete requested a review from a team as a code owner October 1, 2026 14:26
Comment thread packages/stack/src/HttpProxy.ts Outdated
Auth redirect and verify URLs carry PKCE codes, OTP token hashes and
refresh, ID and provider tokens; redact them like API keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Superseded by a newer AI review

🤖 AI Review

Both independent reviews were available. All eight distinct findings were verified: seven confirmed and one refuted. The credential-redaction gap is critical under the supplied severity definitions. Isolated helper probes reproduced credential leakage and incorrect WebSocket status logging; full tests were not run because dependencies are absent.

Findings

Severity Location Category Sources Claim
🔴 CRITICAL packages/stack/src/HttpProxy.ts:155 security claude Credential-bearing query parameters such as token_hash and OAuth code remain in clear text in persisted gateway logs and forwarded Analytics events.
🟡 MINOR packages/stack/src/HttpProxy.ts:599 observability codex HTTP access durations include target cleanup performed after the response finishes.
🟡 MINOR packages/stack/src/HttpProxy.integration.test.ts:1332 test-reliability codex The full-body reset test can reset the client before the proxy forwards response headers, making its required status 200 assertion flaky.
🟡 MINOR packages/stack/src/Owner.logs.integration.test.ts:158 resource-cleanup codex The gateway restart test leaks its first owner scope if setup or an assertion fails before the explicit close.
🟡 MINOR packages/stack/src/HttpProxy.integration.test.ts:1034 error-handling codex rawClient leaves acquisition pending when its initial socket connection fails.
🟡 MINOR packages/stack/src/HttpProxy.ts:512 correctness codex WebSocket access logging records an informational HTTP response as the final handshake status.
⚪ NIT packages/stack/src/host/GatewayLog.ts:29 correctness claude Gateway Analytics timestamps discard the millisecond precision of HttpAccess.time, preventing timestamp-based ordering of requests within the same second.

Findings outside the diff

  • 🟡 MINOR packages/stack/src/HttpProxy.ts:512 — WebSocket access logging records an informational HTTP response as the final handshake status.
Refuted findings (kept for transparency, not posted as review comments)
  • apps/cli/src/commands/experimental/stack/logs/logs.handler.ts:195 (compatibility): Implicit gateway selection causes stack logs --follow to fail against an older running owner because that owner has no attached gateway stream.
    Refuted: The asserted absence of an owner version check is contradicted by HostProcess.ts:292-298. HostProcess.ts:41-50 and internal/release.ts:20-40 derive the release from stack source contents, so an earlier build without gateway logging has a different release and is rejected before log RPCs. Same-release owners unconditionally attach gateway at Owner.ts:200-204. Trusted ADR 0017:163-168 also documents same-release RPC access.

Stats

Claude findings: 3 · Codex findings: 5 · Confirmed: 7 · Refuted: 1 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: auto · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment thread packages/stack/src/HttpProxy.ts Outdated
Comment thread packages/stack/src/HttpProxy.ts
Comment thread packages/stack/src/HttpProxy.integration.test.ts
Comment thread packages/stack/src/Owner.logs.integration.test.ts
Comment thread packages/stack/src/HttpProxy.integration.test.ts Outdated
Comment on lines +29 to +32
const nginxTime = (millis: number) => {
const parts = DateTime.toPartsUtc(DateTime.makeUnsafe(millis));
return `${two(parts.day)}/${monthNames[parts.month - 1]}/${parts.year}:${two(parts.hour)}:${two(parts.minute)}:${two(parts.second)} +0000`;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · correctness · source: claude

Gateway Analytics timestamps discard the millisecond precision of HttpAccess.time, preventing timestamp-based ordering of requests within the same second.

Evidence: GatewayLog.ts:29-31 formats time only through seconds. LogflareEvents.ts:175-186 replaces the event timestamp with that parsed time. LogflareEvents.unit.test.ts:119,137 explicitly maps 09:25:23.456Z to 09:25:23.000Z.

Suggested fix: If subsecond ordering is required, persist an additional millisecond timestamp and use it for Analytics events.

Comment thread packages/stack/src/HttpProxy.ts Outdated
- Measure request duration when the response settles, before target
  cleanup, and skip interim 1xx answers when recording upgrade status.
- Redact parameters whose value carries nested credentials, and URL
  userinfo in logged URLs.
- Make the reset, restart and raw-client test fixtures deterministic and
  leak-free.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/stack/src/HttpProxy.ts Outdated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/stack/src/HttpProxy.ts Outdated
avallete and others added 4 commits October 1, 2026 17:08
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Build gateway access records only on the proxy that logs them.
- Collapse credential redaction into one pattern and move its cases to a
  unit table.
- Share the month table, rename the log escaper, read upgrade answers in
  one loop, and trim a forwarder assertion the remap test already pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…logs

# Conflicts:
#	apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts
#	packages/stack/src/effect.ts
#	packages/stack/src/host/LogForwarder.ts
…avallete/stack-gateway-logs

# Conflicts:
#	packages/stack/src/Owner.logs.integration.test.ts
Comment thread packages/stack/src/HttpProxy.ts
avallete and others added 7 commits October 1, 2026 19:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…avallete/stack-gateway-logs

# Conflicts:
#	apps/cli/src/commands/experimental/stack/logs/logs.handler.ts
#	apps/cli/src/commands/experimental/stack/logs/logs.integration.test.ts
#	packages/stack/src/host/LogForwarder.ts
#	packages/stack/src/host/LogflareEvents.ts
…avallete/stack-gateway-logs

# Conflicts:
#	packages/stack/src/host/LogForwarder.integration.test.ts
#	packages/stack/src/host/LogForwarder.ts
…avallete/stack-gateway-logs

# Conflicts:
#	packages/stack/src/host/LogflareEvents.unit.test.ts
Comment thread packages/stack/src/HttpProxy.ts Outdated
avallete and others added 4 commits October 2, 2026 19:40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…avallete/stack-gateway-logs

# Conflicts:
#	packages/stack/ARCHITECTURE.md
Proxy request and upgrade spans record their route and response status.
CLI tests share one unused gateway fake, and the gateway tests leave the
line format to the proxy tests and prove a request is recorded once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…avallete/stack-gateway-logs

# Conflicts:
#	apps/cli/src/commands/experimental/stack/start/start.integration.test.ts
@avallete

avallete commented Oct 2, 2026

Copy link
Copy Markdown
Member Author

/ai-review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI Review

Verified all eight findings from both available reviews and merged the duplicate WebSocket reports into seven confirmed findings: one critical credential-redaction vulnerability, four minor logging issues, and two nits. Isolated checks reproduced the credential leak, missed WebSocket status, and incorrect HEAD byte count. The full test suite was unavailable because dependencies are not installed.

Findings

Severity Location Category Sources Claim
🔴 CRITICAL packages/stack/src/internal/redact-credentials.ts:55 security codex Malformed percent encoding bypasses redaction of usable nested credentials, exposing them in persisted gateway logs and Analytics.
🟡 MINOR packages/stack/src/host/GatewayLog.ts:47 concurrency claude Concurrent gateway publishers can emit sequence numbers out of order, causing false lost-chunk markers and warnings.
🟡 MINOR packages/stack/src/host/GatewayLog.ts:48 correctness claude Every owner run reuses gateway launch ID 1, so retained records and launch markers cannot distinguish runs by launch ID.
🟡 MINOR packages/stack/src/HttpProxy.ts:505 correctness claude+codex A successful WebSocket handshake can remain unlogged while open and receive a false 502 or 499 at teardown when status inspection gives up.
🟡 MINOR packages/stack/src/HttpProxy.ts:179 correctness codex Locally generated HEAD responses record body bytes that were never sent.
⚪ NIT apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md:47 documentation claude The documented credential-redaction list omits jwt, which the implementation redacts.
⚪ NIT packages/stack/src/host/GatewayLog.ts:16 correctness claude Gateway Analytics timestamps discard request-time milliseconds because they are reconstructed from the whole-second combined-log timestamp.

Stats

Claude findings: 5 · Codex findings: 3 · Confirmed: 7 · Refuted: 0 · Uncertain: 0


Models: claude-opus-5-5 + gpt-6.1-sol · Trigger: manual · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment on lines +55 to +57
return separator >= 0 && sensitive.test(decodeNested(parameter))
? `${parameter.slice(0, separator)}=redacted`
: parameter;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 CRITICAL · security · source: codex

Malformed percent encoding bypasses redaction of usable nested credentials, exposing them in persisted gateway logs and Analytics.

Evidence: packages/stack/src/internal/redact-credentials.ts:2-18 returns the original component when decoding fails; :55-57 then misses encoded credential delimiters. packages/stack/src/HttpProxy.ts:156-158 applies this redactor to the logged target and Referer; packages/stack/src/host/LogForwarder.ts:481 forwards the persisted text.

Suggested fix: Inspect parameter names independently and use tolerant decoding or conservative redaction for malformed nested values. Add the malformed-encoding example to the redaction tests.

const publish = yield* (yield* launchOutputPublisher(output, 1)).part;
return {
logs: PubSub.subscribe(output),
record: (access) => publish("stdout", encoder.encode(`${formatAccess(access)}\n`)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · concurrency · source: claude

Concurrent gateway publishers can emit sequence numbers out of order, causing false lost-chunk markers and warnings.

Evidence: packages/stack/src/host/GatewayLog.ts:44-47 shares one publisher across requests. packages/stack/src/runtime/Session.ts:97-104 separates sequence allocation from publication with Effect operations. packages/stack/src/host/LogStore.ts:905 consumes publication order without sorting; packages/stack/src/host/LogRecord.ts:281-297 reports forward sequence gaps as lost.

Suggested fix: Serialize gateway publication with a semaphore, or perform sequence allocation and publication within one synchronous operation.

return {
logs: PubSub.subscribe(output),
record: (access) => publish("stdout", encoder.encode(`${formatAccess(access)}\n`)),
observation: Stream.make({ launchId: 1 }).pipe(Stream.concat(Stream.never)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · correctness · source: claude

Every owner run reuses gateway launch ID 1, so retained records and launch markers cannot distinguish runs by launch ID.

Evidence: packages/stack/src/host/GatewayLog.ts:44 and :48 hard-code launch ID 1. packages/stack/src/Owner.ts:223-227 attaches that stream to the retained gateway directory. packages/stack/src/host/LogStore.ts:646 creates a fresh splitter on attachment, and packages/stack/src/host/LogRecord.ts:250-252 emits another launch marker.

Suggested fix: Initialize the gateway launch ID from the highest retained launch ID plus one, and use it consistently for publication and observation.

if (!Number.isNaN(status)) Deferred.doneUnsafe(handshake, Effect.succeed(status));
return;
}
if (answer.length >= answerLimit) upstream.off("data", onAnswer);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · correctness · source: claude+codex

A successful WebSocket handshake can remain unlogged while open and receive a false 502 or 499 at teardown when status inspection gives up.

Evidence: packages/stack/src/HttpProxy.ts:492-502 waits for complete headers before resolving the handshake; :505 removes the status listener after 8 KiB without resolving it. Relaying continues at :522-523. The recording fiber waits at :625, while :643-657 supplies a teardown fallback of 502 or 499.

Suggested fix: Capture the final status once its status line is complete, while correctly skipping interim responses. Resolve an explicit observation fallback promptly if parsing must stop.

Comment on lines +179 to +180
sent.bytes = body === undefined ? 0 : Buffer.byteLength(body);
response.end(body);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · correctness · source: codex

Locally generated HEAD responses record body bytes that were never sent.

Evidence: packages/stack/src/HttpProxy.ts:179 counts the supplied body without checking the method. Unmatched and failed HEAD requests receive 'Not Found' or 'Bad Gateway' at :574 and :589, and :599-604 records the resulting 9 or 11 bytes.

Suggested fix: Count zero body bytes for locally generated HEAD responses and cover unmatched and failed HEAD requests in access-record tests.

Comment on lines +47 to +48
fragment values (`apikey`, `token`, `token_hash`, `code`, access, refresh, ID, and provider
tokens, and the `X-Amz-Signature`, `X-Amz-Credential`, and `X-Amz-Security-Token` of S3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · documentation · source: claude

The documented credential-redaction list omits jwt, which the implementation redacts.

Evidence: apps/cli/src/commands/experimental/stack/start/SIDE_EFFECTS.md:47-50 lists redacted parameters without jwt. packages/stack/src/internal/redact-credentials.ts:25 includes jwt, and its unit test at :12-14 covers an Edge Function WebSocket JWT.

Suggested fix: Add jwt to the documented parameter list.

Comment on lines +16 to +31
const nginxTime = (millis: number) => {
const parts = DateTime.toPartsUtc(DateTime.makeUnsafe(millis));
return `${two(parts.day)}/${monthNames[parts.month - 1]}/${parts.year}:${two(parts.hour)}:${two(parts.minute)}:${two(parts.second)} +0000`;
};

/** Escapes quotes, backslashes and control characters like nginx's default log escaping. */
const escapeLogValue = (value: string) =>
value.replace(
// oxlint-disable-next-line no-control-regex -- control characters are what this escapes.
/["\\\u0000-\u001f\u007f]/gu,
(character) => `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`,
);

/** Formats an access record as an nginx combined log line followed by its duration. */
export const formatAccess = (access: HttpAccess) =>
`${access.client} - - [${nginxTime(access.time)}] "${escapeLogValue(`${access.method} ${access.target} ${access.protocol}`)}" ${access.status} ${access.bytes ?? "-"} "${escapeLogValue(access.referer ?? "-")}" "${escapeLogValue(access.userAgent ?? "-")}" ${access.durationMillis}ms`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · correctness · source: claude

Gateway Analytics timestamps discard request-time milliseconds because they are reconstructed from the whole-second combined-log timestamp.

Evidence: packages/stack/src/host/GatewayLog.ts:16-18 formats only through seconds, and :31 uses access.time. packages/stack/src/HttpProxy.ts:558 captures that time at request arrival. packages/stack/src/host/LogflareEvents.ts:175 and :184 replace the event timestamp with the parsed whole-second value.

Suggested fix: Preserve precise request time in an additional field or extended format, and document that the combined-log time represents request arrival.

avallete and others added 4 commits October 2, 2026 20:57
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…avallete/stack-gateway-logs

# Conflicts:
#	packages/stack/ARCHITECTURE.md
#	packages/stack/src/host/LogForwarder.ts

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant