Skip to content

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

Merged
davidsu merged 13 commits into
mainfrom
feat/logs-follow-realtime-sse
Aug 31, 2026
Merged

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

Conversation

@davidsu

@davidsu davidsu commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Note

Description

Turns base44 logs --follow from 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 over fetch (EventSource cannot set auth headers), validated with Zod, and driven by a typed event: end payload 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Other (please describe): one deliberate v1 downscope — --since combined with --follow is now rejected instead of silently backfilling

Changes Made

  • New 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 the api_key header for workspace keys, with proactive token refresh) and returns a LogStreamAttempt — either an async generator of Zod-validated StreamEvents (log | end | ping), a refused, or a transient. parseStreamEvent, readStreamEvents, and isWorthReconnecting are exported for unit testing.
  • SSE reader: tracks event: names so the typed end payload parses against its own schema instead of silently failing the log schema; : comment lines surface as ping events; unknown event names and malformed payloads are skipped so one bad line cannot kill a live tail. Stream level: "warn" is normalized to warning, matching the bounded route, so --level warning filters both paths identically.
  • Bounded silent-failure modes: a 10s connect-phase timeout on the stream fetch (headers only) and a 60s line-silence watchdog on the body — keepalive pings reset it, so quiet-but-healthy apps never trip it. Worst-case mute is bounded at ~70s.
  • Keepalive pings count as liveness: a connection that delivered only : ping comments is treated as proven alive (provedAlive), so an idle app's stream keeps reconnecting instead of burning the give-up budget.
  • Transient connect retries: an unreachable or 5xx endpoint is retried with backoff (1s/2s/4s/8s) before conceding; a deliberate refusal (4xx) is taken at face value immediately. isWorthReconnecting(status) draws that line at 500.
  • Reason-driven reconnect policy in logs.ts: retriable: true reconnects after 1s; retriable: false stops; a bare EOF/error with no end event 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).
  • One decision, then commit to it: followLogs connects first. On failure it warns on stderr (refused → "not available for this app", transient → "could not reach") and hands off to pollLogs for the rest of the session. Once a stream is live, exhausting the reconnect budget throws an ApiError with hints (base44 logs --follow, base44 logs) instead of quietly switching transports behind the user's back.
  • Poll path preserved: the old loop is extracted as pollLogs with a seedable cursor and unchanged behavior (2s interval, selectNewEntries dedup).
  • --since + --follow now 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. --since alone is unchanged.
  • --json --follow still emits NDJSON with the exact same LogEntry shape (time, level, message, source) as the poll path.
  • --help no longer promises a --limit default that never existed: the text now reads Results per page (1-1000; the server returns at most 500). The CLI has never sent a default limit, so the old default: 50 was a documentation-only bug already on main.

Testing

  • I have tested these changes locally
  • I have added/updated tests as needed
  • All tests pass (npm test)

Added 173 lines of tests in tests/cli/logs.spec.ts: the SSE parser (valid log event, warnwarning, function: null kept rather than dropped, unknown event names and malformed payloads ignored, typed end parsing), keepalive-comment handling in readStreamEvents, the printStreamUntilEnd liveness 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/--follow rejection and the corrected --limit help text. The live stream itself is not child-process integration-testable (--follow never 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 build and vitest were both denied in this environment. Previously on this branch logs.spec.ts was green and the only failures were in exec.spec.ts, caused by a local npm-registry TLS interception (Deno cannot resolve @base44/sdk) and unrelated to this diff.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (if applicable)
  • My changes generate no new warnings
  • I have updated docs/ (AGENTS.md) if I made architectural changes

Additional Notes

  • Marked DRAFT: it depends on the companion backend endpoint landing first. Workspace API keys currently 403 on logs routes, and the stream inherits that limitation.
  • --level stays client-side filtered, as on the poll path; only function and env are applied server-side.
  • No event ids or resume in v1, no backpressure handling, no config surface — deliberately minimal.
  • Diff is 4 files, +538/−4: logs.ts, the new stream-api.ts, its barrel export, and tests/cli/logs.spec.ts.

🤖 Generated by Claude | 2026-08-31 09:20 UTC | e3f5cae

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

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/cli@0.1.12-pr.595.e3f5cae

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.12-pr.595.e3f5cae"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.12-pr.595.e3f5cae"
  }
}

Preview published to npm registry — try new features instantly!

@davidsu
davidsu marked this pull request as ready for review August 13, 2026 12:15
davidsu and others added 4 commits August 30, 2026 15:02
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 guyofeck 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.

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 cancelledstream-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 transientstream-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));

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.

this is now native in node if you import setTimeout from node:timers/promises

@davidsu
davidsu merged commit 50ad740 into main Aug 31, 2026
17 checks passed
@davidsu
davidsu deleted the feat/logs-follow-realtime-sse branch August 31, 2026 10:53
davidsu added a commit that referenced this pull request Aug 31, 2026
…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.
davidsu added a commit that referenced this pull request Aug 31, 2026
…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.
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.

3 participants