Skip to content

feat(logs): true realtime --follow via apper SSE stream (CF tail) — DRAFT - #595

Open
davidsu wants to merge 8 commits into
mainfrom
feat/logs-follow-realtime-sse
Open

feat(logs): true realtime --follow via apper SSE stream (CF tail) — DRAFT#595
davidsu wants to merge 8 commits into
mainfrom
feat/logs-follow-realtime-sse

Conversation

@davidsu

@davidsu davidsu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Turns base44 logs --follow from a 2s poll over an eventually-consistent index (~20-30s lag) into a consumer of apper's new realtime SSE stream.

Companion apper PR (the SSE endpoint): base44-dev/apper#19705.

The locked contract

  • Endpoint: GET /api/apps/{app_id}/functions-mgmt/logs/stream — app-level SSE stream on the same AppAdminRouter as the bounded logs route. App-level is correct as fact, not assumption: one WfP script per app hosts all its functions; function identity is log-line attribution, per-function tails don't exist.
  • Auth: identical to the bounded logs route — Authorization: Bearer <app-user JWT> (or api_key header for workspace keys, which 403 on logs routes today; the stream inherits that — acceptable for draft).
  • Event shape: data: {"time": ISO, "level": "debug|info|warn|error", "function": "name"|null, "message": "..."} — one log line per event, fields additive later. function MAY BE NULL (unattributed lines): the CLI prints them without a [fn] prefix, never drops them.
  • Filters: ?function=<csv> and ?env=preview|prod (default preview) applied server-side.
  • Keepalive: : ping comment ~20s. No event ids / resume in v1 (KISS).
  • Semantics: pure tail from connect (no backfill).
  • Typed terminal event: the server never ends a healthy-looking stream as a bare EOF — on give-up or lifetime rollover it sends event: end + data: {"reason": "<slug>", "retriable": true|false} before closing. Unnamed data events remain log rows; unknown event names are ignored (forward-compat). (The server's delivery mechanism changed after this PR was written — dispatcher tail → per-app Tail Worker, see the apper PR — with no change to this wire contract.)

Decisions taken

  1. Raw fetch + hand-parsed SSE, not ky/EventSource — EventSource can't set auth headers; ky is built for bounded requests. Naive reconnect, no state machine.
  2. --since + --follow is REJECTED in v1 (superseding the earlier backfill-then-attach decision): the seam between backfill (reads the bounded index, measured lagging ~17s) and the stream (tails from connect) left a silent data hole — lines landing in neither source — which is data loss in a debugging tool under the exact flag whose purpose is "miss nothing around time X". Closing it properly means poll-until-caught-up splicing, anti-KISS for a draft. The combination now errors like --until/--order ("--since cannot be combined with --follow yet"); --since alone (bounded path) is unchanged. Honesty note: the removed backfill combo was never exercised by the live demo or any spec.
  3. --level stays client-side filtered, exactly like today's poll path (contract only puts function/env server-side).
  4. Reconnect policy is reason-driven (superseding the earlier fixed one-retry counter): the server's typed end event decides — retriable: false → poll fallback immediately; retriable: true → reconnect after 1s; bare EOF/error with no end event (genuine infra drop) → reconnect, giving up only after 2 consecutive drops that produced zero events. The drop counter resets whenever a stream produces any event, so long-lived sessions never exhaust a retry budget. Initial connect 404/error → immediate poll fallback, unchanged. Every fallback prints a one-line stderr notice (empty-is-ambiguous — silence must mean silence). Prompting the user for retry-vs-poll was considered and rejected: the primary consumer is a non-TTY agent, and no prompt answer beats "keep it working".
  5. Poll fallback seeds its cursor from the last streamed timestamp, so fallback doesn't re-print history (boundary-timestamp lines may repeat once — same class of overlap the poll loop already tolerates).
  6. --json under --follow stays NDJSON (one JSON object per line) — this is today's existing behavior and the documented standing exception to the single-JSON-document rule; the stream keeps the exact same LogEntry shape (time, level, message, source).
  7. Malformed SSE lines are skipped silently — a bad line must not kill a live tail; draft accepts the tradeoff.
  8. Level normalization: stream warn → CLI-internal warning, matching the bounded route's existing Zod preprocess, so --level warning filters both paths consistently.
  9. Silent-failure bounds (from the tester's backend-outage probe): a backend outage could previously mute --follow indefinitely — a half-open connection blocks reader.read() forever, and the reconnect fetch had no connect timeout. Now a 60s line-silence watchdog treats transport silence as a bare drop (any line including : ping keepalives resets it — this is transport liveness, distinct from the rejected data-level client watchdog: quiet-but-healthy apps keep pinging and never trigger it), and the stream fetch gets a 10s connect-phase timeout (headers only; body liveness is the watchdog's job). Worst-case mute is bounded at ~70s, ending in the loud poll-path error. The poll path itself was never mute-capable: a failed poll iteration throws into the command error envelope (pre-existing behavior).

Changes

  • core/resources/function/stream-api.ts (new): openLogStream(filters) — fetch with Bearer/api_key auth (reusing the auth-config helpers incl. proactive token refresh), returns an async generator of Zod-validated StreamEvents (a log/end union; the SSE reader tracks event: names, so the typed end payload is parsed rather than silently failing the log schema); parseStreamEvent exported for tests.
  • cli/commands/project/logs.ts: followLogs now orchestrates streamUntilExhausted (stream + one reconnect; returns only once streaming is no longer possible) → stderr notice → poll fallback; the old poll loop survives as pollLogs with a seedable cursor. --since/--until/--order are all rejected with --follow.
  • tests/cli/logs.spec.ts: unit tests for the SSE line parser (valid event, warn→warning, null function kept, keepalives/malformed ignored) + the --since-rejection spec. The stream itself can't be child-process integration-tested (--follow never exits) — the live two-terminal demo covers end-to-end.

Demo (definition of done)

Terminal A: local apper stack with the SSE endpoint + a function that logs.
Terminal B: BASE44_API_URL=http://localhost:<apper-port> base44 logs --follow
Expected: invocation lines appear near-instantly (verified live: ~0.65s end-to-end through this CLI, vs 17.4s on the poll path).

Notes

  • GitHub Actions is disabled on this repo — CI will not run here. Locally: typecheck, lint green; logs.spec.ts fully green (36/36 incl. the new rejection spec). Full suite at the downscope commit: 724/738 — the 14 failures are all exec.spec.ts, root-caused to machine state, not this diff: the wix-embargo npm setup on this machine intercepts registry.npmjs.org TLS (Deno fetches @base44/sdk at exec time and fails with invalid peer certificate: CaUsedAsEndEntity; Deno ignores npm's cafile, and the testkit spawns the CLI with a clean env so no DENO_CERT can reach it). The same suite was 737/737 on this branch before the embargo hosts/cert change landed.
  • Deliberately minimal: no backpressure handling, no reconnect state machine beyond the reason-driven policy above, no config surface.

🤖 Generated with Claude Code

davidsu and others added 8 commits August 11, 2026 10:16
…allback

--follow now connects to the new apper endpoint
GET /api/apps/{app_id}/functions-mgmt/logs/stream (SSE, same Bearer
auth as the bounded logs route) and prints log events as they arrive.
On connect failure or after one reconnect attempt it falls back to
today's 2s poll loop with a one-line stderr notice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
David's ruling from the draft review: the backfill-then-attach seam
had a silent ~17-20s data hole (backfill reads the lagging bounded
index while the stream tails from connect), which is data loss in a
debugging tool. Guard the combination like --until/--order instead;
also rename followViaStream to streamUntilExhausted so the fallback
sequence below it reads as the failure path it is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
David's ruling: the server must not translate a diagnosed condition
into a bare EOF. The bridge now self-heals degraded tails invisibly
and, when it gives up, sends 'event: end' with
{reason, retriable} before closing. The cli replaces its magic
retry-counter with a reason-driven policy: retriable:false → poll
fallback; retriable:true → reconnect after 1s; bare EOF → reconnect,
giving up after 2 consecutive drops that produced no events (counter
resets on any event, so long-lived sessions never exhaust a budget).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Probe found a backend outage could mute --follow indefinitely. Two
gaps, both in the stream leg: a half-open connection blocks
reader.read() forever (the cli never enforced keepalive arrival), and
the reconnect fetch had no connect timeout against a wedged backend.
Now: 60s line-silence watchdog (any line incl. ': ping' resets it —
transport liveness, so quiet-but-healthy apps never trigger it) treats
silence as a bare drop, and a 10s connect-phase timeout guards the
fetch (body deliberately unguarded — body liveness is the watchdog's
job). Worst-case mute is now bounded, ending in the loud poll error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Actions were re-enabled on the org with a policy requiring third-party
actions to be SHA-locked; tag-pinned workflows now die at startup
(startup_failure, 0s). Pin every uses: reference to a full commit SHA
with the tag kept as a trailing comment, matching the form the
already-passing workflows use for actions/checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/preview-publish.yml
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/cli@0.1.9-pr.595.21d640d

Prefer not to change any import paths? Install using npm alias so your code still imports base44:

npm i "base44@npm:@base44-preview/cli@0.1.9-pr.595.21d640d"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.9-pr.595.21d640d"
  }
}

Preview published to npm registry — try new features instantly!

@davidsu
davidsu marked this pull request as ready for review August 13, 2026 12:15
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