Skip to content

refactor: migrate LogDisplayer to @heroku/sdk streamLogs#3722

Merged
eablack merged 7 commits into
feat/heroku-sdk-integrationfrom
eb/refactor/sdk-logs-command
May 22, 2026
Merged

refactor: migrate LogDisplayer to @heroku/sdk streamLogs#3722
eablack merged 7 commits into
feat/heroku-sdk-integrationfrom
eb/refactor/sdk-logs-command

Conversation

@eablack
Copy link
Copy Markdown
Contributor

@eablack eablack commented May 22, 2026

Summary

Migrates heroku logs (and heroku run:detached's --tail path) from the legacy EventSource-based polling + manual session-recreate loop to the new SDK's logSession.streamLogs async iterable.

What the SDK now owns (heroku/heroku-sdk#28)

  • Generation detection (Cedar vs Fir) and the per-generation log-session option shape (dyno/type separate on Fir, collapsed on Cedar; lines Cedar-only; tail always-true on Fir).
  • Logplex transport. Opens the logplex_url and parses the chunked text/plain stream through HerokuApiClient.stream(). The platform never spoke text/event-stream, so EventSource was the wrong abstraction for this endpoint and there was risk of losing lines — EventSource parses lines prefixed with data:/event:/id:/retry: and silently drops anything else, while logplex sends raw log entries terminated by \n. Reading the chunked stream directly delivers every byte.
  • Idle-timeout recreate. When tailing, the SDK transparently re-opens a new session after the platform's idle timeout. The caller's for await loop never sees the disconnect.

What stays in the CLI (displayLogs)

  • The for await loop driving streamLogs.
  • colorize(line) per line.
  • SIGINT/SIGTERM → AbortController.abort().
  • EPIPE handler so piping into head/grep exits cleanly.
  • The Fir-only Fetching logs... notice, surfaced via the SDK's onSessionCreated callback.

Drive-by cleanup: class → function

The old LogDisplayer was a class with a single public method (display) and an unused APIClient constructor parameter. With the SDK now owning generation detection, transport, and recreate, the class added nothing — fold it into a plain displayLogs function. Both call sites (logs and run/detached) keep a static reference (Cmd.displayLogs = displayLogs) so existing sinon stubs still work, mirroring the pattern used by Promote.promotePipeline.

The 412-line log-displayer.unit.test.ts (tightly coupled to the old EventSource + getGenerationByAppId architecture) is replaced with a small unit test that asserts on the streamLogs call arguments and the abort-swallow path. Real network behavior is covered by test/integration/logs.integration.test.ts.

Drive-by fix: probably-broken proxy support

The legacy CLI passed agent: HttpsProxyAgent via fetch options, which Node's native fetch ignores entirely. Proxy support has likely been broken since the CLI moved off node-fetch. heroku-fetch (heroku/heroku-fetch#14) now routes requests through undici.EnvHttpProxyAgent when HTTP_PROXY / HTTPS_PROXY / NO_PROXY are set — those env vars now do what they say.

Drive-by cleanup: drop eventsource dependency

The eventsource package was only used by the old LogDisplayer. With that gone, drop the dep from package.json (and 13 lines from the lockfile).

Type of Change

Breaking Changes (major semver update)

  • Add a ! after your change type to denote a change that breaks current behavior

Feature Additions (minor semver update)

  • feat: Introduces a new feature to the codebase

Patch Updates (patch semver update)

  • fix: Bug fix
  • deps: Dependency upgrade
  • revert: Revert a previous commit
  • chore: Change that does not affect production code
  • refactor: Refactoring existing code without changing behavior
  • test: Add/update/remove tests

Testing

Notes:

  • Replace <cedar-app> and <fir-app> with apps you have access to.
  • The 15-minute idle-timeout case requires leaving a --tail session running for >15 min; otherwise just confirm the stream stays connected.

Steps:

  1. npm run build
  2. ./bin/run logs --app <cedar-app> --num 5 — bounded, prints last 5 lines and exits.
  3. ./bin/run logs --app <cedar-app> --tail — tails until Ctrl-C; lines arrive in real time, colorization preserved, Ctrl-C exits cleanly.
  4. ./bin/run logs --app <fir-app> — exercises Fir auto-tail + the Fetching logs... notice.
  5. ./bin/run logs --app <cedar-app> --tail | head -3 — exercises EPIPE handler; pipeline exits 0.
  6. ./bin/run run:detached -a <cedar-app> --tail echo hello — exercises run:detached's tail path.
  7. HTTPS_PROXY=http://your-proxy:8080 ./bin/run logs --app <cedar-app> --num 5 — fetch routes through the proxy.
  8. npx mocha 'test/unit/commands/logs.unit.test.ts' 'test/unit/commands/run/detached.unit.test.ts' 'test/unit/lib/run/log-displayer.unit.test.ts' --timeout 30000 — expect 19 passing.
  9. Optional: leave a --tail session open for >15 minutes and confirm it survives the platform's idle timeout (the SDK's auto-recreate should handle the disconnect transparently).

Screenshots (if applicable)

Related Issues

GUS work item: W-22265373

eablack added 2 commits May 22, 2026 10:26
Replaces the EventSource-based polling + manual session-recreate loop
with a single 'for await' over the SDK's logSession.streamLogs async
iterable. The SDK now owns:

  - generation detection (Cedar vs Fir) and the per-generation log
    session option shape
  - forcing tail=true on Fir, since the platform doesn't support a
    bounded session there
  - opening the logplex_url and parsing the chunked text/plain stream
    (the platform never spoke text/event-stream — EventSource was
    the wrong abstraction for this endpoint)
  - transparent session recreate after the platform's idle timeout
    when tailing

The CLI side is now just transport plumbing (User-Agent + proxy via
a custom fetch override), an AbortController hooked to SIGINT/SIGTERM,
the EPIPE handler for piping into 'head'/'grep', and an
onSessionCreated callback that prints the legacy 'Fetching logs...'
notice for Fir on the first session.
…fault

The SDK's streamLogs now defaults to a Node-aware fetch that adds a
User-Agent and routes through undici.EnvHttpProxyAgent (which honors
HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars). The CLI's local
buildFetch wrapper is redundant and is removed.

This also fixes a likely-broken proxy path: the previous CLI code
passed an https-proxy-agent via the 'agent' option, which Node's
native fetch ignores entirely. Proxy support has probably not worked
since the CLI moved off node-fetch. The undici dispatcher in the SDK
is the modern correct way to wire proxy support into native fetch.
@eablack eablack requested a review from a team as a code owner May 22, 2026 17:50
The SDK now routes the logplex fetch through HerokuApiClient with
service: 'custom', which inherits proxy + UA support from
heroku-fetch. The CLI's wrapper was already removed; this just picks
up the new SHA where the SDK's internals match.
The LogDisplayer class wrapped a single public method (display) and
required an unused APIClient constructor parameter (kept for back-
compat after the SDK migration). With the SDK now owning generation
detection, transport, and session recreate, the class adds nothing —
fold it into a plain async function.

Both call sites (logs and run/detached) keep a static reference
(Cmd.displayLogs = displayLogs) so existing sinon stubs can swap it
out for tests, mirroring the pattern used by pipelines:promote's
Promote.promotePipeline. (Drop the explanatory comment on those
static references — the pattern's now repeated enough to be obvious.)

Replaces the 412-line log-displayer.unit.test.ts (which was tightly
coupled to the old EventSource + getGenerationByAppId architecture)
with a small unit test that asserts on the streamLogs call arguments.
Real network behavior is covered by the existing integration test at
test/integration/logs.integration.test.ts.
The legacy LogDisplayer used EventSource to consume the logplex stream.
With the SDK migration, that's gone — the stream is read directly as
chunked text/plain, which is what the platform actually serves.

No remaining imports of 'eventsource' anywhere in src/.
Copy link
Copy Markdown
Contributor

@tlowrimore-heroku tlowrimore-heroku left a comment

Choose a reason for hiding this comment

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

LGTM!

heroku/heroku-sdk#28 (logSession.streamLogs) has merged. Picks up
@heroku/heroku-fetchbf6be07 transitively (the merged dispatcher fix
plus the proxy/Accept/ky2 work).
setupProcessHandlers ran on every displayLogs() call and added a
fresh process.stdout 'error' listener with no removal. Single-shot
CLI invocations don't notice, but repeated calls in-process would
accumulate listeners and trip Node's MaxListeners warning.

Move the listener to module load (it's already process-global state
either way) so each call wires only the per-invocation SIGINT/SIGTERM
abort handlers.

Also surface err.message ?? String(err) instead of err.stack —
err.stack can be undefined on non-Error throwables.

Adds a unit test for the abort-swallow path: when streamLogs rejects
with an AbortError after we abort the controller, displayLogs should
resolve cleanly.
@eablack eablack merged commit 3b54ace into feat/heroku-sdk-integration May 22, 2026
6 of 19 checks passed
@eablack eablack deleted the eb/refactor/sdk-logs-command branch May 22, 2026 22:24
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.

2 participants