feat(logs): true realtime --follow via apper SSE stream (CF tail) — DRAFT - #595
Conversation
…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
🚀 Package Preview Available!Install this PR's preview build with npm: npm i @base44-preview/cli@0.1.12-pr.595.e3f5caePrefer not to change any import paths? Install using npm alias so your code still imports npm i "base44@npm:@base44-preview/cli@0.1.12-pr.595.e3f5cae"Or add it to your {
"dependencies": {
"base44": "npm:@base44-preview/cli@0.1.12-pr.595.e3f5cae"
}
}
Preview published to npm registry — try new features instantly! |
A backend rolling deploy could kill --follow's realtime stream for the rest of the session: openLogStream collapsed every failure into null and streamUntilExhausted broke out on the first one, so a single 502 from an unhealthy pod dropped the client to 20-30s polling permanently. openLogStream now returns a discriminated LogStreamAttempt. A network error, connect timeout or 5xx is transient and gets a bounded 1s/2s/4s/8s ladder; a refusal (404 when streaming is off for the app, 401/403) still falls back to polling on the first attempt, so nothing slows down for users without the feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A quiet app's --follow stream retired after two bare disconnects even though pings had been flowing the whole time: apper sends them as SSE comment lines, the reader only acted on event:/data: prefixes, so liveness was measured by application logs rather than by the transport built to prove it. Comment lines now surface as a ping event and count toward the connection having proved itself, so a stream that lived long enough to ping keeps reconnecting when it dies bare-EOF, while a connection that delivered nothing at all still retires on the second drop. Pings print nothing and never move the log boundary. The drop-budget decision moves into two pure functions so it can be tested without a socket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--limit` was documented as "default: 50" in the command's first commit, but nothing has ever applied 50: the CLI only sends and slices a limit when the flag is passed, and both backends fall back to their own cap instead (CFW returns up to 500, the Deno path leaves it to the SDK). Both also clamp a passed limit to 500, so the advertised 1-1000 range silently tops out. Say what actually happens. No behavior change; the --help spec now asserts the cap so the string can't drift back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t to it --follow used to answer "can this app stream?" implicitly, inside the reconnect loop, and every way out of that loop landed on the same quiet downgrade to polling. Reading it meant holding two loops and two counters at once. followLogs now opens the stream itself, before any loop runs. A refusal or a connect that keeps failing warns and polls, as before, with its own message for each. A live stream is handed into the loop instead of being thrown away, and the loop no longer needs a first-pass branch: it starts by printing what it has and ends by fetching the next one. A stream that is lost for good now ends the command instead of dropping to 20-30s polling for the rest of the session. A normal server-side rollover still reconnects silently -- only exhausted reconnects fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
guyofeck
left a comment
There was a problem hiding this comment.
Read the diff, ran typecheck/lint/knip/logs.spec.ts locally (49/49 green). Shape is right — three-way LogStreamAttempt, typed end driving retry, polling kept as a live fallback. Three things before this leaves draft:
1. Body never cancelled — stream-api.ts:137: the finally calls releaseLock() but not reader.cancel(). Verified with a spied ReadableStream: breaking out on event: end never cancels, so each reconnect (and the final drop into the forever-running pollLogs) abandons an open connection. try { await reader.cancel(); } catch {} — cancel also releases the lock.
2. Auth errors classified as transient — stream-api.ts:183-193: buildStreamAuthHeaders() is awaited inside the fetch(...) args, so a logged-out user's readAuth() throw is caught as transient and retried 1+2+4+8s before the poll path surfaces the real error. Repro: base44 logs -f --function foo while logged out. Build the headers and URL before the try.
3. --since + --follow regression — on main the first poll passed since through, so backfill-then-tail worked; now it hard-errors while "Type of Change" says non-breaking. One since-bounded fetch before opening the stream would keep it (selectNewEntries already dedupes) — otherwise check the breaking box.
Nice drive-by on the --limit help text.
Generated by Claude Code
| process.stdout.write(`${line}\n`); | ||
| } | ||
|
|
||
| const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
There was a problem hiding this comment.
this is now native in node if you import setTimeout from node:timers/promises
…rors, poll for --since Three findings from the review of #595 that were never actioned before it merged, plus the review's one-line suggestion. - Cancel the response, don't just unlock the reader. readLines' finally called releaseLock() only, so exiting on a typed `end` left the body unconsumed, and an unconsumed body keeps its socket alive in the fetch pool -- a long tail's reconnects pile them up. The silence path's own cancel() is now redundant, so it just returns and lets the finally do it. This is client-side hygiene: the server ends its own generator after the end frame and detaches there, so a rollover does not leave a subscriber behind. - Build the URL and auth headers before the try. They were evaluated inside the fetch() arguments, so a logged-out user's readAuth() throw was caught as a transient failure and laddered 1+2+4+8s before the real error surfaced. A missing token is not something to retry. - `--since` with `--follow` now warns and polls instead of erroring. It worked before #595 (the first poll passed `since` through) and #595 turned it into a hard error, which is a break on a shipped flag pair. A stream only carries what happens next, so a run that asked for the past skips the stream entirely rather than opening one and backfilling around it. Also: `delay` is now `setTimeout` from node:timers/promises, per review.
…rors, poll for --since (#612) Three findings from the review of #595 that were never actioned before it merged, plus the review's one-line suggestion. - Cancel the response, don't just unlock the reader. readLines' finally called releaseLock() only, so exiting on a typed `end` left the body unconsumed, and an unconsumed body keeps its socket alive in the fetch pool -- a long tail's reconnects pile them up. The silence path's own cancel() is now redundant, so it just returns and lets the finally do it. This is client-side hygiene: the server ends its own generator after the end frame and detaches there, so a rollover does not leave a subscriber behind. - Build the URL and auth headers before the try. They were evaluated inside the fetch() arguments, so a logged-out user's readAuth() throw was caught as a transient failure and laddered 1+2+4+8s before the real error surfaced. A missing token is not something to retry. - `--since` with `--follow` now warns and polls instead of erroring. It worked before #595 (the first poll passed `since` through) and #595 turned it into a hard error, which is a break on a shipped flag pair. A stream only carries what happens next, so a run that asked for the past skips the stream entirely rather than opening one and backfilling around it. Also: `delay` is now `setTimeout` from node:timers/promises, per review.
Note
Description
Turns
base44 logs --followfrom a 2-second poll over an eventually-consistent index (~20-30s lag) into a consumer of the realtime SSE log stream (GET /api/apps/{app_id}/functions-mgmt/logs/stream), measured at ~0.65s end-to-end vs ~17s on the poll path. The stream is read by hand overfetch(EventSource cannot set auth headers), validated with Zod, and driven by a typedevent: endpayload that tells the CLI whether a disconnect is retriable. The command decides once, at connect time, whether streaming is possible: if the endpoint is unavailable it says so on stderr and runs the old poll loop for the whole session; if a stream was established and later cannot be re-established, it exits with an actionable error rather than silently degrading mid-tail.Related Issue
None (companion backend PR: base44-dev/apper#19705)
Type of Change
--sincecombined with--followis now rejected instead of silently backfillingChanges Made
core/resources/function/stream-api.ts:openLogStream(filters)opens the SSE endpoint with the same auth as the bounded logs route (Authorization: Bearer <jwt>, or theapi_keyheader for workspace keys, with proactive token refresh) and returns aLogStreamAttempt— either an async generator of Zod-validatedStreamEvents (log|end|ping), arefused, or atransient.parseStreamEvent,readStreamEvents, andisWorthReconnectingare exported for unit testing.event:names so the typedendpayload parses against its own schema instead of silently failing the log schema;:comment lines surface aspingevents; unknown event names and malformed payloads are skipped so one bad line cannot kill a live tail. Streamlevel: "warn"is normalized towarning, matching the bounded route, so--level warningfilters both paths identically.:ping comments is treated as proven alive (provedAlive), so an idle app's stream keeps reconnecting instead of burning the give-up budget.isWorthReconnecting(status)draws that line at 500.logs.ts:retriable: truereconnects after 1s;retriable: falsestops; a bare EOF/error with noendevent reconnects and gives up only after 2 consecutive drops that produced zero events (the counter resets whenever a stream proves alive, so long sessions never exhaust a budget).followLogsconnects first. On failure it warns on stderr (refused→ "not available for this app",transient→ "could not reach") and hands off topollLogsfor the rest of the session. Once a stream is live, exhausting the reconnect budget throws anApiErrorwith hints (base44 logs --follow,base44 logs) instead of quietly switching transports behind the user's back.pollLogswith a seedable cursor and unchanged behavior (2s interval,selectNewEntriesdedup).--since+--follownow errors like--until/--order— the seam between the lagging bounded index and a tail-from-connect stream left a silent data hole, and closing it properly needs poll-until-caught-up splicing.--sincealone is unchanged.--json --followstill emits NDJSON with the exact sameLogEntryshape (time,level,message,source) as the poll path.--helpno longer promises a--limitdefault that never existed: the text now readsResults per page (1-1000; the server returns at most 500). The CLI has never sent a default limit, so the olddefault: 50was a documentation-only bug already onmain.Testing
npm test)Added 173 lines of tests in
tests/cli/logs.spec.ts: the SSE parser (valid log event,warn→warning,function: nullkept rather than dropped, unknown event names and malformed payloads ignored, typedendparsing), keepalive-comment handling inreadStreamEvents, theprintStreamUntilEndliveness signal, the drop-budget arithmetic (including a silent drop following a proven one), and the reconnect-vs-refuse status classification — plus CLI specs for the--since/--followrejection and the corrected--limithelp text. The live stream itself is not child-process integration-testable (--follownever exits) and was verified with a two-terminal demo against a local backend. The suite was not re-run while generating this description —bun run buildandvitestwere both denied in this environment. Previously on this branchlogs.spec.tswas green and the only failures were inexec.spec.ts, caused by a local npm-registry TLS interception (Deno cannot resolve@base44/sdk) and unrelated to this diff.Checklist
docs/(AGENTS.md) if I made architectural changesAdditional Notes
--levelstays client-side filtered, as on the poll path; onlyfunctionandenvare applied server-side.logs.ts, the newstream-api.ts, its barrel export, andtests/cli/logs.spec.ts.🤖 Generated by Claude | 2026-08-31 09:20 UTC | e3f5cae