diff --git a/AGENTS.md b/AGENTS.md index a36c3ec76..43221a2cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1068,89 +1068,31 @@ mock.module("./some-module", () => ({ ### Architecture - -* **Issue resolve --in grammar: release + @next + @commit sentinels**: \`sentry issue resolve --in\` grammar: (a) omitted→immediate resolve, (b) \`\\`→\`inRelease\` (monorepo \`spotlight@1.2.3\` pass-through), (c) \`@next\`→\`inNextRelease\`, (d) \`@commit\`→auto-detect git HEAD + match Sentry repos, (e) \`@commit:\@\\`→explicit. Sentinel matching case-insensitive; unknown \`@\`-prefixed tokens throw \`ValidationError\`. \`parseResolveSpec\` splits on LAST \`@\` to handle scoped names like \`@acme/web\`. \`resolveCommitSpec\` uses \`getHeadCommit\`/\`getRepositoryName\` from \`src/lib/git.ts\`, matching Sentry repo \`externalSlug\` or \`name\` via \`listRepositoriesCached\`. API requires \`statusDetails.inCommit: {commit, repository}\` — not bare SHA. + +* **env-registry.ts drives --help env var section + docs**: \`src/lib/env-registry.ts\` (\`ENV\_VAR\_REGISTRY\`) is the single source for all env vars the CLI honors. Entries have \`{name, description, example?, defaultValue?, installOnly?, topLevel?, briefDescription?}\`. \`topLevel: true\` + \`briefDescription\` surfaces in \`sentry --help\` Environment Variables section (via \`formatEnvVarsSection()\` in \`help.ts\`) and in \`sentry help --json\` as \`envVars\` array on the full-tree envelope. Docs generator consumes the full registry for \`configuration.md\`. When adding a new env var, add it here with \`installOnly: true\` if install-script-only. Reserve \`topLevel: true\` for core-path vars only (auth, targeting, URL, key display/logging). - -* **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. + +* **Sentry log IDs are UUIDv7 — enables deterministic retention checks**: Sentry log IDs are UUIDv7 (first 12 hex = ms timestamp, version char \`7\` at pos 13). Traces/event IDs are NOT v7. \`decodeUuidV7Timestamp()\` and \`ageInDaysFromUuidV7()\` in \`src/lib/hex-id.ts\` return null for non-v7, safe to call unconditionally. Enables deterministic 'past retention' messages; wired in \`recoverHexId\` and \`log/view.ts#throwNotFoundError\`. \`RETENTION\_DAYS.log = 90\` in \`src/lib/retention.ts\`; traces/events are \`null\` (plan-dependent). \`LOG\_RETENTION\_PERIOD\` is DERIVED as \`\` \`${RETENTION\_DAYS.log}d\` \`\` — never hardcode \`'90d'\`. Shared hex primitives (\`HEX\_ID\_RE\`, \`SPAN\_ID\_RE\`, \`UUID\_DASH\_RE\`, etc.) live in \`hex-id.ts\`. - -* **repo\_cache SQLite table for offline Sentry repo lookups**: Schema v14 adds \`repo\_cache\` table in \`src/lib/db/schema.ts\` + helpers in \`src/lib/db/repo-cache.ts\` (7-day TTL). \`listAllRepositories(org)\` in \`src/lib/api/repositories.ts\` paginates through \`listRepositoriesPaginated\` using \`API\_MAX\_PER\_PAGE\` and \`MAX\_PAGINATION\_PAGES\` — never use the unpaginated \`listRepositories\` for cache-backed lookups (silently caps at ~25). \`listRepositoriesCached(org)\` wraps it with cache-first lookup and a try/catch around \`setCachedRepos\` so read-only databases (macOS \`sudo brew install\`) don't crash commands whose API fetch already succeeded. Used by \`@commit\` resolver to match git origin \`owner/repo\` against Sentry repo \`externalSlug\` or \`name\`. - - -* **Response cache hit invisibility — synthetic Response carries no marker**: Response cache hit invisibility — synthetic Response from \`getCachedResponse()\` in \`src/lib/response-cache.ts\` is indistinguishable from network. Solved via module-level \`lastCacheHitAgeMs\`: set on hit, cleared at top of \`authenticatedFetch()\` per-call (single-process CLI = race-free). \`src/lib/cache-hint.ts\` provides \`formatCacheHint()\` (\`"cached · 3m ago · use -f to refresh"\`) and \`appendCacheHint(existingHint)\` (joins with \` | \`). Wired in \`buildCommand\` (\`src/lib/command.ts\`): \`appendCacheHint(returned?.hint)\` runs only when generator returns a \`CommandReturn\` — bare \`return;\` paths (e.g. \`--web\`) skip the hint. Same chokepoint can host future cross-cutting hint decorators. Test-only \`\_setLastCacheHitAgeForTesting(ms)\` exposes state. - - -* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Seer trial prompt via error middleware layering: \`bin.ts\` chain is \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (\`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. 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. - -### Decision - - -* **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. + +* **Three Sentry APIs for span custom attributes with different capabilities**: \*\*Three Sentry span APIs with different capabilities\*\*: (1) \`/trace/{traceId}/\` — hierarchical tree with \`additional\_attributes\`. (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span with ALL attributes. (3) \`/events/?dataset=spans\&field=X\` — list/search. Critical: \`meta.fields\` order is non-deterministic — derive column order from user's \`--field\`/\`-F\` list, not \`Object.keys()\`. See \`orderFieldNames()\` in \`explore.ts\`. ### Gotcha - -* **--json schema stability: collapse=organization drops nested org fields**: --json schema + response cache gotchas: (1) \`?collapse=organization\` shrinks \`organization\` to \`{id, slug}\` — silent --json regression. \`jsonTransform\` re-hydrates \`organization.name\` via \`resolveOrgDisplayName\` against \`org\_regions\` cache. (2) \`buildCacheKey()\` normalizes URL with sorted query params, so \`invalidateCachedResponse(baseUrl)\` misses entries with query suffixes. Use \`invalidateCachedResponsesMatching(prefix)\` (raw \`startsWith()\`); \`buildApiUrl()\` always emits trailing slash → safe prefix. (3) When \`jsonTransform\` is set, \`jsonExclude\` and \`filterFields\` are NOT applied — transform must call \`filterFields(result, fields)\` and omit excluded keys itself. - - -* **@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 → \`prepareHeaders\` creates empty headers, stripping Content-Type on Node.js (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access Link header for pagination. \`per\_page\` not in SDK types — cast query at runtime. SDK returns \`data={}\` (not \`\[]\`) for empty/204/missing Content-Type responses — always guard with \`Array.isArray(data)\` before \`.map()\`. Self-hosted instances behind reverse proxies commonly trigger this. + +* **api.ts: plain Error throws inside func() bypass CliError handling**: \*\*api.ts: plain Error throws inside func() bypass CliError handling\*\*: \`src/commands/api.ts\` throws plain \`new Error(...)\` in validation paths called from \`func()\` — this bypasses \`app.ts\`'s \`instanceof CliError\` check, causing user to see stack traces AND Sentry bug reports. Fix: use \`ValidationError\` for user-input errors inside \`func()\`. Plain \`Error\` is only OK in Stricli \`parse:\` callbacks where Stricli catches them. - -* **API tests must use useTestConfigDir to isolate disk response cache**: API tests that mock \`globalThis.fetch\` MUST call \`useTestConfigDir()\` from \`test/helpers.ts\` + \`setAuthToken()\`. The \`authenticatedFetch\` singleton in \`src/lib/sentry-client.ts\` checks a filesystem-based response cache (\`~/.sentry/cache/responses/\`, see \`response-cache.ts\`) BEFORE calling fetch. Without per-test config dirs, test N's API response gets cached to disk and served to test N+1 — fetch mock never fires, assertion sees stale data. TTL tiers in \`classifyUrl()\`: stable=5min (default), volatile=60s (issues, logs), immutable=24h (events/traces by ID). Symptom: test expects fresh mock value, receives prior test's value. Reference: \`test/lib/api/issues.test.ts\` (correct pattern), \`test/lib/api/repositories.test.ts\` regression fixed by adding \`useTestConfigDir("repo-cache-")\` + \`setAuthToken("test-token", 3600, "test-refresh")\` in beforeEach. + +* **Biome lint differs between local lint:fix and CI lint**: \*\*Biome lint differs between local lint:fix and CI lint\*\*: \`lint:fix\` hides CI issues; always run \`bun run lint\` before pushing. Key gotchas: (1) \`noPrecisionLoss\` on int >2^53 — use \`Number(string)\`. (2) \`noIncrementDecrement\` — use \`i += 1\`. (3) \`noExcessiveCognitiveComplexity\` caps at 15 — extract helpers, don't biome-ignore. (4) \`noUselessUndefined\` then \`noEmptyBlockStatements\` — use \`function noop() {}\`. (5) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`. - -* **Biome noUselessUndefined also rejects () => {} empty arrow callbacks**: Biome lint traps: (1) \`noUselessUndefined\` rejects \`() => undefined\` AND \`noEmptyBlockStatements\` rejects \`() => {}\` — use top-level \`function noop(): void {}\`. (2) \`noExcessiveCognitiveComplexity\` caps at 15. (3) \`expect(() => fn()).toThrow(X)\` must be one line. (4) Plugin forbids raw \`metadata\` table queries — use \`getMetadata\`/\`setMetadata\`/\`clearMetadata\`. (5) Also enforced: \`useBlockStatements\`, \`noNestedTernary\`, \`useAtIndex\`, \`noStaticOnlyClass\`, \`useSimplifiedLogicExpression\`, \`noShadow\`. Namespace imports forbidden. (6) \`useYield\` fires on \`async \*func()\` with statements but not empty bodies — only add \`biome-ignore\` to generators with statements. \`lint:fix\` differs from CI \`lint\`: auto-fix hides \`noPrecisionLoss\` on >2^53 literals, \`noIncrementDecrement\`, import ordering. Always \`bun run lint\` before pushing. - - -* **Bun --isolate coverage inflates LF count for files with verbose comments/JSDoc**: Bun --isolate coverage inflates LF count: under \`bun test --isolate --parallel\` (CI's \`test:unit\`), Bun's coverage instrumentation counts comments, blank lines, type annotations, and closing braces as 'executable'. E.g. \`zstd-transport.ts\` LF=165 locally → 210 under --isolate, dropping coverage 99%→78%. Codecov sees inflated number. Workaround: trim verbose inline comments inside function bodies (move rationale to JSDoc above function or module-level doc). Statement coverage stays 100% — 'missing' lines are non-executable. - - -* **Bun 1.3.11 tty.ReadStream leaks libuv handle — process.stdin.unref is undefined**: Bun 1.3.11 macOS TTY bug: \`process.stdin\` via kqueue \`EVFILT\_READ\` on reopened non-session-leader TTY fd fails to deliver keystrokes when fd 0 inherited via \`exec bin \ -* **MastraClient has no dispose API — use AbortController for cleanup**: MastraClient has no \`close()\`/\`dispose()\` API — cleanup via \`ClientOptions.abortSignal\` (constructor) or per-prompt \`signal\`. Without explicit abort, Bun's fetch dispatcher keep-alive sockets hold the event loop alive past natural exit. Pattern in \`src/lib/init/wizard-runner.ts\`: create \`AbortController\` per \`runWizard\`, pass \`abortSignal: controller.signal\` to \`new MastraClient(...)\`, abort via \`using \_ = { \[Symbol.dispose]: () => controller.abort() }\`. Custom \`fetch\` wrapper must preserve \`init.signal\` via spread. Tests capture \`ClientOptions\` via \`spyOn(MastraClient.prototype, 'getWorkflow').mockImplementation(function() { capturedOpts.push(this.options); ... })\`. - - -* **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\`. + +* **buildCommand wrapper: loader() returns wrapped async fn, not the generator**: \*\*buildCommand wrapper: loader() returns wrapped async fn, not generator\*\*: \`cmd.loader()\` returns the wrapped async fn, not \`async \*func()\`. Wrapper iterates generator internally and writes to \`ctx.stdout\`. Tests: \`await func.call(ctx, flags, ...args)\` like a promise — don't iterate. Auth guard runs first; \`test/preload.ts:100\` sets fake \`SENTRY\_AUTH\_TOKEN\`. Tests must save/restore only env vars they mutate. ### Pattern - -* **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race (CLI-1D3): \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600 = 3.1s). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Windows + Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path → opaque \`Executable not found in $PATH\` from \`Bun.spawn\`. Safety net \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` detects both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free delayed-write tests: writer must POLL until bad state exists THEN overwrite. - - -* **Cross-compile sentry-cli with patched Bun: drop compile.target to use selfExePath**: Cross-compile sentry-cli with patched Bun: \`Bun.build({compile})\` downloads stock Bun from npm when \`compile.target\` is set. Workaround in \`script/build.ts\`: omit \`target\` entirely so Bun hits \`isDefault()\` branch → uses \`selfExePath()\` = the running Bun as embed runtime. Only works when host OS/arch matches desired output. Escape hatch: place file at \`$CWD/bun-\-\-v\\` (e.g. \`bun-darwin-arm64-v1.3.13\`) picked up via \`bun.FD.cwd().existsAt(version\_str)\` in \`src/compile\_target.zig:exePath\`. Build also requires \`SENTRY\_CLIENT\_ID\` env var. - - -* **Dedupe resolved entity IDs in batch operations before API call**: Batch issue merge (src/commands/issue/merge.ts): (1) Dedupe by resolved numeric ID after \`Promise.all(args.map(resolveIssue))\`, not raw input (users pass same entity as \`CLI-K9\`, \`my-org/CLI-K9\`, \`123\`). Throw ValidationError if \`new Set(ids).size < 2\`. (2) Reject undefined orgs in cross-org check — bare numeric IDs without DSN/config resolve with \`org: undefined\`; filtering them out lets mixed-org merges slip through. (3) Pass \`--into\` through \`resolveIssue()\` for alias/org-qualified parity; compare by numeric \`id\`, not \`shortId\`. (4) Sentry bulk merge API picks canonical parent by event count — \`--into\` is preference only; warn when API's \`parent\` differs. Empty results return 204. - - -* **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 \`\/\\` 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. - - -* **Grouped widget --limit auto-default via applyGroupLimitAutoDefault helper**: Dashboard widget flag normalization: (1) Dataset aliases (errors→error-events) normalize ONCE at top of \`func()\` via \`normalizeDataset()\` in \`src/commands/dashboard/resolve.ts\`. In \`edit.ts\`, pass \`normalizedFlags\` to \`buildReplacement\` — \`validateAggregateNames\` reads \`flags.dataset\` and rejects valid aggregates like \`failure\_rate\` if it sees raw alias. (2) Grouped widgets need \`limit\` (API rejects). \`applyGroupLimitAutoDefault\` defaults to \`DEFAULT\_GROUP\_BY\_LIMIT=5\` only when user passed \`--group-by\` without \`--limit\`; skip for auto-defaulted columns like \`\["issue"]\`. (3) Tests asserting \`--limit\` >10 survives into PUT body must use \`display: "line"\` — \`prepareWidgetQueries\` clamps bar/table to max=10. - - -* **Hidden --org/--project compat flags via mergeGlobalFlags**: Hidden global \`--org\`/\`--project\` flags accept old \`sentry-cli\` syntax. Defined in \`GLOBAL\_FLAGS\` (global-flags.ts) so argv-hoist relocates them. \`mergeGlobalFlags()\` in command.ts injects hidden flag shapes (skip if command owns the flag — e.g. \`release create --project -p\`) and returns \`stripKeys\` set used by \`cleanRawFlags\`. \`applyOrgProjectFlags()\` writes values to \`SENTRY\_ORG\`/\`SENTRY\_PROJECT\` via \`getEnv()\` before auth guard, overwriting existing env vars (explicit CLI > env var). Resolution chain in resolve-target.ts picks them up at priority #2. No short aliases (\`-p\` conflicts). The helper extraction was needed to keep \`buildCommand\` under Biome's cognitive complexity limit of 15. - - -* **Preserve ApiError type so classifySilenced can silence 4xx errors**: Preserve ApiError type for classifySilenced: \`classifySilenced\` (src/lib/error-reporting.ts) only silences \`ApiError\` with status 401-499 — wrapping in generic \`CliError\` loses \`status\` and causes 403s to be captured. Re-throw via \`new ApiError(msg, error.status, error.detail, error.endpoint)\` with terse message (\`ApiError.format()\` appends detail/endpoint). \`ValidationError\` without \`field\` collapses unfielded errors into one fingerprint; always pass \`field\`. Fingerprint rule changes don't retroactively re-fingerprint — manually merge new groups into canonical old parents. \`ApiError\` rule keys by \`api\_status + command\`. - - -* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: Sentry SDK tree-shaking via bun patch: \`patchedDependencies\` in \`package.json\` strips unused exports from \`@sentry/core\` and \`@sentry/node-core\`. Non-light root of \`@sentry/node-core\` pulls uninstalled \`@opentelemetry/instrumentation\` — \*\*always import from \`@sentry/node-core/light\`\*\* (subpaths: \`.\`, \`./light\`, \`./light/otlp\`, \`./init\`, \`./loader\`, \`./import\`). No supported import for \`HttpsProxyAgent\`. Bumping SDK: remove old patches, \`rm -rf ~/.bun/install/cache/@sentry\`, \`bun install\`, \`bun patch @sentry/core\`, edit, \`bun patch --commit\`; repeat for node-core. Preserved: \`\_INTERNAL\_safeUnref\`, \`\_INTERNAL\_safeDateNow\`, \`nodeRuntimeMetricsIntegration\`. Before stripping any core export, grep \`node-core/build/{cjs,esm}/light/sdk.js\` for runtime usage (e.g. \`spanStreamingIntegration\` when \`traceLifecycle === 'stream'\`). Remove \`.bun-tag-\*\` hunks from generated patches. Manual \`git diff\` patches fail. - - -* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Bidirectional pagination via cursor stack in \`src/lib/db/pagination.ts\`. \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/first/last) to \`{cursor, direction}\`. \`advancePaginationState\` manages stack — back-then-forward truncates stale entries. \`hasPreviousPage\` checks \`page\_index > 0\`. \`paginationHint()\` builds nav strings. All list commands use this. Critical: \`resolveCursor()\` must be called inside \`org-all\` override closures, not before \`dispatchOrgScopedList\`. - - -* **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). - - -* **Testing Stricli command func() bodies via spyOn mocking**: Testing Stricli command func() bodies: (1) \`const func = await cmd.loader(); func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. \`loader()\` return type union causes \`.call()\` LSP false-positives that pass \`tsc --noEmit\`. (2) When API functions are renamed, update both spy target AND mock return shape. (3) \`normalizeSlug\` replaces \`\_\`→\`-\` but does NOT lowercase. (4) Bun \`mockFetch()\` replaces \`globalThis.fetch\` — use one unified mock dispatching by URL. (5) \`mock.module()\` pollutes module registry for ALL subsequent files — put in \`test/isolated/\` and run via \`test:isolated\`. (6) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - -### Preference + +* **Merging mock.module() test files with static-import counterparts**: \*\*Bun test mocking traps\*\*: (1) \`mock.module()\` for CJS built-ins needs \`default\` re-export + named exports, declared top-level BEFORE \`await import()\`. (2) Convert code-under-test to \`await import()\` when merging mocks — pre-existing static imports won't re-bind. (3) Destructured imports capture binding at load. (4) \`Bun.mmap()\` always PROT\_WRITE — use \`new Uint8Array(await Bun.file(path).arrayBuffer())\` for read-only. - -* **Bot review triage: distinguish real bugs from SDK-mirroring false positives**: When Sentry Seer or Cursor Bugbot flags 'unusual' code that intentionally mirrors upstream SDK behavior (e.g., \`http\_proxy\` as last-resort fallback for HTTPS URLs — deliberate in \`@sentry/node-core\` \`applyNoProxyOption\`), decline with a written rationale referencing the SDK source rather than silently changing behavior. Removing the mirror creates a divergence where users get different proxy semantics from our transport vs. the SDK default. BYK's pattern: verify against \`node\_modules/@sentry/node-core/build/esm/transports/http.js\`, post a reply explaining the precedent, and resolve the thread. Real bugs (uppercase env var support, whitespace trimming, wildcard handling) get fixed; SDK-mirroring 'bugs' get explained and dismissed. + +* **URL-encoded paren assertions: decode before contains-check**: \*\*URL-encoded paren assertions in tests\*\*: Aggregate field names like \`count()\` become \`count%28%29\` via \`encodeURIComponent\` — use \`expect(decodeURIComponent(url)).toContain("field=count()")\`. Sentry pagination Link header format: \`\; rel="next"; cursor="0:50:0"\` — cursor is in a separate attribute, NOT in URL query. Use \`parseSentryLinkHeader()\` from \`src/lib/api/infrastructure.ts\` to extract. diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index eff1bcec6..6964c9021 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -54,7 +54,7 @@ cli/ │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete -│ │ ├── event/ # view, list +│ │ ├── event/ # view, list, send │ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge │ │ ├── log/ # list, view │ │ ├── org/ # list, view diff --git a/docs/src/fragments/commands/event.md b/docs/src/fragments/commands/event.md index bad1c71ce..92b69704a 100644 --- a/docs/src/fragments/commands/event.md +++ b/docs/src/fragments/commands/event.md @@ -1,7 +1,57 @@ + ## Examples +### Sending Events + +```bash +# Send an error event (default level) +sentry event send -m "Something went wrong" + +# Specify level, release, and environment +sentry event send -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry event send -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry event send -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry event send -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} +``` + +### Send from a JSON file + +```bash +# Send a serialized Sentry Event object +sentry event send ./crash.json + +# Send without re-parsing (raw mode — also supports pre-built envelopes) +sentry event send --raw ./crash.json +sentry event send --raw ./captured.envelope +``` + +### DSN authentication + +`sentry event send` authenticates via a **DSN** rather than a user token. +No `sentry auth login` is required. + +The DSN is resolved in priority order: + +1. `--dsn ` flag (explicit) +2. `SENTRY_DSN` environment variable + +```bash +# Explicit DSN +sentry event send -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry event send -m "Test" +``` + ### Listing Events ```bash @@ -68,3 +118,11 @@ Event IDs can be found: 1. In the Sentry UI when viewing an issue's events 2. In the output of `sentry issue view` commands 3. In error reports sent to Sentry (as `event_id`) + +## Backward compatibility + +The old sentry-cli top-level command is available as a hidden alias: + +```bash +sentry send-event # same as: sentry event send +``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index d89f44bef..453af3718 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -323,10 +323,11 @@ Manage Sentry issues ### Event -View and list Sentry events +View, list, and send Sentry events - `sentry event view ` — View details of one or more events - `sentry event list ` — List events for an issue +- `sentry event send ` — Send a Sentry event → Full flags and examples: `references/event.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/event.md b/plugins/sentry-cli/skills/sentry-cli/references/event.md index 705dbf667..94358dafd 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -1,7 +1,7 @@ --- name: sentry-cli-event version: 0.32.0-dev.0 -description: View and list Sentry events +description: View, list, and send Sentry events requires: bins: ["sentry"] auth: true @@ -9,7 +9,7 @@ requires: # Event Commands -View and list Sentry events +View, list, and send Sentry events ### `sentry event view ` @@ -87,4 +87,60 @@ sentry event list PROJ-ABC -c prev sentry event list PROJ-ABC --json ``` +### `sentry event send ` + +Send a Sentry event + +**Flags:** +- `--dsn - DSN to send events to (overrides SENTRY_DSN env var)` +- `-m, --message ... - Event message (repeat for multi-line)` +- `-a, --message-arg ... - Arguments for message template (repeat for multiple)` +- `-l, --level - Event severity level - (default: "error")` +- `-r, --release - Release version` +- `-d, --dist - Distribution identifier` +- `-E, --env - Environment name (e.g. production, staging)` +- `-p, --platform - Platform identifier (default: other)` +- `-t, --tag ... - Tag as KEY:VALUE (repeat for multiple)` +- `-e, --extra ... - Extra data as KEY:VALUE (repeat for multiple)` +- `-u, --user ... - User info as KEY:VALUE — id, email, username, ip_address, or custom` +- `-f, --fingerprint ... - Custom fingerprint part (repeat for multiple)` +- `--timestamp - Event timestamp (Unix epoch, ISO 8601, or RFC 2822)` +- `--no-environ - Do not include environment variables in the event` +- `--raw - Send file contents as-is without parsing` + +**Examples:** + +```bash +# Send an error event (default level) +sentry event send -m "Something went wrong" + +# Specify level, release, and environment +sentry event send -m "Deploy check" -l info -r 1.0.0 -E production + +# Add tags and extra data +sentry event send -m "Payment failed" --tag env:prod --tag region:us-east --extra amount:99.99 + +# Set user context +sentry event send -m "Login error" --user id:42 --user email:alice@example.com + +# Custom fingerprint to group related events together +sentry event send -m "DB timeout" --fingerprint db-timeout --fingerprint {{ default }} + +# Send a serialized Sentry Event object +sentry event send ./crash.json + +# Send without re-parsing (raw mode — also supports pre-built envelopes) +sentry event send --raw ./crash.json +sentry event send --raw ./captured.envelope + +# Explicit DSN +sentry event send -m "Test" --dsn "https://key@o123.ingest.us.sentry.io/456" + +# Via environment variable +export SENTRY_DSN="https://key@o123.ingest.us.sentry.io/456" +sentry event send -m "Test" + +sentry send-event # same as: sentry event send +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/src/app.ts b/src/app.ts index ea537a416..95f3b590b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,8 @@ import { listCommand as replayListCommand } from "./commands/replay/list.js"; import { repoRoute } from "./commands/repo/index.js"; import { listCommand as repoListCommand } from "./commands/repo/list.js"; import { schemaCommand } from "./commands/schema.js"; +import { sendEnvelopeCommand } from "./commands/send-envelope.js"; +import { sendEventCommand } from "./commands/send-event.js"; import { sourcemapRoute } from "./commands/sourcemap/index.js"; import { spanRoute } from "./commands/span/index.js"; import { listCommand as spanListCommand } from "./commands/span/list.js"; @@ -105,6 +107,9 @@ export const routes = buildRouteMap({ init: initCommand, api: apiCommand, schema: schemaCommand, + // Backward-compat aliases for old sentry-cli — hidden from help + "send-event": sendEventCommand, + "send-envelope": sendEnvelopeCommand, dashboards: dashboardListCommand, issues: issueListCommand, orgs: orgListCommand, @@ -141,6 +146,8 @@ export const routes = buildRouteMap({ trials: true, sourcemaps: true, whoami: true, + "send-event": true, + "send-envelope": true, }, }, }); diff --git a/src/commands/event/index.ts b/src/commands/event/index.ts index b1909fadc..70888d552 100644 --- a/src/commands/event/index.ts +++ b/src/commands/event/index.ts @@ -1,19 +1,22 @@ import { buildRouteMap } from "../../lib/route-map.js"; import { listCommand } from "./list.js"; +import { sendCommand } from "./send.js"; import { viewCommand } from "./view.js"; export const eventRoute = buildRouteMap({ routes: { view: viewCommand, list: listCommand, + send: sendCommand, }, defaultCommand: "view", docs: { - brief: "View and list Sentry events", + brief: "View, list, and send Sentry events", fullDescription: - "View and list event data from Sentry.\n\n" + + "View, list, and send event data from Sentry.\n\n" + "Use 'sentry event view ' to view a specific event.\n" + - "Use 'sentry event list ' to list events for an issue.", + "Use 'sentry event list ' to list events for an issue.\n" + + "Use 'sentry event send -m ' to send a test event.", hideRoute: {}, }, }); diff --git a/src/commands/event/send.ts b/src/commands/event/send.ts new file mode 100644 index 000000000..f4c343ed9 --- /dev/null +++ b/src/commands/event/send.ts @@ -0,0 +1,318 @@ +/** + * `sentry event send` — Send a Sentry event from CLI flags or a JSON file. + * + * Unlike most commands, this authenticates via a DSN (not a Bearer token), + * so no `sentry auth login` is required. The DSN can be provided via: + * 1. --dsn flag + * 2. SENTRY_DSN environment variable + */ + +import type { DsnComponents, Event } from "@sentry/core"; +import { createEventEnvelope, makeDsn, serializeEnvelope } from "@sentry/core"; +import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; +import { + buildEventFromFlags, + type SendEventFlags, +} from "../../lib/envelope/event-builder.js"; +import { + readFileBytes, + requireDsn, + sendEnvelopeRequest, +} from "../../lib/envelope/transport.js"; +import { ConfigError, ValidationError } from "../../lib/errors.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; + +/** Shape of the data yielded to the output layer. */ +type SendEventResult = { + eventId: string; + file?: string; +}; + +function formatSendEventHuman(result: SendEventResult): string { + if (result.file) { + return `Event from ${result.file} dispatched: ${result.eventId}`; + } + return `Event dispatched.\nEvent ID: ${result.eventId}`; +} + +/** + * Build the envelope body and extract the event ID for a file-based send. + * + * In raw mode the file bytes are sent as-is; in normal mode the JSON is + * parsed, wrapped in an EventEnvelope, and re-serialized. + */ +async function buildFilePayload( + file: string, + raw: boolean, + dsnComponents: DsnComponents +): Promise<{ body: string | Uint8Array; eventId: string }> { + const bytes = await readFileBytes(file); + + if (raw) { + // Best-effort: extract event_id from the first line (envelope header JSON). + let eventId = ""; + try { + const firstLine = new TextDecoder().decode(bytes).split("\n")[0] ?? "{}"; + const header = JSON.parse(firstLine) as Record; + eventId = (header.event_id as string) ?? ""; + } catch { + // Non-critical — event_id is informational only + } + return { body: bytes, eventId }; + } + + let event: Event; + try { + event = JSON.parse(new TextDecoder().decode(bytes)) as Event; + } catch (err) { + throw new ValidationError( + `Failed to parse JSON from ${file}: ${(err as Error).message}`, + "path" + ); + } + + let body: string | Uint8Array; + try { + const envelope = createEventEnvelope(event, dsnComponents); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to create envelope from ${file}: ${(err as Error).message}`, + "path" + ); + } + return { body, eventId: event.event_id ?? "" }; +} + +export const sendCommand = buildCommand({ + docs: { + brief: "Send a Sentry event", + fullDescription: `\ +Send a Sentry event to the ingest pipeline using DSN-based authentication. + +No login required — provide a DSN via --dsn or the SENTRY_DSN environment variable. + +## Building an event from flags + +\`\`\` +sentry event send -m "Something went wrong" -l error --tag env:prod +\`\`\` + +## Sending from a JSON file + +The JSON file must be a valid serialized Sentry Event object: + +\`\`\` +sentry event send ./event.json +\`\`\` + +Use --raw to skip JSON parsing and send the file bytes directly to the ingest endpoint. +This also supports sending pre-built Sentry envelope files. + +When file arguments are provided, flags like -m/--message are ignored — the event is +built entirely from the file contents. + +## Common flags + +| Flag | Description | +|------|-------------| +| \`--dsn\` | DSN to send to (overrides SENTRY_DSN) | +| \`-m\` / \`--message\` | Event message (repeat for multi-line) | +| \`-l\` / \`--level\` | Severity: debug, info, warning, error, fatal | +| \`-r\` / \`--release\` | Release version | +| \`-E\` / \`--env\` | Environment name | +| \`-t\` / \`--tag\` | Tag as KEY:VALUE (repeat for multiple) | +| \`-e\` / \`--extra\` | Extra data as KEY:VALUE | +| \`-u\` / \`--user\` | User info as KEY:VALUE (id, email, username, ip_address) | +| \`-f\` / \`--fingerprint\` | Custom fingerprint parts (repeat) | +`, + }, + auth: "dsn", + output: { + human: formatSendEventHuman, + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "Path(s) to JSON event file(s) to send", + parse: String, + optional: true, + }, + }, + flags: { + dsn: { + kind: "parsed", + parse: String, + brief: "DSN to send events to (overrides SENTRY_DSN env var)", + optional: true, + }, + message: { + kind: "parsed", + parse: String, + brief: "Event message (repeat for multi-line)", + variadic: true, + optional: true, + }, + "message-arg": { + kind: "parsed", + parse: String, + brief: "Arguments for message template (repeat for multiple)", + variadic: true, + optional: true, + }, + level: { + kind: "enum", + values: ["debug", "info", "warning", "error", "fatal"], + brief: "Event severity level", + default: "error", + optional: true, + }, + release: { + kind: "parsed", + parse: String, + brief: "Release version", + optional: true, + }, + dist: { + kind: "parsed", + parse: String, + brief: "Distribution identifier", + optional: true, + }, + env: { + kind: "parsed", + parse: String, + brief: "Environment name (e.g. production, staging)", + optional: true, + }, + platform: { + kind: "parsed", + parse: String, + brief: "Platform identifier (default: other)", + optional: true, + }, + tag: { + kind: "parsed", + parse: String, + brief: "Tag as KEY:VALUE (repeat for multiple)", + variadic: true, + optional: true, + }, + extra: { + kind: "parsed", + parse: String, + brief: "Extra data as KEY:VALUE (repeat for multiple)", + variadic: true, + optional: true, + }, + user: { + kind: "parsed", + parse: String, + brief: + "User info as KEY:VALUE — id, email, username, ip_address, or custom", + variadic: true, + optional: true, + }, + fingerprint: { + kind: "parsed", + parse: String, + brief: "Custom fingerprint part (repeat for multiple)", + variadic: true, + optional: true, + }, + timestamp: { + kind: "parsed", + parse: String, + brief: "Event timestamp (Unix epoch, ISO 8601, or RFC 2822)", + optional: true, + }, + "no-environ": { + kind: "boolean", + brief: "Do not include environment variables in the event", + default: false, + optional: true, + }, + raw: { + kind: "boolean", + brief: "Send file contents as-is without parsing", + default: false, + optional: true, + }, + }, + aliases: { + m: "message", + a: "message-arg", + l: "level", + r: "release", + d: "dist", + E: "env", + p: "platform", + t: "tag", + e: "extra", + u: "user", + f: "fingerprint", + }, + }, + async *func( + this: SentryContext, + flags: SendEventFlags & { + dsn?: string; + raw?: boolean; + json?: boolean; + }, + ...files: string[] + ) { + const dsn = requireDsn(flags, this.cwd); + let dsnComponents: ReturnType; + try { + dsnComponents = makeDsn(dsn); + } catch { + dsnComponents = undefined; + } + if (!dsnComponents) { + throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); + } + + if (files.length > 0) { + for (const file of files) { + const { body, eventId } = await buildFilePayload( + file, + flags.raw ?? false, + dsnComponents + ); + await sendEnvelopeRequest(dsn, body); + yield new CommandOutput({ eventId, file }); + } + } else { + if (flags.raw) { + throw new ValidationError( + "--raw requires a file argument (raw bytes cannot be built from inline flags)", + "raw" + ); + } + if (!flags.message?.length) { + throw new ConfigError( + "Provide a message via -m/--message or a JSON event file as a positional argument.", + "sentry event send -m 'My message'" + ); + } + const event = buildEventFromFlags(flags); + let body: string | Uint8Array; + try { + const envelope = createEventEnvelope(event, dsnComponents); + body = serializeEnvelope(envelope); + } catch (err) { + throw new ValidationError( + `Failed to create event envelope: ${(err as Error).message}`, + "event" + ); + } + await sendEnvelopeRequest(dsn, body); + yield new CommandOutput({ + eventId: event.event_id ?? "", + }); + } + }, +}); diff --git a/src/commands/send-envelope.ts b/src/commands/send-envelope.ts new file mode 100644 index 000000000..0ac9c9a7f --- /dev/null +++ b/src/commands/send-envelope.ts @@ -0,0 +1,61 @@ +/** + * `sentry send-envelope` — Deprecated. Suggests `sentry event send --raw`. + * + * Kept as a hidden backward-compat alias that prints a deprecation notice + * and forwards to `sentry event send --raw`. + */ + +import type { SentryContext } from "../context.js"; +import { buildCommand } from "../lib/command.js"; +import { CliError } from "../lib/errors.js"; + +export const sendEnvelopeCommand = buildCommand({ + docs: { + brief: "Send a Sentry envelope file (deprecated)", + fullDescription: + "This command has been replaced by `sentry event send --raw `.\n\n" + + "Use `sentry event send --raw ./captured.envelope` instead.", + }, + auth: false, + skipRcUrlCheck: true, + output: { + human: () => "", + }, + parameters: { + positional: { + kind: "array", + parameter: { + brief: "Path(s) to envelope file(s)", + parse: String, + optional: true, + }, + }, + flags: { + dsn: { + kind: "parsed", + parse: String, + brief: "DSN", + optional: true, + }, + raw: { + kind: "boolean", + brief: "Raw mode", + default: false, + optional: true, + }, + }, + }, + // biome-ignore lint/correctness/useYield lint/suspicious/useAwait: deprecation shim — throws before yielding + async *func( + this: SentryContext, + _flags: { dsn?: string; raw?: boolean }, + ...files: string[] + ) { + const fileArgs = files.length > 0 ? ` ${files.join(" ")}` : " "; + throw new CliError( + "`sentry send-envelope` has been removed.\n" + + `Use: sentry event send --raw${fileArgs}`, + 1 + ); + }, +}); diff --git a/src/commands/send-event.ts b/src/commands/send-event.ts new file mode 100644 index 000000000..5adbaf16d --- /dev/null +++ b/src/commands/send-event.ts @@ -0,0 +1,9 @@ +/** + * Backward-compat re-export: `sentry send-event` → `sentry event send`. + * + * Registered as a hidden alias in app.ts. The canonical command lives + * in `event/send.ts`. + */ + +// biome-ignore lint/performance/noBarrelFile: backward-compat alias, not a barrel +export { sendCommand as sendEventCommand } from "./event/send.js"; diff --git a/src/lib/command-suggestions.ts b/src/lib/command-suggestions.ts index eded64477..6730394f9 100644 --- a/src/lib/command-suggestions.ts +++ b/src/lib/command-suggestions.ts @@ -99,14 +99,7 @@ const SUGGESTIONS: ReadonlyMap = new Map([ // --- old sentry-cli commands (~5 events) --- ["cli/info", { command: "sentry auth status" }], - [ - "cli/send-event", - { - command: - "sentry api /api/0/projects/{org}/{project}/store/ --method POST", - explanation: "Use the API to send test events", - }, - ], + ["cli/send-event", { command: "sentry event send" }], ["cli/issues", { command: "sentry issue list" }], ["cli/logs", { command: "sentry log list" }], diff --git a/src/lib/command.ts b/src/lib/command.ts index 97bfbc784..a90f0778b 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -166,8 +166,13 @@ type LocalCommandBuilderArguments< * * Set to `false` for commands that intentionally work without a token * (e.g. `auth login`, `auth logout`, `auth status`, `help`, `cli upgrade`). + * + * Set to `"dsn"` for commands that authenticate via a Sentry DSN instead of + * a Bearer token (e.g. `event send`). These commands skip + * the token guard and the `.sentryclirc` URL trust check entirely, since + * DSN auth is fully independent of the user's logged-in session. */ - readonly auth?: boolean; + readonly auth?: boolean | "dsn"; /** * Skip the `.sentryclirc` URL trust check. Defaults to `false`. * @@ -490,8 +495,9 @@ export function buildCommand< ): Command { const originalFunc = builderArgs.func; const outputConfig = builderArgs.output; - const requiresAuth = builderArgs.auth !== false; - const skipRcUrlCheck = builderArgs.skipRcUrlCheck === true; + const requiresAuth = builderArgs.auth !== false && builderArgs.auth !== "dsn"; + const skipRcUrlCheck = + builderArgs.skipRcUrlCheck === true || builderArgs.auth === "dsn"; // Merge global flags into the command's flag definitions. const existingParams = (builderArgs.parameters ?? {}) as Record< diff --git a/src/lib/envelope/event-builder.ts b/src/lib/envelope/event-builder.ts new file mode 100644 index 000000000..eef6f571b --- /dev/null +++ b/src/lib/envelope/event-builder.ts @@ -0,0 +1,145 @@ +/** + * Constructs a Sentry Event from `sentry event send` CLI flags. + * + * Mirrors the behaviour of the old Rust sentry-cli `send-event` command + * tags/extras as KEY:VALUE pairs, user fields with known routing + * (id, email, ip_address, username → top-level; everything else → user.data), + * environment variables optionally included as `extra.environ`. + */ + +import type { Event, SeverityLevel, User } from "@sentry/core"; +import { uuid4 } from "@sentry/core"; +import { ValidationError } from "../errors.js"; + +/** CLI flags accepted by `sentry event send`. */ +export type SendEventFlags = { + message?: string[]; + "message-arg"?: string[]; + level?: string; + release?: string; + dist?: string; + env?: string; + platform?: string; + tag?: string[]; + extra?: string[]; + user?: string[]; + fingerprint?: string[]; + timestamp?: string; + "no-environ"?: boolean; +}; + +const KNOWN_USER_FIELDS = new Set(["id", "email", "ip_address", "username"]); + +/** + * Parse a single KEY:VALUE string, splitting on the first colon. + * + * Values may contain colons (e.g. `url:https://example.com`). + * Throws ValidationError if the format is wrong. + */ +export function parseKeyValue(pair: string): [string, string] { + const idx = pair.indexOf(":"); + if (idx <= 0) { + throw new ValidationError( + `Expected KEY:VALUE format, got: ${JSON.stringify(pair)}`, + "tag/extra" + ); + } + return [pair.slice(0, idx), pair.slice(idx + 1)]; +} + +/** + * Parse an array of KEY:VALUE strings into a plain object. + */ +function parseKeyValuePairs( + pairs: string[] | undefined +): Record { + if (!pairs?.length) { + return {}; + } + return Object.fromEntries(pairs.map(parseKeyValue)); +} + +/** + * Parse `--user` KEY:VALUE pairs into a Sentry User object. + * + * Known fields (id, email, ip_address, username) map directly to User + * properties. Unknown keys go into `user.data` for custom attributes. + */ +export function parseUserFields(pairs: string[]): User { + const user: User & { data?: Record } = {}; + for (const pair of pairs) { + const [key, value] = parseKeyValue(pair); + if (KNOWN_USER_FIELDS.has(key)) { + (user as Record)[key] = value; + } else { + user.data ??= {}; + user.data[key] = value; + } + } + return user; +} + +/** + * Parse a timestamp string into a Unix epoch float (seconds). + * + * Accepts: Unix integer/float, ISO 8601, RFC 2822. + * Returns undefined for falsy input (caller uses Date.now()). + * Throws ValidationError for non-empty strings that cannot be parsed. + */ +function parseTimestamp(ts: string | undefined): number | undefined { + if (!ts) { + return; + } + // Unix numeric + const num = Number(ts); + if (Number.isFinite(num)) { + return num; + } + // ISO / RFC 2822 + const parsed = Date.parse(ts); + if (!Number.isNaN(parsed)) { + return parsed / 1000; + } + throw new ValidationError( + `Invalid --timestamp value: '${ts}'. Use a Unix epoch number, ISO 8601, or RFC 2822 date.`, + "timestamp" + ); +} + +/** + * Build a Sentry Event from CLI flag values. + * + * The returned object is ready to be wrapped in an EventEnvelope and + * serialized for posting to the ingest endpoint. + */ +export function buildEventFromFlags(flags: SendEventFlags): Event { + const tags = parseKeyValuePairs(flags.tag); + // environ goes first so explicit --extra environ:val overrides it + const extra: Record = { + ...(flags["no-environ"] ? {} : { environ: process.env }), + ...parseKeyValuePairs(flags.extra), + }; + + return { + event_id: uuid4(), + level: (flags.level ?? "error") as SeverityLevel, + platform: flags.platform ?? "other", + timestamp: parseTimestamp(flags.timestamp) ?? Date.now() / 1000, + release: flags.release, + dist: flags.dist, + environment: flags.env, + logentry: + flags.message && flags.message.length > 0 + ? { + message: flags.message.join("\n"), + ...(flags["message-arg"]?.length + ? { params: flags["message-arg"] as unknown[] } + : {}), + } + : undefined, + tags: Object.keys(tags).length > 0 ? tags : undefined, + extra: Object.keys(extra).length > 0 ? extra : undefined, + user: flags.user?.length ? parseUserFields(flags.user) : undefined, + fingerprint: flags.fingerprint, + }; +} diff --git a/src/lib/envelope/transport.ts b/src/lib/envelope/transport.ts new file mode 100644 index 000000000..41b521f4a --- /dev/null +++ b/src/lib/envelope/transport.ts @@ -0,0 +1,135 @@ +/** + * DSN-based envelope transport for Sentry's event ingestion pipeline. + * + * Unlike the Web API (which uses Bearer token auth), envelope ingestion + * authenticates via the DSN's public key embedded in the request URL. + * This is the same mechanism all Sentry SDKs use when reporting errors. + * + * Endpoint pattern: + * POST https:///api//envelope/ + * ?sentry_key=&sentry_version=7 + * Content-Type: application/x-sentry-envelope + */ + +import { getEnvelopeEndpointWithUrlEncodedAuth, makeDsn } from "@sentry/core"; +import { ApiError, ConfigError, ValidationError } from "../errors.js"; + +/** Client name passed to getEnvelopeEndpointWithUrlEncodedAuth, which appends / internally. */ +const SENTRY_CLIENT = "sentry-cli"; + +/** Flags subset relevant to DSN resolution. */ +export type DsnFlags = { + dsn?: string; +}; + +/** + * Build the ingest URL for a given DSN. + * + * Returns the full URL including auth query params, ready to POST to. + * Throws ValidationError on an unparseable DSN. + */ +export function buildEnvelopeUrl(dsn: string): string { + let dsnComponents: ReturnType; + try { + dsnComponents = makeDsn(dsn); + } catch { + // makeDsn may throw a SentryError on malformed input + dsnComponents = undefined; + } + if (!dsnComponents) { + throw new ValidationError(`Invalid DSN: ${dsn}`, "dsn"); + } + return getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents, undefined, { + name: SENTRY_CLIENT, + version: "dev", + }); +} + +/** + * Resolve the DSN to use for sending, in priority order: + * 1. `--dsn` flag + * 2. `SENTRY_DSN` environment variable + * 3. Returns `undefined` (caller decides whether to auto-detect or error) + */ +export function resolveDsn(flags: DsnFlags, _cwd: string): string | undefined { + if (flags.dsn) { + return flags.dsn.trim(); + } + const envDsn = process.env.SENTRY_DSN; + if (envDsn) { + return envDsn.trim(); + } + return; +} + +/** + * Require a DSN to be available, throwing a helpful ConfigError if not. + * + * Auto-detection via project scanning is intentionally deferred — callers + * that want it can call the DSN detector before this. + */ +export function requireDsn(flags: DsnFlags, cwd: string): string { + const dsn = resolveDsn(flags, cwd); + if (dsn) { + return dsn; + } + throw new ConfigError( + "No DSN found. Provide one via --dsn or set the SENTRY_DSN environment variable.", + "sentry event send --dsn " + ); +} + +/** + * Read a file's bytes, throwing a clean ValidationError on ENOENT or I/O errors. + * + * Centralises the file-reading error-handling pattern used by + * `event send` (and previously by `send-envelope`). + */ +export async function readFileBytes(file: string): Promise { + try { + return new Uint8Array(await Bun.file(file).arrayBuffer()); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError(`File not found: ${file}`, "path"); + } + throw new ValidationError( + `Cannot read file ${file}: ${(err as Error).message}`, + "path" + ); + } +} + +/** + * POST a serialized envelope to Sentry's ingest endpoint using DSN auth. + * + * No Bearer token is required — the DSN public key serves as authentication. + * Throws ApiError on non-2xx responses. + */ +export async function sendEnvelopeRequest( + dsn: string, + body: string | Uint8Array +): Promise { + const url = buildEnvelopeUrl(dsn); + + const response = await fetch( + new Request(url, { + method: "POST", + headers: { "Content-Type": "application/x-sentry-envelope" }, + body, + }) + ); + + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const json = (await response.json()) as Record; + if (typeof json.detail === "string") { + detail = json.detail; + } + } catch { + // Non-JSON error body — keep the HTTP status message + } + throw new ApiError(detail, response.status, detail, url); + } +} diff --git a/test/commands/event/send.test.ts b/test/commands/event/send.test.ts new file mode 100644 index 000000000..44f59cb6a --- /dev/null +++ b/test/commands/event/send.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for `sentry event send` command func(). + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { sendCommand } from "../../../src/commands/event/send.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn +import * as transport from "../../../src/lib/envelope/transport.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("send-event-"); + +const SAAS_DSN = "https://abc123@o1.ingest.us.sentry.io/999"; + +function makeContext() { + const writes: string[] = []; + return { + ctx: { + stdout: { + write: (s: string) => { + writes.push(s); + return true; + }, + }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }, + writes, + }; +} + +describe("sendCommand.func()", () => { + let func: Awaited>; + let sendSpy: ReturnType; + + beforeEach(async () => { + func = await sendCommand.loader(); + sendSpy = spyOn(transport, "sendEnvelopeRequest").mockResolvedValue( + undefined + ); + }); + + afterEach(() => { + sendSpy.mockRestore(); + }); + + test("inline message sends an envelope and prints event ID", async () => { + const { ctx, writes } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["Test message"], + level: "error", + "no-environ": true, + }); + + expect(sendSpy).toHaveBeenCalledTimes(1); + const [calledDsn, calledBody] = sendSpy.mock.calls[0] as [string, string]; + expect(calledDsn).toBe(SAAS_DSN); + expect(calledBody).toContain('"type":"event"'); + + const output = writes.join(""); + expect(output).toContain("Event dispatched"); + expect(output).toMatch(/[0-9a-f]{32}/); // event ID in output + }); + + test("--level flag is included in envelope body", async () => { + const { ctx } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["boom"], + level: "fatal", + "no-environ": true, + }); + + const body = sendSpy.mock.calls[0]?.[1] as string; + expect(body).toContain('"level":"fatal"'); + }); + + test("--tag pairs appear in envelope body", async () => { + const { ctx } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["hi"], + tag: ["env:prod", "region:us"], + "no-environ": true, + }); + + const body = sendSpy.mock.calls[0]?.[1] as string; + expect(body).toContain('"env":"prod"'); + expect(body).toContain('"region":"us"'); + }); + + test("missing DSN throws ConfigError", async () => { + const savedDsn = process.env.SENTRY_DSN; + delete process.env.SENTRY_DSN; + const { ctx } = makeContext(); + try { + await expect(func.call(ctx, { "no-environ": true })).rejects.toThrow(); + } finally { + if (savedDsn !== undefined) process.env.SENTRY_DSN = savedDsn; + } + }); + + test("--json outputs JSON with eventId field", async () => { + const { ctx, writes } = makeContext(); + await func.call(ctx, { + dsn: SAAS_DSN, + message: ["hello"], + json: true, + "no-environ": true, + }); + + const output = writes.join(""); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("eventId"); + expect(parsed.eventId).toMatch(/^[0-9a-f]{32}$/); + }); + + test("nonexistent file throws ValidationError (not raw stack trace)", async () => { + const { ctx } = makeContext(); + const { ValidationError } = await import("../../../src/lib/errors.js"); + await expect( + func.call( + ctx, + { dsn: SAAS_DSN, "no-environ": true }, + "/nonexistent/missing.json" + ) + ).rejects.toBeInstanceOf(ValidationError); + }); + + test("--raw requires file arguments", async () => { + const { ctx } = makeContext(); + const { ValidationError } = await import("../../../src/lib/errors.js"); + await expect( + func.call(ctx, { dsn: SAAS_DSN, raw: true, "no-environ": true }) + ).rejects.toBeInstanceOf(ValidationError); + }); +}); diff --git a/test/commands/send-envelope.test.ts b/test/commands/send-envelope.test.ts new file mode 100644 index 000000000..c0dcc87da --- /dev/null +++ b/test/commands/send-envelope.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for `sentry send-envelope` deprecation shim. + */ + +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { sendEnvelopeCommand } from "../../src/commands/send-envelope.js"; +import { CliError } from "../../src/lib/errors.js"; +import { useTestConfigDir } from "../helpers.js"; + +useTestConfigDir("send-envelope-"); + +function makeContext() { + return { + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + }; +} + +describe("sendEnvelopeCommand (deprecation shim)", () => { + let func: Awaited>; + + beforeEach(async () => { + func = await sendEnvelopeCommand.loader(); + }); + + test("throws CliError suggesting event send --raw", async () => { + const ctx = makeContext(); + await expect( + func.call( + ctx, + { dsn: "https://x@o1.ingest.sentry.io/1" }, + "file.envelope" + ) + ).rejects.toBeInstanceOf(CliError); + }); + + test("error message includes the file argument", async () => { + const ctx = makeContext(); + try { + await func.call( + ctx, + { dsn: "https://x@o1.ingest.sentry.io/1" }, + "my.envelope" + ); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as CliError).message).toContain("sentry event send --raw"); + expect((err as CliError).message).toContain("my.envelope"); + } + }); + + test("error message uses placeholder when no files given", async () => { + const ctx = makeContext(); + try { + await func.call(ctx, { dsn: "https://x@o1.ingest.sentry.io/1" }); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as CliError).message).toContain( + "sentry event send --raw " + ); + } + }); +}); diff --git a/test/lib/command-suggestions.test.ts b/test/lib/command-suggestions.test.ts index e2f7f0b39..75c207079 100644 --- a/test/lib/command-suggestions.test.ts +++ b/test/lib/command-suggestions.test.ts @@ -81,9 +81,9 @@ describe("getCommandSuggestion", () => { expect(getCommandSuggestion("cli", "logs")?.command).toContain("log list"); }); - test("suggests api for 'cli/send-event'", () => { + test("suggests send-event for 'cli/send-event'", () => { expect(getCommandSuggestion("cli", "send-event")?.command).toContain( - "sentry api" + "sentry event send" ); }); diff --git a/test/lib/envelope/event-builder.test.ts b/test/lib/envelope/event-builder.test.ts new file mode 100644 index 000000000..9e9d68f80 --- /dev/null +++ b/test/lib/envelope/event-builder.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for buildEventFromFlags — converts CLI flags to a Sentry Event. + * + * Note: Core invariants (tag/extra parsing, user field routing) are property- + * tested below. Unit tests here focus on specific edge cases and output shape. + */ + +import { describe, expect, test } from "bun:test"; +import type { SendEventFlags } from "../../../src/lib/envelope/event-builder.js"; +import { + buildEventFromFlags, + parseKeyValue, + parseUserFields, +} from "../../../src/lib/envelope/event-builder.js"; + +// ── parseKeyValue ────────────────────────────────────────────────── + +describe("parseKeyValue", () => { + test("splits on first colon", () => { + expect(parseKeyValue("key:value")).toEqual(["key", "value"]); + }); + + test("value may contain colons", () => { + expect(parseKeyValue("url:https://example.com")).toEqual([ + "url", + "https://example.com", + ]); + }); + + test("no colon → throws ValidationError", () => { + const { ValidationError } = require("../../../src/lib/errors.js"); + expect(() => parseKeyValue("nocohere")).toThrow(ValidationError); + }); + + test("empty key → throws ValidationError", () => { + const { ValidationError } = require("../../../src/lib/errors.js"); + expect(() => parseKeyValue(":value")).toThrow(ValidationError); + }); +}); + +// ── parseUserFields ─────────────────────────────────────────────── + +describe("parseUserFields", () => { + test("id maps to user.id", () => { + expect(parseUserFields(["id:42"])).toMatchObject({ id: "42" }); + }); + + test("email maps to user.email", () => { + expect(parseUserFields(["email:alice@example.com"])).toMatchObject({ + email: "alice@example.com", + }); + }); + + test("ip_address maps to user.ip_address", () => { + expect(parseUserFields(["ip_address:1.2.3.4"])).toMatchObject({ + ip_address: "1.2.3.4", + }); + }); + + test("username maps to user.username", () => { + expect(parseUserFields(["username:alice"])).toMatchObject({ + username: "alice", + }); + }); + + test("unknown keys go into user.data", () => { + expect(parseUserFields(["role:admin"])).toMatchObject({ + data: { role: "admin" }, + }); + }); + + test("multiple pairs merged", () => { + const result = parseUserFields(["id:1", "email:a@b.com", "role:admin"]); + expect(result).toMatchObject({ + id: "1", + email: "a@b.com", + data: { role: "admin" }, + }); + }); +}); + +// ── buildEventFromFlags ─────────────────────────────────────────── + +describe("buildEventFromFlags", () => { + function flags(overrides: Partial = {}): SendEventFlags { + return { "no-environ": true, ...overrides }; + } + + test("defaults: level=error, platform=other", () => { + const event = buildEventFromFlags(flags()); + expect(event.level).toBe("error"); + expect(event.platform).toBe("other"); + }); + + test("event_id is always a 32-char hex string", () => { + const event = buildEventFromFlags(flags()); + expect(event.event_id).toMatch(/^[0-9a-f]{32}$/); + }); + + test("timestamp is a Unix float", () => { + const event = buildEventFromFlags(flags()); + expect(typeof event.timestamp).toBe("number"); + expect(event.timestamp).toBeGreaterThan(0); + }); + + test("--level sets level", () => { + expect(buildEventFromFlags(flags({ level: "warning" })).level).toBe( + "warning" + ); + }); + + test("--message joined with newline", () => { + const event = buildEventFromFlags(flags({ message: ["hello", "world"] })); + expect(event.logentry?.message).toBe("hello\nworld"); + }); + + test("--message-arg sets params", () => { + const event = buildEventFromFlags( + flags({ message: ["hello %s"], "message-arg": ["world"] }) + ); + expect(event.logentry?.params).toEqual(["world"]); + }); + + test("--tag parses into tags object", () => { + const event = buildEventFromFlags(flags({ tag: ["env:prod", "ver:1.0"] })); + expect(event.tags).toEqual({ env: "prod", ver: "1.0" }); + }); + + test("--extra parses into extra object", () => { + const event = buildEventFromFlags(flags({ extra: ["foo:bar"] })); + expect((event.extra as Record).foo).toBe("bar"); + }); + + test("--no-environ omits process.env from extra", () => { + const event = buildEventFromFlags(flags({ "no-environ": true })); + expect((event.extra as Record)?.environ).toBeUndefined(); + }); + + test("environ included when --no-environ not set", () => { + const event = buildEventFromFlags(flags({ "no-environ": false })); + expect((event.extra as Record)?.environ).toBeDefined(); + }); + + test("--user routes known fields correctly", () => { + const event = buildEventFromFlags( + flags({ user: ["id:99", "email:a@b.com"] }) + ); + expect(event.user?.id).toBe("99"); + expect(event.user?.email).toBe("a@b.com"); + }); + + test("--fingerprint sets fingerprint array", () => { + const event = buildEventFromFlags( + flags({ fingerprint: ["my-error", "{{ default }}"] }) + ); + expect(event.fingerprint).toEqual(["my-error", "{{ default }}"]); + }); + + test("--release sets release", () => { + expect(buildEventFromFlags(flags({ release: "1.2.3" })).release).toBe( + "1.2.3" + ); + }); + + test("--env sets environment", () => { + expect(buildEventFromFlags(flags({ env: "staging" })).environment).toBe( + "staging" + ); + }); + + test("--platform sets platform", () => { + expect(buildEventFromFlags(flags({ platform: "python" })).platform).toBe( + "python" + ); + }); + + test("--dist sets dist", () => { + expect(buildEventFromFlags(flags({ dist: "x86" })).dist).toBe("x86"); + }); + + test("each call produces a unique event_id", () => { + const a = buildEventFromFlags(flags()); + const b = buildEventFromFlags(flags()); + expect(a.event_id).not.toBe(b.event_id); + }); +}); diff --git a/test/lib/envelope/transport.test.ts b/test/lib/envelope/transport.test.ts new file mode 100644 index 000000000..a89358e55 --- /dev/null +++ b/test/lib/envelope/transport.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for the DSN-based envelope transport. + * + * Core invariants: + * - URL is built from DSN components (host + projectId) + * - Auth is injected as query params (sentry_key, sentry_version) + * - Content-Type is always application/x-sentry-envelope + * - Non-2xx responses throw ApiError + * - Both string and Uint8Array bodies are supported + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + buildEnvelopeUrl, + resolveDsn, + sendEnvelopeRequest, +} from "../../../src/lib/envelope/transport.js"; +import { ApiError, ValidationError } from "../../../src/lib/errors.js"; + +const SAAS_DSN = "https://abc123@o1169445.ingest.us.sentry.io/4505229541441536"; +const SELF_HOSTED_DSN = "https://pubkey99@sentry.mycompany.com/7"; + +// ── buildEnvelopeUrl ─────────────────────────────────────────────── + +describe("buildEnvelopeUrl", () => { + test("SaaS DSN → correct ingest URL with auth params", () => { + const url = buildEnvelopeUrl(SAAS_DSN); + expect(url).toContain("/api/4505229541441536/envelope/"); + expect(url).toContain("sentry_key=abc123"); + expect(url).toContain("sentry_version=7"); + expect(url.startsWith("https://")).toBe(true); + }); + + test("self-hosted DSN → correct ingest URL", () => { + const url = buildEnvelopeUrl(SELF_HOSTED_DSN); + expect(url).toContain("sentry.mycompany.com"); + expect(url).toContain("/api/7/envelope/"); + expect(url).toContain("sentry_key=pubkey99"); + }); + + test("invalid DSN → throws ValidationError", () => { + expect(() => buildEnvelopeUrl("not-a-dsn")).toThrow(ValidationError); + }); + + test("sentry_client does not have doubled version suffix", () => { + // SENTRY_CLIENT must be the bare name ('sentry-cli'), not 'sentry-cli/dev', + // because getEnvelopeEndpointWithUrlEncodedAuth appends / internally. + const url = buildEnvelopeUrl(SAAS_DSN); + expect(url).not.toContain("sentry-cli%2Fdev%2Fdev"); + expect(decodeURIComponent(url)).toContain("sentry_client=sentry-cli/"); + }); +}); + +// ── resolveDsn ──────────────────────────────────────────────────── + +describe("resolveDsn", () => { + const originalEnv = process.env.SENTRY_DSN; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.SENTRY_DSN; + } else { + process.env.SENTRY_DSN = originalEnv; + } + }); + + test("explicit --dsn flag takes priority over env", () => { + process.env.SENTRY_DSN = SELF_HOSTED_DSN; + const result = resolveDsn({ dsn: SAAS_DSN }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); + + test("SENTRY_DSN env var used when no flag", () => { + process.env.SENTRY_DSN = SAAS_DSN; + const result = resolveDsn({ dsn: undefined }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); + + test("returns undefined when neither flag nor env set", () => { + delete process.env.SENTRY_DSN; + const result = resolveDsn({ dsn: undefined }, "/tmp"); + expect(result).toBeUndefined(); + }); + + test("trims whitespace from --dsn flag", () => { + const result = resolveDsn({ dsn: ` ${SAAS_DSN} ` }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); + + test("trims whitespace from SENTRY_DSN env var", () => { + process.env.SENTRY_DSN = `\n${SAAS_DSN}\n`; + const result = resolveDsn({ dsn: undefined }, "/tmp"); + expect(result).toBe(SAAS_DSN); + }); +}); + +// ── sendEnvelopeRequest ─────────────────────────────────────────── + +describe("sendEnvelopeRequest", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("POSTs with correct Content-Type header", async () => { + let capturedRequest: Request | undefined; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedRequest = input as Request; + return new Response("{}", { status: 200 }); + }; + + await sendEnvelopeRequest( + SAAS_DSN, + '{"event_id":"abc"}\n{"type":"event","length":2}\n{}' + ); + + expect(capturedRequest).toBeDefined(); + expect(capturedRequest!.method).toBe("POST"); + expect(capturedRequest!.headers.get("Content-Type")).toBe( + "application/x-sentry-envelope" + ); + }); + + test("URL contains sentry_key and sentry_version", async () => { + let capturedUrl = ""; + globalThis.fetch = async (input: RequestInfo | URL) => { + capturedUrl = (input as Request).url; + return new Response("{}", { status: 200 }); + }; + + await sendEnvelopeRequest(SAAS_DSN, "body"); + + expect(capturedUrl).toContain("sentry_key=abc123"); + expect(capturedUrl).toContain("sentry_version=7"); + }); + + test("accepts Uint8Array body", async () => { + globalThis.fetch = async () => new Response("{}", { status: 200 }); + // should not throw + await expect( + sendEnvelopeRequest(SAAS_DSN, new TextEncoder().encode("bytes")) + ).resolves.toBeUndefined(); + }); + + test("non-2xx response throws ApiError", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "invalid DSN" }), { status: 403 }); + + await expect(sendEnvelopeRequest(SAAS_DSN, "body")).rejects.toBeInstanceOf( + ApiError + ); + }); + + test("400 response includes error detail in message", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ detail: "bad envelope" }), { + status: 400, + }); + + const err = await sendEnvelopeRequest(SAAS_DSN, "body").catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toContain("bad envelope"); + }); +});