Skip to content

feat: AsyncIterable streaming support for library SDK#586

Merged
BYK merged 1 commit intomainfrom
byk/async-streaming
Mar 27, 2026
Merged

feat: AsyncIterable streaming support for library SDK#586
BYK merged 1 commit intomainfrom
byk/async-streaming

Conversation

@BYK
Copy link
Copy Markdown
Member

@BYK BYK commented Mar 27, 2026

Summary

  • Streaming commands (log list --follow, dashboard view --refresh) now return AsyncIterable instead of throwing in library mode
  • Consumer break and AbortSignal both wire through to command abort for clean shutdown
  • New AsyncChannel<T> primitive bridges command generators with consumer iteration

Consumer API

const sdk = createSentrySDK({ token: "sntrys_..." });

// Streaming — returns AsyncIterable
for await (const log of sdk.log.list({ follow: "5", orgProject: "acme/backend" })) {
  console.log(log);
}

// Stop by breaking (triggers immediate abort)
for await (const log of sdk.log.list({ follow: "2" })) {
  if (done) break;
}

// External cancellation via AbortSignal
const controller = new AbortController();
const sdk2 = createSentrySDK({ signal: controller.signal });
setTimeout(() => controller.abort(), 30_000);
for await (const log of sdk2.log.list({ follow: "5" })) {
  console.log(log);
}

Changes

  • src/lib/async-channel.ts (new) — Push/pull async channel with dual-queue pattern
  • src/lib/sdk-invoke.ts — New executeWithStream<T>() parallel to executeWithCapture<T>(); buildRunner/buildInvoker branch on streaming flag detection
  • src/lib/sdk-types.ts — Added signal?: AbortSignal to SentryOptions
  • src/commands/log/list.ts — Thread abortSignal through FollowGeneratorConfig for library mode abort
  • src/commands/dashboard/view.ts — Honor external abortSignal in refresh loop
  • script/generate-sdk.ts — Streaming flags moved from INTERNAL_FLAGS to STREAMING_FLAGS; generates overloaded type signatures (Promise<T> vs AsyncIterable<unknown>)
  • script/bundle.ts — Added signal to npm type declarations + AsyncChannel type export
  • Docs — Streaming section in library-usage.md, signal option in README

Tests

  • 14 new async-channel unit tests (push/pull, close, error, for-await, break/return, interleaved)
  • 3 updated SDK tests (streaming flags now return AsyncIterable instead of throwing)
  • All 4349 unit tests pass (2 pre-existing failures in upgrade tests)

Closes #585

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

Semver Impact of This PR

🟡 Minor (new features)

📋 Changelog Preview

This is how your changes will appear in the changelog.
Entries from this PR are highlighted with a left border (blockquote style).


New Features ✨

  • (init) Surface server-provided detail in spinner messages by MathurAditya724 in #588
  • AsyncIterable streaming support for library SDK by BYK in #586

Bug Fixes 🐛

  • (auth) Skip stale cached user info for env var tokens in auth status by BYK in #589
  • (upgrade) Move delta patch log.info outside spinner callback by BYK in #590

Internal Changes 🔧

  • Regenerate skill files and command docs by github-actions[bot] in 0276f760

🤖 This preview updates automatically when you update the PR.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://cli.sentry.dev/pr-preview/pr-586/

Built to branch gh-pages at 2026-03-27 13:30 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 27, 2026

Codecov Results 📊

126 passed | Total: 126 | Pass Rate: 100% | Execution Time: 0ms

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

✅ Patch coverage is 100.00%. Project has 1267 uncovered lines.
✅ Project coverage is 95.65%. Comparing base (base) to head (head).

Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    95.60%    95.65%    +0.05%
==========================================
  Files          201       202        +1
  Lines        29003     29143      +140
  Branches         0         0         —
==========================================
+ Hits         27727     27876      +149
- Misses        1276      1267        -9
- Partials         0         0         —

Generated by Codecov Action

@BYK BYK force-pushed the byk/async-streaming branch from ff7decc to 5cfff1e Compare March 27, 2026 12:08
@BYK BYK marked this pull request as ready for review March 27, 2026 12:09
@BYK BYK force-pushed the byk/async-streaming branch from 5cfff1e to 801921b Compare March 27, 2026 12:52
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Streaming catch block missing process exit code fallback
    • Added captureCtx?.context.process.exitCode fallback between extractExitCode(thrown) and 1 to match executeWithCapture's three-level fallback pattern.
  • ✅ Fixed: Already-aborted signal ignored due to missing guard check
    • Added .aborted guard checks before addEventListener in both log/list.ts and dashboard/view.ts to handle pre-aborted signals correctly.

Create PR

Or push these changes by commenting:

@cursor push 34726e0dad
Preview (34726e0dad)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -893,81 +893,59 @@
 
 ### Architecture
 
-<!-- lore:019ce2be-39f1-7ad9-a4c5-4506b62f689c -->
-* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file.
+<!-- lore:019d2d10-671c-77d8-9dbc-c32d1604dcf7 -->
+* **AsyncIterable streaming for SDK blocked by four structural concerns**: AsyncIterable streaming for SDK implemented via AsyncChannel push/pull pattern. \`src/lib/async-channel.ts\` provides a dual-queue channel: producer calls \`push()\`/\`close()\`/\`error()\`, consumer iterates via \`for await...of\`. \`break\` triggers \`onReturn\` callback for cleanup. \`executeWithStream()\` in \`sdk-invoke.ts\` runs the command in background, pipes \`captureObject\` calls to the channel, and returns the channel immediately. Streaming detection: \`hasStreamingFlag()\` checks for \`--refresh\`/\`--follow\`/\`-f\`. \`buildInvoker\` accepts \`meta.streaming\` flag; \`buildRunner\` auto-detects from args. Abort wiring: \`AbortController\` created per stream, signal placed on fake \`process.abortSignal\`, \`channel.onReturn\` calls \`controller.abort()\`. Both \`log/list.ts\` and \`dashboard/view.ts\` check \`this.process?.abortSignal\` alongside SIGINT. Codegen generates callable interface overloads for streaming commands.
 
-<!-- lore:019d0b69-1430-74f0-8e9a-426a5c7b321d -->
-* **Bun compiled binary sourcemap options and size impact**: Binary build (\`script/build.ts\`) uses two steps: (1) \`Bun.build()\` produces \`dist-bin/bin.js\` + \`.map\` with \`sourcemap: "linked"\` and minification. (2) \`Bun.build()\` with \`compile: true\` produces native binary — no sourcemap embedded. Bun's compiled binaries use \`/$bunfs/root/bin.js\` as the virtual path in stack traces. Sourcemap upload must use \`--url-prefix '/$bunfs/root/'\` so Sentry can match frames. The upload runs \`sentry-cli sourcemaps inject dist-bin/\` first (adds debug IDs), then uploads both JS and map. Bun's compile step strips comments (including \`//# debugId=\`), but debug ID matching still works via the injected runtime snippet + URL prefix matching. Size: +0.04 MB gzipped vs +2.30 MB for inline sourcemaps. Without \`SENTRY\_AUTH\_TOKEN\`, upload is skipped gracefully.
+<!-- lore:019d2690-4df2-7ac8-82c4-54656d987339 -->
+* **Bundle uses esbuild with bun:sqlite polyfill plugin for Node.js compatibility**: \`script/bundle.ts\` uses esbuild to produce \`dist/index.cjs\` from \`src/index.ts\`. A \`bunSqlitePlugin\` replaces \`bun:sqlite\` imports with a polyfill. Build defines \`SENTRY\_CLI\_VERSION\` and \`SENTRY\_CLIENT\_ID\_BUILD\`, externalizes \`node:\*\` builtins. \`sentrySourcemapPlugin\` handles debug ID injection and sourcemap upload. After the main build, writes: (1) \`dist/bin.cjs\` — CLI wrapper with shebang/Node version check/warning suppression, (2) \`dist/index.d.cts\` — type declarations read from pre-built \`src/sdk.generated.d.cts\`. Both \`sdk.generated.\*\` files are gitignored and regenerated via \`generate:sdk\` script chained before \`bundle\` in \`package.json\`. Debug IDs solve sourcemap deduplication between npm bundle and bun compile builds.
 
-<!-- lore:019cb8ea-c6f0-75d8-bda7-e32b4e217f92 -->
-* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`.
+<!-- lore:019d2a93-55dd-7060-ba70-4cf2ae22ecfe -->
+* **CLI logic extracted from bin.ts into cli.ts for shared entry points**: \`src/cli.ts\` contains the full CLI runner extracted from \`bin.ts\`: \`runCompletion()\` (shell completion fast path), \`runCli()\` (full CLI with middleware — auto-auth, seer trial, unknown command telemetry), and \`startCli()\` (top-level dispatch). All functions are exported, no top-level execution. \`src/bin.ts\` is a thin ~30-line wrapper for bun compile that registers EPIPE/EIO stream error handlers and calls \`startCli()\`. The npm bin wrapper (\`dist/bin.cjs\`) is a ~300-byte generated script that \`require('./index.cjs').\_cli()\`. Both entry points share the same CLI logic via \`cli.ts\`.
 
-<!-- lore:019c978a-18b5-7a0d-a55f-b72f7789bdac -->
-* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow.
+<!-- lore:019d2690-4de7-75ed-b6c6-fb3deaf8871e -->
+* **getConfigDir and getAuthToken read global process.env directly**: \`src/lib/env.ts\` provides a module-level env registry: \`getEnv()\` defaults to \`process.env\`, \`setEnv(env)\` swaps it. Library entry (\`src/index.ts\`) calls \`setEnv({ ...process.env, ...overrides })\` before running commands, restores in \`finally\`. All ~14 files that previously read \`process.env.SENTRY\_\*\` directly now use \`getEnv().SENTRY\_\*\`. Key files ported: \`db/auth.ts\`, \`db/index.ts\`, \`db/schema.ts\`, \`constants.ts\`, \`resolve-target.ts\`, \`telemetry.ts\`, \`formatters/plain-detect.ts\`, \`sentry-url-parser.ts\` (which also WRITES to env), \`logger.ts\`, \`response-cache.ts\`, \`api/infrastructure.ts\`, \`dsn/env.ts\`, \`version-check.ts\`, \`oauth.ts\`. CLI mode never calls \`setEnv()\` so behavior is unchanged. Tests using \`useTestConfigDir()\` continue to work since \`getEnv()\` defaults to \`process.env\`.
 
-<!-- lore:019cbe93-19b8-7776-9705-20bbde226599 -->
-* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTY). \`loadCachedChain\` stitches patches for multi-hop offline upgrades.
+<!-- lore:019d26a2-8d2f-7a31-81aa-e665b4dde197 -->
+* **Library API: variadic sentry() function with last-arg options detection**: \`createSentrySDK(options?)\` in \`src/index.ts\` is the sole public API. Returns a typed SDK object with methods for every CLI command plus \`run()\` escape hatch. \`SentryOptions\` in \`src/lib/sdk-types.ts\`: \`token?\`, \`text?\` (run-only), \`cwd?\`, \`url?\` (self-hosted base URL → \`SENTRY\_HOST\`), \`org?\` (default org → \`SENTRY\_ORG\`), \`project?\` (default project → \`SENTRY\_PROJECT\`). Env isolation via \`buildIsolatedEnv(options)\` helper in \`sdk-invoke.ts\` — shared by both \`buildInvoker\` and \`buildRunner\`, maps each option to its env var. Zero-copy \`captureObject\` return, \`OutputError\` → data recovery. Default JSON output via \`SENTRY\_OUTPUT\_FORMAT=json\`. Non-zero exit throws \`SentryError\` with \`.exitCode\` and \`.stderr\`.
 
-<!-- lore:2c3eb7ab-1341-4392-89fd-d81095cfe9c4 -->
-* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError.
+<!-- lore:019d26a2-8d34-76ef-a855-c28a7e7796d1 -->
+* **Library mode telemetry strips all global-polluting Sentry integrations**: When \`initSentry(enabled, { libraryMode: true })\` is called, the Sentry SDK initializes without integrations that pollute the host process. \`LIBRARY\_EXCLUDED\_INTEGRATIONS\` extends the base set with: \`OnUncaughtException\`, \`OnUnhandledRejection\`, \`ProcessSession\` (process listeners), \`Http\`/\`NodeFetch\` (trace header injection), \`FunctionToString\` (wraps \`Function.prototype.toString\`), \`ChildProcess\`/\`NodeContext\`. Also disables \`enableLogs\` and \`sendClientReports\` (both use timers/\`beforeExit\`), and skips \`process.on('beforeExit')\` handler registration. Keeps pure integrations: \`eventFiltersIntegration\`, \`linkedErrorsIntegration\`. Library entry manually calls \`client.flush(3000)\` after command completion (both success and error paths via \`flushTelemetry()\` helper). Only unavoidable global: \`globalThis.\_\_SENTRY\_\_\[SDK\_VERSION]\`.
 
-<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 -->
-* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only.
+<!-- lore:019d2a93-55d9-7bc9-add2-c14a9a5fdd69 -->
+* **Typed SDK uses direct Command.loader() invocation bypassing Stricli dispatch**: \`createSentrySDK(options?)\` in \`src/index.ts\` builds a typed namespace API (\`sdk.org.list()\`, \`sdk.issue.view()\`) generated by \`script/generate-sdk.ts\`. At runtime, \`src/lib/sdk-invoke.ts\` resolves commands via Stricli route tree, caches \`Command\` objects, and calls \`command.loader()\` directly — bypassing string dispatch and flag parsing. The standalone variadic \`sentry()\` function has been removed. Typed SDK methods are the primary path, with \`sdk.run()\` as an escape hatch for arbitrary CLI strings (interactive commands like \`auth login\`, raw \`api\` passthrough). The codegen auto-discovers ALL commands from the route tree with zero config, using CLI route names as-is (\`org.list\`, \`dashboard.widget.add\`). Return types are derived from \`\_\_jsonSchema\` when present, otherwise \`unknown\`. Positional patterns are derived from introspection placeholder strings. Hidden routes (plural aliases) are skipped.
 
-<!-- lore:019ce0bb-f35d-7380-b661-8dc56f9938cf -->
-* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Auth retry goes through full chain. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode.
-
-<!-- lore:019d1f97-563d-72f0-80a9-accaa6d9b282 -->
-* **SQLite DB functions are synchronous — async signatures are historical artifacts**: All \`src/lib/db/\` functions do synchronous SQLite operations (both \`bun:sqlite\` and the \`node:sqlite\` polyfill's \`DatabaseSync\` are sync). Many functions still have \`async\` signatures — this is a historical artifact from PR #89 which migrated config storage from JSON files (using async \`Bun.file().text()\` / \`Bun.write()\`) to SQLite. The function signatures were preserved to minimize diff size and never cleaned up. These can safely be converted to synchronous. Exceptions that ARE legitimately async: \`clearAuth()\` (cache dir cleanup), \`getCachedDetection()\`/\`getCachedProjectRoot()\`/\`setCachedProjectRoot()\` (stat for mtime), \`refreshToken()\`/\`performTokenRefresh()\` (HTTP calls).
-
 ### Decision
 
-<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d -->
-* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions.
+<!-- lore:019d26a2-8d3b-770a-866c-10ca04a374e7 -->
+* **OutputError propagates via throw instead of process.exit()**: The \`process.exit()\` call in \`command.ts\` (OutputError handler) is replaced with \`throw err\` to support library mode. \`OutputError\` is re-thrown through Stricli via \`exceptionWhileRunningCommand\` in \`app.ts\` (added before the \`AuthError\` check), so Stricli never writes an error message for it. In CLI mode (\`cli.ts\`), OutputError is caught and \`process.exitCode\` is set silently without writing to stderr (data was already rendered). In library mode (\`index.ts\`), the catch block checks if \`capturedResult\` has data (the OutputError's payload was rendered to stdout via \`captureObject\` before the throw) and returns it instead of throwing \`SentryError\`. This eliminates the only \`process.exit()\` outside of \`bin.ts\`.
 
-<!-- lore:00166785-609d-4ab5-911e-ee205d17b90c -->
-* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing.
+<!-- lore:019d2bd4-65c2-7b4c-99f7-cab41ee1ed71 -->
+* **SDK codegen moving to auto-generate all commands from route tree**: \`script/generate-sdk.ts\` walks the Stricli route tree via \`discoverCommands()\`, skipping hidden routes. For each command: extracts flags, derives positional params from placeholder strings, checks \`\_\_jsonSchema\` for typed return types. Naming uses CLI route path as-is: \`\["org", "list"]\` → \`sdk.org.list()\`. Generates TWO gitignored files: (1) \`src/sdk.generated.ts\` — runtime, (2) \`src/sdk.generated.d.cts\` — npm type declarations. \`generate:sdk\` is chained before \`typecheck\`, \`dev\`, \`build\`, \`build:all\`, \`bundle\`. \`INTERNAL\_FLAGS\` set excludes \`json\`, \`fields\`, \`refresh\`, \`follow\` from generated parameter types — streaming flags are library-incompatible. CI check \`bun run check:skill\` validates SKILL.md stays in sync.
 
 ### Gotcha
 
-<!-- lore:019c8ab6-d119-7365-9359-98ecf464b704 -->
-* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime.
+<!-- lore:019d2d39-3c7b-7bf6-ab73-778d2c51af15 -->
+* **Test mocks lack process property — use optional chaining on this.process**: Command \`func()\` methods access \`this: SentryContext\` which has \`this.process\`. But test mocks created via \`createMockContext()\` only provide \`stdout\`/\`stderr\`/\`cwd\` — no \`process\` property. Accessing \`this.process.abortSignal\` crashes with \`undefined is not an object\`. Fix: always use optional chaining \`(this.process as T)?.abortSignal\` or check \`this.process\` exists first. This applies to any new property added to the process-like object in \`sdk-invoke.ts\` that commands read via \`this.process\`.
 
-<!-- lore:019c9e98-7af4-7e25-95f4-fc06f7abf564 -->
-* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\`
+### Pattern
 
-<!-- lore:019c9776-e3dd-7632-88b8-358a19506218 -->
-* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`.
+<!-- lore:019d2c9b-8e26-7b87-b141-98db9fa943f1 -->
+* **buildIsolatedEnv helper centralizes SDK env setup**: \`buildIsolatedEnv(options?)\` in \`src/lib/sdk-invoke.ts\` maps \`SentryOptions\` fields to env vars (\`token\` → \`SENTRY\_AUTH\_TOKEN\`, \`url\` → \`SENTRY\_HOST\`, etc.) plus \`SENTRY\_OUTPUT\_FORMAT=json\` (unless \`text: true\`). The core dedup is \`executeWithCapture\<T>()\` which centralizes the env isolation → capture context → telemetry → error wrapping → output parsing pipeline. Both \`buildInvoker\` (typed methods) and \`buildRunner\` (\`run()\` escape hatch) are thin ~15-line wrappers providing only the executor callback. \`STREAMING\_FLAGS\` set (\`--refresh\`, \`--follow\`, \`-f\`) is checked in \`buildRunner\` before execution — throws \`SentryError\` immediately since streaming output is unsuitable for library mode. Same flags are in \`INTERNAL\_FLAGS\` in codegen so typed SDK methods can't trigger streaming.
 
-<!-- lore:019cb8c2-d7b5-780c-8a9f-d20001bc198f -->
-* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`.
+<!-- lore:019d2d39-3c7f-757d-b484-ac997581c36e -->
+* **SDK codegen callable interface pattern for streaming overloads**: In \`script/generate-sdk.ts\`, streaming-capable commands (those with flags in \`STREAMING\_FLAGS\` set) use a callable interface pattern instead of a simple method signature. This produces TypeScript overloaded signatures: \`(params?: T): Promise\<R>\` for non-streaming and \`(params: T & { follow: string }): AsyncIterable\<unknown>\` for streaming. At runtime, \`generateStreamingMethodBody()\` emits code that checks if any streaming flag is present in params, then passes \`{ streaming: true }\` meta to the invoker which branches to \`executeWithStream\` vs \`executeWithCapture\`. The \`STREAMING\_FLAGS\` set (\`refresh\`, \`follow\`) is separate from \`INTERNAL\_FLAGS\` — streaming flags ARE included in generated params but excluded from \`INTERNAL\_FLAGS\`.
 
-<!-- lore:019d0b04-ccec-7bd2-a5ca-732e7064cc1a -->
-* **Multi-region fan-out: distinguish all-403 from empty orgs with hasSuccessfulRegion flag**: In \`listOrganizationsUncached\` (\`src/lib/api/organizations.ts\`), \`Promise.allSettled\` collects multi-region results. Don't use \`flatResults.length === 0\` to detect all-regions-failed — a region returning 200 OK with zero orgs pushes nothing into \`flatResults\`. Track a \`hasSuccessfulRegion\` boolean on any \`"fulfilled"\` settlement. Only re-throw 403 \`ApiError\` when \`!hasSuccessfulRegion && lastScopeError\`.
+<!-- lore:019d26a2-8d37-72ea-b7bd-14fef8556fea -->
+* **SENTRY\_OUTPUT\_FORMAT env var enables JSON mode from env instead of --json flag**: In \`src/lib/command.ts\`, the \`wrappedFunc\` checks \`this.env?.SENTRY\_OUTPUT\_FORMAT === "json"\` to force JSON output mode without passing \`--json\` on the command line. This is how the library entry point (\`src/index.ts\`) gets JSON by default — it sets this env var in the isolated env. The check runs after \`cleanRawFlags\` and only when the command has an \`output\` config (supports JSON). Commands without JSON support (help, version) are unaffected. ~5-line addition to \`command.ts\`.
 
-<!-- lore:019c969a-1c90-7041-88a8-4e4d9a51ebed -->
-* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`.
+<!-- lore:019d2495-54da-7506-b6ba-fee422164bca -->
+* **Target argument 4-mode parsing convention (project-search-first)**: \`parseOrgProjectArg()\` in \`src/lib/arg-parsing.ts\` returns a 4-mode discriminated union: \`auto-detect\` (empty), \`explicit\` (\`org/project\`), \`org-all\` (\`org/\` trailing slash), \`project-search\` (bare slug). Bare slugs are ALWAYS \`project-search\` first. The "is this an org?" check is secondary: list commands with \`orgSlugMatchBehavior\` pre-check cached orgs (\`redirect\` or \`error\` mode), and \`handleProjectSearch()\` has a safety net checking orgs after project search fails. Non-list commands (init, view) treat bare slugs purely as project search with no org pre-check. For \`init\`, unmatched bare slugs become new project names. Key files: \`src/lib/arg-parsing.ts\` (parsing), \`src/lib/org-list.ts\` (dispatch + org pre-check), \`src/lib/resolve-target.ts\` (resolution cascade).
 
-<!-- lore:019c9741-d78e-73b1-87c2-e360ef6c7475 -->
-* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`.
+<!-- lore:019d2690-4df5-7b2d-8194-00771eb3a9ce -->
+* **Writer type is the minimal output interface for streams and mocks**: The \`Writer\` type in \`src/types/index.ts\` is \`{ write(data: string): void; captureObject?: (obj: unknown) => void }\`. The optional \`captureObject\` property replaces the previous duck-typing pattern (\`hasCaptureObject()\` with \`typeof\` check and \`Record\<string, unknown>\` cast). In library mode, the writer sets \`captureObject\` to capture the fully-transformed JSON object directly without serialization. In CLI mode, \`process.stdout\` lacks this property so it's \`undefined\` → falsy, and \`emitJsonObject()\` falls through to \`JSON.stringify\`. The check is now a simple truthiness test: \`if (stdout.captureObject)\`. Since \`captureObject\` is part of the \`Writer\` type, \`sdk-invoke.ts\` no longer needs \`Writer & { captureObject?: ... }\` intersection types — plain \`Writer\` suffices.
 
-### Pattern
+### Preference
 
-<!-- lore:019d0b36-5da2-750c-b26f-630a2927bd79 -->
-* **findProjectsByPattern as fuzzy fallback for exact slug misses**: When \`findProjectsBySlug\` returns empty (no exact match), use \`findProjectsByPattern\` as a fallback to suggest similar projects. \`findProjectsByPattern\` does bidirectional word-boundary matching (\`matchesWordBoundary\`) against all projects in all orgs — the same logic used for directory name inference. In the \`project-search\` handler, call it after the exact miss, format matches as \`\<org>/\<slug>\` suggestions in the \`ResolutionError\`. This avoids a dead-end error for typos like 'patagonai' when 'patagon-ai' exists. Note: \`findProjectsByPattern\` makes additional API calls (lists all projects per org), so only call it on the failure path.
-
-<!-- lore:019c972c-9f11-7c0d-96ce-3f8cc2641175 -->
-* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves.
-
-<!-- lore:5ac4e219-ea1f-41cb-8e97-7e946f5848c0 -->
-* **PR workflow: wait for Seer and Cursor BugBot before resolving**: CI includes Seer Code Review and Cursor Bugbot as advisory checks (~2-3 min, only on ready-for-review PRs). Workflow: push → wait for all CI (including npm build) → check inline review comments from Seer/BugBot → fix valid findings → repeat. Bugbot sometimes catches real logic bugs, not just style — always review before merging. Use \`gh pr checks \<PR> --watch\` to monitor. Fetch comments via \`gh api repos/OWNER/REPO/pulls/NUM/comments\`.
-
-<!-- lore:019cb162-d3ad-7b05-ab4f-f87892d517a6 -->
-* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Schema v12 replaced \`pagination\_cursors.cursor TEXT\` with \`cursor\_stack TEXT\` (JSON array) + \`page\_index INTEGER\`. Stack-based API in \`src/lib/db/pagination.ts\`: \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/previous/first/last) to \`{cursor, direction}\`. \`advancePaginationState(key, contextKey, direction, nextCursor)\` pushes/pops the stack — back-then-forward truncates stale entries. \`hasPreviousPage(key, contextKey)\` checks \`page\_index > 0\`. \`clearPaginationState(key)\` removes state. \`parseCursorFlag\` in \`list-command.ts\` accepts next/prev/previous/first/last keywords. \`paginationHint()\` in \`org-list.ts\` builds bidirectional hints (\`-c prev | -c next\`). JSON envelope includes \`hasPrev\` boolean. All 7 list commands (trace, span, issue, project, team, repo, dashboard) use this stack API. \`resolveCursor()\` must be called inside \`org-all\` override closures.
-
-<!-- lore:019cbd5f-ec35-7e2d-8386-6d3a67adf0cf -->
-* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts).
-
-<!-- lore:019cc43d-e651-7154-a88e-1309c4a2a2b6 -->
-* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`).
+<!-- lore:019d26db-3ed0-7773-85ff-72226b404b98 -->
+* **Library features require README and docs site updates**: When adding new features like the library API, documentation must be updated in both places: the root \`README.md\` (library usage section between Configuration and Development, before the \`---\` divider) and the docs website at \`docs/src/content/docs/\`. The docs site uses Astro + Starlight with sidebar defined in \`docs/astro.config.mjs\`. New pages outside \`commands/\` must be manually added to the sidebar config. \`library-usage.md\` was added to the "Getting Started" sidebar section after "Configuration". Note: \`features.md\` and \`agent-guidance.md\` exist but are NOT in the sidebar.
 <!-- End lore-managed section -->

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -112,7 +112,10 @@
 - `project` — Default project slug.
 - `text` — Return human-readable string instead of parsed JSON (affects `run()` only).
 - `cwd` — Working directory for DSN auto-detection. Defaults to `process.cwd()`.
+- `signal` — `AbortSignal` to cancel streaming commands (`--follow`, `--refresh`).
 
+Streaming commands return `AsyncIterable` — use `for await...of` and `break` to stop.
+
 Errors are thrown as `SentryError` with `.exitCode` and `.stderr`.
 
 ---

diff --git a/docs/src/content/docs/library-usage.md b/docs/src/content/docs/library-usage.md
--- a/docs/src/content/docs/library-usage.md
+++ b/docs/src/content/docs/library-usage.md
@@ -151,6 +151,7 @@
 | `project` | `string` | Auto-detected | Default project slug |
 | `text` | `boolean` | `false` | Return human-readable text instead of parsed JSON (`run()` only) |
 | `cwd` | `string` | `process.cwd()` | Working directory for DSN auto-detection |
+| `signal` | `AbortSignal` | — | Abort signal for cancelling streaming commands |
 
 ## Return Values
 
@@ -217,7 +218,49 @@
 - **Node.js >= 22** (required for `node:sqlite`)
 - Or **Bun** (any recent version)
 
-:::caution
-Streaming flags (`--refresh`, `--follow`) are not supported in library mode
-and will throw a `SentryError`. Use the CLI binary directly for live-streaming commands.
+## Streaming Commands
+
+Two commands support real-time streaming: `log list --follow` and `dashboard view --refresh`.
+When using streaming flags, methods return an `AsyncIterable` instead of a `Promise`:
+
+```typescript
+const sdk = createSentrySDK({ token: "sntrys_..." });
+
+// Stream logs as they arrive (polls every 5 seconds)
+for await (const log of sdk.log.list({ follow: "5", orgProject: "acme/backend" })) {
+  console.log(log);
+}
+
+// Auto-refresh dashboard (polls every 30 seconds)
+for await (const snapshot of sdk.run("dashboard", "view", "123", "--refresh", "30")) {
+  console.log(snapshot);
+}
+
+// Stop streaming by breaking out of the loop
+for await (const log of sdk.log.list({ follow: "2" })) {
+  if (someCondition) break; // Streaming stops immediately
+}
+```
+
+### Cancellation
+
+`break` in a `for await...of` loop immediately signals the streaming command to stop.
+You can also pass an `AbortSignal` via `SentryOptions` for programmatic cancellation:
+
+```typescript
+const controller = new AbortController();
+const sdk = createSentrySDK({ token: "...", signal: controller.signal });
+
+// Cancel after 30 seconds
+setTimeout(() => controller.abort(), 30_000);
+
+for await (const log of sdk.log.list({ follow: "5" })) {
+  console.log(log);
+}
+// Loop exits when signal fires
+```
+
+:::note
+Concurrent streaming calls are not supported. Each streaming invocation
+uses an isolated environment — only one can be active at a time.
 :::

diff --git a/script/bundle.ts b/script/bundle.ts
--- a/script/bundle.ts
+++ b/script/bundle.ts
@@ -226,8 +226,16 @@
   text?: boolean;
   /** Working directory (affects DSN detection, project root). Defaults to process.cwd(). */
   cwd?: string;
+  /** AbortSignal to cancel streaming commands (e.g. log list --follow). */
+  signal?: AbortSignal;
 };
 
+export type AsyncChannel<T> = AsyncIterable<T> & {
+  push(value: T): void;
+  close(): void;
+  error(err: Error): void;
+};
+
 export declare class SentryError extends Error {
   readonly exitCode: number;
   readonly stderr: string;

diff --git a/script/generate-sdk.ts b/script/generate-sdk.ts
--- a/script/generate-sdk.ts
+++ b/script/generate-sdk.ts
@@ -34,11 +34,11 @@
   "log-level",
   "verbose",
   "fields",
-  // Streaming flags produce infinite output — not supported in library mode
-  "refresh",
-  "follow",
 ]);
 
+/** Flags that trigger streaming mode — included in params but change return type */
+const STREAMING_FLAGS = new Set(["refresh", "follow"]);
+
 /** Regex for stripping angle-bracket/ellipsis decorators from placeholder names */
 const PLACEHOLDER_CLEAN_RE = /[<>.]/g;
 
@@ -341,13 +341,12 @@
   return { name: interfaceName, code };
 }
 
-/** Generate the method body (invoke call) for a command. */
-function generateMethodBody(
+/** Build the flag object expression and positional expression for an invoke call. */
+function buildInvokeArgs(
   path: string[],
   positional: PositionalInfo,
-  flags: SdkFlagInfo[],
-  returnType: string
-): string {
+  flags: SdkFlagInfo[]
+): { flagObj: string; positionalExpr: string; pathStr: string } {
   const flagEntries = flags.map((f) => {
     const camel = camelCase(f.name);
     if (f.name !== camel) {
@@ -368,10 +367,67 @@
 
   const flagObj =
     flagEntries.length > 0 ? `{ ${flagEntries.join(", ")} }` : "{}";
+  const pathStr = JSON.stringify(path);
 
-  return `invoke<${returnType}>(${JSON.stringify(path)}, ${flagObj}, ${positionalExpr})`;
+  return { flagObj, positionalExpr, pathStr };
 }
 
+/**
+ * Generate the method body (invoke call) for a non-streaming command.
+ * Uses `as Promise<T>` to narrow the invoke union return type, since
+ * non-streaming calls never pass `meta.streaming` and always return a Promise.
+ */
+function generateMethodBody(
+  path: string[],
+  positional: PositionalInfo,
+  flags: SdkFlagInfo[],
+  returnType: string
+): string {
+  const { flagObj, positionalExpr, pathStr } = buildInvokeArgs(
+    path,
+    positional,
+    flags
+  );
+  return `invoke<${returnType}>(${pathStr}, ${flagObj}, ${positionalExpr}) as Promise<${returnType}>`;
+}
+
+/** Options for generating a streaming method body. */
+type StreamingMethodOpts = {
+  path: string[];
+  positional: PositionalInfo;
+  flags: SdkFlagInfo[];
+  returnType: string;
+  streamingFlagNames: string[];
+  indent: string;
+};
+
+/**
+ * Generate the method body for a streaming-capable command.
+ *
+ * Detects at runtime whether any streaming flag is present and passes
+ * `{ streaming: true }` to the invoker when it is.
+ */
+function generateStreamingMethodBody(opts: StreamingMethodOpts): string {
+  const { flagObj, positionalExpr, pathStr } = buildInvokeArgs(
+    opts.path,
+    opts.positional,
+    opts.flags
+  );
+
+  // Build the streaming condition: params?.follow !== undefined || params?.refresh !== undefined
+  const conditions = opts.streamingFlagNames
+    .map((name) => `params?.${camelCase(name)} !== undefined`)
+    .join(" || ");
+
+  const lines = [
+    "{",
+    `${opts.indent}    const streaming = ${conditions};`,
+    `${opts.indent}    return invoke<${opts.returnType}>(${pathStr}, ${flagObj}, ${positionalExpr}, { streaming });`,
+    `${opts.indent}  }`,
+  ];
+  return lines.join("\n");
+}
+
 // ---------------------------------------------------------------------------
 // Namespace Tree Building
 // ---------------------------------------------------------------------------
@@ -472,6 +528,12 @@
   const flags = extractSdkFlags(command);
   const positional = derivePositional(command);
 
+  // Detect streaming-capable commands
+  const streamingFlagNames = flags
+    .filter((f) => STREAMING_FLAGS.has(f.name))
+    .map((f) => f.name);
+  const isStreaming = streamingFlagNames.length > 0;
+
   // Generate return type from schema
   const schemaTypeName = `${buildTypeName(path)}Result`;
   const returnTypeInfo = generateReturnType(command, schemaTypeName);
@@ -493,6 +555,10 @@
   let paramsArg: string;
   let body: string;
 
+  const brief = command.brief || path.join(" ");
+  const methodName = path.at(-1) ?? path[0];
+  const indent = "    ".repeat(path.length - 1);
+
   if (hasVariadicPositional) {
     // Variadic: (params: XParams, ...positional: string[]) or (params?: XParams, ...positional: string[])
     // Required flags make params required even with variadic positionals
@@ -500,34 +566,77 @@
     paramsArg = params
       ? `params${paramsOpt}: ${params.name}, ...positional: string[]`
       : "...positional: string[]";
-    body = generateMethodBody(path, positional, flags, returnType);
+    body = isStreaming
+      ? generateStreamingMethodBody({
+          path,
+          positional,
+          flags,
+          returnType,
+          streamingFlagNames,
+          indent,
+        })
+      : generateMethodBody(path, positional, flags, returnType);
   } else if (params) {
     const paramsRequired = hasRequiredFlags;
     paramsArg = paramsRequired
       ? `params: ${params.name}`
       : `params?: ${params.name}`;
-    body = generateMethodBody(path, positional, flags, returnType);
+    body = isStreaming
+      ? generateStreamingMethodBody({
+          path,
+          positional,
+          flags,
+          returnType,
+          streamingFlagNames,
+          indent,
+        })
+      : generateMethodBody(path, positional, flags, returnType);
   } else {
     body = generateMethodBody(path, positional, flags, returnType);
     paramsArg = "";
   }
 
-  const brief = command.brief || path.join(" ");
-  const methodName = path.at(-1) ?? path[0];
-  const indent = "    ".repeat(path.length - 1);
   const sig = paramsArg ? `(${paramsArg})` : "()";
-  const methodCode = [
-    `${indent}    /** ${brief} */`,
-    `${indent}    ${methodName}: ${sig}: Promise<${returnType}> =>`,
-    `${indent}      ${body},`,
-  ].join("\n");
 
-  // Type declaration: method signature without implementation
-  const typeDecl = [
-    `${indent}    /** ${brief} */`,
-    `${indent}    ${methodName}${sig}: Promise<${returnType}>;`,
-  ].join("\n");
+  let methodCode: string;
+  let typeDecl: string;
 
+  if (isStreaming) {
+    // Streaming commands use a function body (not arrow expression)
+    // because they need runtime streaming detection
+    methodCode = [
+      `${indent}    /** ${brief} */`,
+      `${indent}    ${methodName}: ${sig} => ${body},`,
+    ].join("\n");
+
+    // Type declaration: callable interface with overloaded signatures
+    const streamingFlagTypes = streamingFlagNames.map((name) => {
+      const flag = flags.find((f) => f.name === name);
+      return `${camelCase(name)}: ${flag?.tsType ?? "string"}`;
+    });
+    const streamingConstraint = streamingFlagTypes.join("; ");
+
+    typeDecl = [
+      `${indent}    /** ${brief} */`,
+      `${indent}    ${methodName}: {`,
+      `${indent}      (params: ${params?.name ?? "Record<string, never>"} & { ${streamingConstraint} }): AsyncIterable<unknown>;`,
+      `${indent}      ${sig}: Promise<${returnType}>;`,
+      `${indent}    };`,
+    ].join("\n");
+  } else {
+    methodCode = [
+      `${indent}    /** ${brief} */`,
+      `${indent}    ${methodName}: ${sig}: Promise<${returnType}> =>`,
+      `${indent}      ${body},`,
+    ].join("\n");
+
+    // Type declaration: method signature without implementation
+    typeDecl = [
+      `${indent}    /** ${brief} */`,
+      `${indent}    ${methodName}${sig}: Promise<${returnType}>;`,
+    ].join("\n");
+  }
+
   insertMethod(root, path, methodCode, typeDecl);
 }
 

diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts
--- a/src/commands/dashboard/view.ts
+++ b/src/commands/dashboard/view.ts
@@ -219,6 +219,17 @@
       const stop = () => controller.abort();
       process.once("SIGINT", stop);
 
+      // Library mode: honor external abort signal (e.g., consumer break)
+      const externalSignal = (this.process as { abortSignal?: AbortSignal })
+        ?.abortSignal;
+      if (externalSignal) {
+        if (externalSignal.aborted) {
+          controller.abort();
+        } else {
+          externalSignal.addEventListener("abort", stop, { once: true });
+        }
+      }
+
       let isFirstRender = true;
 
       try {

diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts
--- a/src/commands/log/list.ts
+++ b/src/commands/log/list.ts
@@ -256,6 +256,11 @@
    * Use this to seed dedup state (e.g., tracking seen log IDs).
    */
   onInitialLogs?: (logs: T[]) => void;
+  /**
+   * External abort signal (library mode). When aborted, the follow
+   * generator stops on the next poll cycle. Complements SIGINT handling.
+   */
+  abortSignal?: AbortSignal;
 };
 
 /** Find the highest timestamp_precise in a batch, or undefined if none have it. */
@@ -343,11 +348,20 @@
   // timestamp_precise is nanoseconds; Date.now() is milliseconds → convert
   let lastTimestamp = Date.now() * 1_000_000;
 
-  // AbortController for clean SIGINT handling
+  // AbortController for clean SIGINT handling + library mode abort
   const controller = new AbortController();
   const stop = () => controller.abort();
   process.once("SIGINT", stop);
 
+  // Library mode: honor external abort signal (e.g., consumer break)
+  if (config.abortSignal) {
+    if (config.abortSignal.aborted) {
+      controller.abort();
+    } else {
+      config.abortSignal.addEventListener("abort", stop, { once: true });
+    }
+  }
+
   try {
     // Initial fetch
     const initialLogs = await config.fetch("1m");
@@ -695,6 +709,8 @@
           const generator = generateFollowLogs({
             flags,
             onDiagnostic: (msg) => logger.warn(msg),
+            abortSignal: (this.process as { abortSignal?: AbortSignal })
+              ?.abortSignal,
             fetch: (statsPeriod) =>
               listTraceLogs(org, traceId, {
                 query: flags.query,
@@ -757,6 +773,8 @@
           const generator = generateFollowLogs({
             flags,
             onDiagnostic: (msg) => logger.warn(msg),
+            abortSignal: (this.process as { abortSignal?: AbortSignal })
+              ?.abortSignal,
             fetch: (statsPeriod, afterTimestamp) =>
               listLogs(org, project, {
                 query: flags.query,

diff --git a/src/index.ts b/src/index.ts
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,7 @@
 import { buildInvoker, buildRunner } from "./lib/sdk-invoke.js";
 import { createSDKMethods } from "./sdk.generated.js";
 
+export type { AsyncChannel } from "./lib/async-channel.js";
 // Re-export public types and error class from the shared module.
 // These re-exports exist to break a circular dependency between
 // index.ts ↔ sdk-invoke.ts. SentryError and SentryOptions live

diff --git a/src/lib/async-channel.ts b/src/lib/async-channel.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/async-channel.ts
@@ -1,0 +1,135 @@
+/**
+ * Minimal push/pull async channel for streaming SDK results.
+ *
+ * Producer calls `push(value)` for each item, `close()` when done,
+ * or `error(err)` on failure. Consumer iterates with `for await...of`.
+ *
+ * Backpressure is not implemented — producers push freely. This is
+ * acceptable for streaming commands (bounded by poll interval).
+ *
+ * @module
+ */
+
+/** Options for creating an async channel. */
+export type AsyncChannelOptions = {
+  /**
+   * Called when the consumer calls `return()` on the iterator
+   * (e.g., `break` in a `for await...of` loop). Use this to signal
+   * the producer to stop.
+   */
+  onReturn?: () => void;
+};
+
+/**
+ * A push/pull async channel that implements `AsyncIterable<T>`.
+ *
+ * The producer side pushes values, the consumer side iterates.
+ * When the producer is done, it calls `close()`. On error, `error(err)`.
+ * Push after close is a silent no-op.
+ */
+export type AsyncChannel<T> = AsyncIterable<T> & {
+  /** Push a value to the consumer. Buffers if no one is waiting. No-op after close/error. */
+  push(value: T): void;
+  /** Signal normal completion. Consumer's next() returns { done: true }. */
+  close(): void;
+  /** Signal an error. Consumer's next() rejects with this error. */
+  error(err: Error): void;
+};
+
+/**
+ * Create a new async channel.
+ *
+ * Uses a dual-queue pattern: a buffer for values pushed before `next()`
+ * is called, and a pending resolver for when `next()` is called first.
+ */
+export function createAsyncChannel<T>(
+  options?: AsyncChannelOptions
+): AsyncChannel<T> {
+  const buffer: T[] = [];
+  let pending:
+    | {
+        resolve: (result: IteratorResult<T>) => void;
+        reject: (err: Error) => void;
+      }
+    | undefined;
+  let closed = false;
+  let errorValue: Error | undefined;
+
+  function push(value: T): void {
+    if (closed || errorValue) {
+      return;
+    }
+    if (pending) {
+      const p = pending;
+      pending = undefined;
+      p.resolve({ value, done: false });
+    } else {
+      buffer.push(value);
+    }
+  }
+
+  function close(): void {
+    if (closed) {
+      return;
+    }
+    closed = true;
+    if (pending) {
+      const p = pending;
+      pending = undefined;
+      p.resolve({ value: undefined as T, done: true });
+    }
+  }
+
+  function error(err: Error): void {
+    if (closed || errorValue) {
+      return;
+    }
+    errorValue = err;
+    closed = true;
+    if (pending) {
+      const p = pending;
+      pending = undefined;
+      p.reject(err);
+    }
+  }
+
+  function next(): Promise<IteratorResult<T>> {
+    if (buffer.length > 0) {
+      const value = buffer.shift() as T;
+      return Promise.resolve({ value, done: false });
+    }
+    if (errorValue) {
+      return Promise.reject(errorValue);
+    }
+    if (closed) {
+      return Promise.resolve({ value: undefined as T, done: true });
+    }
+    return new Promise<IteratorResult<T>>((resolve, reject) => {
+      pending = { resolve, reject };
+    });
+  }
+
+  const iterator: AsyncIterator<T> = {
+    next,
+    return(): Promise<IteratorResult<T>> {
+      closed = true;
+      buffer.length = 0;
+      if (pending) {
+        const p = pending;
+        pending = undefined;
+        p.resolve({ value: undefined as T, done: true });
+      }
+      options?.onReturn?.();
+      return Promise.resolve({ value: undefined as T, done: true });
+    },
+  };
+
+  return {
+    push,
+    close,
+    error,
+    [Symbol.asyncIterator](): AsyncIterator<T> {
+      return iterator;
+    },
+  };
+}

diff --git a/src/lib/sdk-invoke.ts b/src/lib/sdk-invoke.ts
--- a/src/lib/sdk-invoke.ts
+++ b/src/lib/sdk-invoke.ts
@@ -17,12 +17,18 @@
 import { homedir } from "node:os";
 import type { Span } from "@sentry/core";
 import type { Writer } from "../types/index.js";
+import { type AsyncChannel, createAsyncChannel } from "./async-channel.js";
 import { setEnv } from "./env.js";
 import { SentryError, type SentryOptions } from "./sdk-types.js";
 
-/** Flags that trigger infinite streaming — not supported in library mode. */
-const STREAMING_FLAGS = new Set(["--refresh", "--follow", "-f"]);
+/** CLI flag names/aliases that trigger infinite streaming output. */
+const STREAMING_FLAG_NAMES = new Set(["--refresh", "--follow", "-f"]);
 
+/** Check if CLI args contain any streaming flag. */
+function hasStreamingFlag(args: string[]): boolean {
+  return args.some((arg) => STREAMING_FLAG_NAMES.has(arg));
+}
+
 /**
  * Build an isolated env from options, inheriting the consumer's process.env.
  * Sets auth token, host URL, default org/project, and output format.
@@ -166,22 +172,37 @@
   getCapturedResult: () => unknown;
 };
 
+/** Options for building a capture context. */
+type CaptureOptions = {
+  /** When set, captureObject pushes to this channel instead of accumulating. */
+  channel?: AsyncChannel<unknown>;
+  /** When set, placed on the fake process for streaming commands to honor. */
+  abortSignal?: AbortSignal;
+};
+
 /** Build output capture writers and a SentryContext-compatible context object. */
 async function buildCaptureContext(
   env: NodeJS.ProcessEnv,
-  cwd: string
+  cwd: string,
+  opts?: CaptureOptions

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@BYK BYK force-pushed the byk/async-streaming branch 2 times, most recently from d3240b6 to 89d55be Compare March 27, 2026 13:17
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Streaming commands (log list --follow, dashboard view --refresh) now
return AsyncIterable instead of throwing. Consumer break and AbortSignal
both wire through to command abort for clean shutdown.

Closes #585
@BYK BYK force-pushed the byk/async-streaming branch from 89d55be to 0ddb128 Compare March 27, 2026 13:30
@BYK BYK merged commit 8c2f8b3 into main Mar 27, 2026
23 checks passed
@BYK BYK deleted the byk/async-streaming branch March 27, 2026 13:42
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.

feat: AsyncIterable streaming support for library SDK

1 participant