diff --git a/.lore.md b/.lore.md index 026a0b936..c861dec04 100644 --- a/.lore.md +++ b/.lore.md @@ -52,6 +52,12 @@ * **Host-scoped token model: auth.host column + three-layer enforcement**: Host-scoped token model (schema v16): every token bound to issuing host via \`auth.host\` column, lazy-migrated from boot-env. Trust established ONLY via \`sentry auth login --url\` or shell-exported \`SENTRY\_HOST\`/\`SENTRY\_URL\` at boot — \`.sentryclirc\` URL never a trust source. Three enforcement layers: (1) \`applySentryUrlContext\` throws on URL-arg mismatch; (2) \`applySentryCliRcEnvShim\` throws on rc-url mismatch (auth login/logout bypass via \`skipUrlTrustCheck\`); (3) fetch-layer \`isRequestOriginTrusted\`. Region trust: in-process Set in \`db/regions.ts\`, auto-synced by \`setOrgRegion(s)\`. \`clearTrustedHostState\` must NOT clear login anchor (breaks IAP re-auth). \`HostScopeError\` has overloads \`(message)\` and \`(source, destinationUrl, tokenHost)\`. Test helpers: \`resetHostScopingState()\` bundles \`resetEnvTokenHostForTesting\` + \`resetLoginTrustAnchorForTesting\` + \`resetTrustedRegionUrlsForTesting\`. E2E: pass \`--url ${ctx.serverUrl}\` to \`auth login --token\`; \`SENTRY\_URL\` alone doesn't anchor. Multi-region tests need \`registerTrustedRegionUrls\`. + +* **InkUI teardown order — 6 steps, all try/catch, torndown guard prevents double-unmount**: \`InkUI.tearDown()\` must follow this order: (1) stop tip-rotation interval; (2) detach SIGINT listener + \`store.setRequestCancel(undefined)\`; (3) \`instance.clear()\`; (4) \`instance.unmount()\`; (5) restore alternate screen \`\x1b\[?1049l\`; (6) \`freshStdin.setRawMode(false)\` + \`.pause()\` + \`.destroy()\`. \`torndown: boolean\` guard prevents double-unmount (throws on some platforms). \`cancelRequested\` guard: second Ctrl+C → \`process.exit(130)\`. Every step wrapped in try/catch. + + +* **InkUI vs OpenTUI decision — pure JS wins over native binary cost**: Chose Ink over OpenTUI because OpenTUI added ~10.7 MB to the binary (libopentui.so + ~12k-line FFI bindings) and required alternate-screen buffer + post-dispose stderr replay. Ink is pure JS, writes incrementally to stdout so log lines land in scrollback. UI evolution: ClackUI → OpenTuiUI (PR 4) → InkUI (current). \`exitOnCtrlC: false\` routes Ctrl+C through prompt cancellation; \`patchConsole: false\` keeps \`console.\*\` flowing to real stdout (Sentry SDK breadcrumbs not swallowed). + * **isSentrySaasUrl vs isSaaSTrustOrigin: two intentional SaaS checks**: \`src/lib/sentry-urls.ts\` exports two SaaS-detection helpers with intentional split: (1) \`isSentrySaasUrl(url)\` — hostname-only check (\`sentry.io\` or \`\*.sentry.io\`), accepts any protocol/port. Used for routing/UX: custom-headers warning, \`getSentryBaseUrl\`/\`isSelfHosted\`, region resolution skip, telemetry \`is\_self\_hosted\` tag. (2) \`isSaaSTrustOrigin(url)\` — stricter: additionally requires \`https:\` and default port. Used for security decisions: token-host trust comparison, sentryclirc URL trust check, URL-arg trust, login refusal. Rule: hostname-only for routing/UX (don't break users behind TLS-terminating proxies with \`http://sentry.io\`); strict for credential scoping. JSDoc on \`isSentrySaasUrl\` points callers to \`isSaaSTrustOrigin\` for security contexts. Keep both implementations in sync re: hostname matching. @@ -70,6 +76,9 @@ * **safe-read.ts wraps isRegularFile + Bun.file().text() for FIFO-safe user-path reads**: \`src/lib/safe-read.ts\` \`safeReadFile(path, operation)\` combines \`isRegularFile()\` + file read + broad error swallow (FIFO/ENOENT/EACCES/EPERM/EISDIR/ENOTDIR). Do NOT use for committed config loads — swallows EPERM/EISDIR, making \`chmod 000 .sentryclirc\` manifest as confusing 'no auth token'. For loud permission surfacing, call \`fs.promises.stat\` directly, gate on \`isFile()\`, catch only ENOENT/EACCES. General rule: bare \`catch {}\` swallows \`EACCES\`/\`EPERM\`/\`EIO\` — always check \`(err as NodeJS.ErrnoException).code === 'ENOENT'\` and re-throw anything else. \`read-files.ts\`/\`workflow-inputs.ts\` use direct stat to reuse one stat for size-gating. Test with real \`mkfifo\` + short timeout as hang detector. + +* **SDK invoke path bypasses Stricli parsing — no defaults, no parsePeriod**: SDK invoke path bypasses Stricli parsing — no defaults, no parsePeriod: \`src/lib/sdk-invoke.ts\` \`buildInvoker()\` calls command \`func()\` directly with pre-built flags, skipping Stricli's \`parseInputsForFlag\`. Fix (implemented): \`resolveCommand()\` returns \`{ handler, flagDefs }\` (type \`ResolvedCommand\`); new \`applyFlagDefaults(flags, flagDefs)\` applies parsed defaults — for \`kind:'parsed'\` + string default + \`parse\` fn, calls \`flag.parse(flag.default)\` (catches errors → undefined); for other kinds returns raw \`def.default\`; skips already-set flags. Both streaming and capture call sites in \`buildInvoker\` call \`applyFlagDefaults\` before handler invocation. \`commandCache\` type updated to store \`{ loader, flagDefs }\`. Flag defs sourced from \`command.parameters?.flags ?? {}\` during route tree walk. \`issue list\` defines its own \`period\` flag inline (\`default: '90d'\`), not via shared \`LIST\_PERIOD\_FLAG\` (\`default: '7d'\`). + * **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. @@ -91,6 +100,9 @@ * **src/cli.ts: middleware chain, completion optimization, sensitive argv redaction**: \`src/cli.ts\` exports \`startCli()\`, \`runCli()\`, \`runCompletion()\`. Middleware chain (innermost-first): \`\[seerTrialMiddleware, autoAuthMiddleware]\` — auth is outermost. \`autoAuthMiddleware\` uses \`isatty(0)\` not \`process.stdin.isTTY\` (Bun returns undefined). \`runCompletion()\` sets \`SENTRY\_CLI\_NO\_TELEMETRY=1\` to skip \`@sentry/node-core\` lazy-require (~280ms). \`redactArgv()\` handles \`--flag=value\` and \`--flag \\` forms; \`SENSITIVE\_ARGV\_FLAGS\` includes \`token\` and \`auth-token\`. \`reportUnknownCommand()\` wrapped in try/catch — telemetry must never crash CLI. \`preloadProjectContext()\` calls \`captureEnvTokenHost()\` BEFORE any env mutation. + +* **stdin-reopen.ts: forwardFreshTtyToStdin() idempotency and isTTY backfill pattern**: \`src/lib/init/stdin-reopen.ts\` exports \`forwardFreshTtyToStdin(deps?)\` returning a \`Disposable\` (\`TtyForwardingHandle\`) — always non-null so callers use \`using tty = forwardFreshTtyToStdin()\` without null-checking. Idempotency: repeated calls return \`NOOP\_HANDLE\` (secondary callers don't tear down primary's install). isTTY backfill: captures \`previousIsTty\` before touching; if \`undefined\`, uses \`Object.defineProperty\` to set \`isTTY: true, writable: true, configurable: true\` — required because Ink/clack gates \`setRawMode(true)\` on \`input.isTTY\`, so without backfill the fresh fd stays in canonical mode. \`pause\`/\`resume\` replaced with noops to prevent Bun kqueue EINVAL on fd-0 transitions. \`TtyDeps\` allows injection of \`openTty\` and \`isTty\` for test isolation. + * **Zod schema on OutputConfig enables self-documenting JSON fields in help and SKILL.md**: Zod schema on OutputConfig enables self-documenting JSON fields: List commands register \`schema?: ZodType\` on \`OutputConfig\\`. \`extractSchemaFields()\` produces \`SchemaFieldInfo\[]\` from Zod shapes. \`buildFieldsFlag()\` enriches \`--fields\` brief; \`enrichDocsWithSchema()\` appends fields to \`fullDescription\`. Schema exposed as \`\_\_jsonSchema\` on built commands — \`introspect.ts\` reads it into \`CommandInfo.jsonFields\`, \`help.ts\` and \`generate-skill.ts\` render it. For \`buildOrgListCommand\`/\`dispatchOrgScopedList\`, pass \`schema\` via \`OrgListConfig\`. @@ -143,6 +155,9 @@ * **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\`. + +* **pnpm nested script invocation loses TTY — inline tsx to fix**: Trap: \`"cli": "pnpm tsx src/bin.ts"\` creates nested pnpm invocations (pnpm → /bin/sh → pnpm → /bin/sh → tsx → node). Each inner pnpm layer pipes stdio, so \`process.stdin.isTTY\` and \`process.stdout.isTTY\` are \`undefined\` in the final Node process. This breaks \`sentry init\` at three gates: \`isNonInteractiveContext()\` (init.ts:182), \`isInteractiveTerminal()\` (factory.ts:62), and the wizard preamble (wizard-runner.ts:376). Fix: inline tsx directly — \`"cli": "tsx --import ./script/require-shim.mjs src/bin.ts"\` and same for \`dev\`. Single-layer \`pnpm run\` uses \`stdio: 'inherit'\`; nested pnpm does not. Approach B (\`node --import tsx/esm ...\`) rejected as fragile (tsx internal API). Approach C (shell wrapper) rejected as non-portable. Keep the \`tsx\` alias for non-interactive scripts. + * **process.stdin.isTTY unreliable in Bun — use isatty(0) and backfill for clack**: \`process.stdin.isTTY\` unreliable — use \`isatty(0)\` from \`node:tty\`. Bun's single-file binary can leave \`process.stdin.isTTY === undefined\` on TTY fds. \`@clack/core\` gates \`setRawMode(true)\` on \`input.isTTY\`, silently disabling raw mode. Fix: backfill \`process.stdin.isTTY = true\` when \`isatty(0)\` confirms. Debugging: \`src/lib/init/tty-diagnostics.ts\` \`dumpTtyDiagnostics(label)\` — no-op unless \`SENTRY\_INIT\_DIAGNOSTICS=1\`. @@ -166,6 +181,9 @@ ### Pattern + +* **clack-utils.ts filename preserved intentionally — rename deferred to next cleanup PR**: \`src/lib/init/clack-utils.ts\` filename kept (not renamed to \`wizard-utils.ts\`) to keep PR 4 diff focused on clack removal. No clack references remain in the file. \`WizardCancelledError\` lives here. \`abortIfCancelled\()\` return type uses \`Exclude\\` to narrow union types. \`FEATURE\_DISPLAY\_ORDER\` and \`CANONICAL\_STEP\_ORDER\` (12 steps) also defined here. Rename is intentionally deferred. + * **CLI-1D3 Windows download visibility race: poll statSync with exponential backoff**: Windows upgrade download visibility race: \`waitForBinaryVisible\` in \`src/lib/upgrade.ts\` polls \`statSync\` with exponential backoff (6 attempts, 5 sleeps: 100+200+400+800+1600ms). Loop breaks BEFORE final sleep — \`VERIFY\_MAX\_ATTEMPTS=N\` yields N-1 sleeps (off-by-one trap). Covers Bun 1.3.9 race where \`Bun.file().writer().end()\` returns before OS surfaces file by path. \`isEnoentSpawnError()\` in \`src/commands/cli/upgrade.ts\` catches both \`code==='ENOENT'\` and Bun's path-string error → \`UpgradeError('execution\_failed')\`. Race-free tests: writer must poll until bad state exists, then overwrite. @@ -184,9 +202,15 @@ * **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. + +* **InkUI ink-app.js sidecar loading — three runtime contexts**: \`createInkUI()\` resolves \`inkAppPath\` differently per runtime: (1) Node SEA binary — \`sea.getAsset('dist-build/ink-app.js', 'utf-8')\`, write to \`mkdtempSync\`, import via \`pathToFileURL\`, then \`rmSync\` temp dir (best-effort); (2) Node/npm bundle — \`inkAppPath\` starts with \`'./'\`, resolve via \`new URL(inkAppPath, import.meta.url).href\`; (3) Dev mode — absolute filesystem path. Imported via \`with { type: 'file' }\` from \`./ink-app.tsx\`. See also \[\[019e4fe7-dbf1-7ed6-8b39-473e2e4ea29e]] for SEA temp file cleanup pattern. + * **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\`. + +* **sensitive argv flags must never reach telemetry — redactArgv() in cli.ts**: \`SENSITIVE\_ARGV\_FLAGS = new Set(\['token', 'auth-token'])\` in \`src/cli.ts\`. \`redactArgv()\` replaces values of these flags with \`\[REDACTED]\` before any telemetry call. This is an absolute invariant — never pass raw \`process.argv\` to telemetry without running through \`redactArgv()\` first. + * **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. @@ -202,8 +226,14 @@ * **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\`. + +* **wizard-runner.ts: large shared context via initialState, not inputData — D1 row size limit**: In \`wizard-runner.ts\`, large shared context (\`dirListing\`, \`fileCache\`, \`existingSentry\`) travels via \`initialState\` (not \`inputData\`) to avoid D1 per-row size overflow (see getsentry/cli-init-api#98). \`MAX\_RESUME\_RETRIES = 3\`, \`RETRY\_BACKOFF\_MS = \[2000, 4000, 8000]\`. \`resumeWithRetry()\` handles stale-step recovery via \`tryRecoverCurrentRunState()\` when \`isStepAlreadyAdvancedError()\` detects 'was not suspended' 500. + ### Preference + +* **Always add new check scripts to both package.json and CI workflow**: When introducing a new check or validation script, the user expects it to be registered in two places simultaneously: (1) as a named script in \`package.json\` alongside other \`check:\*\` scripts, and (2) as a \`- run: pnpm run \\` step in \`.github/workflows/ci.yml\`. Never add to only one location. This applies to any new linting, validation, or verification script added to the project. + * **Always check with user before taking irreversible or external actions**: When the user asks the assistant to perform actions that affect external systems (sending messages, merging PRs, deploying, etc.), they explicitly require confirmation before proceeding. The user states 'check with user before sending any messages' or similar directives. The assistant should always pause and present a plan or draft to the user for approval before executing any action that cannot be easily undone — such as sending communications, merging code, or triggering external workflows. This applies even when the user has asked the assistant to handle the task end-to-end. @@ -216,9 +246,15 @@ * **Always compare PR branch against main before reviewing changes**: When reviewing a PR, the user consistently wants to understand exactly what changed in the PR branch versus main before diving into the content. This means fetching the remote branch if not available locally, running \`git log main..origin/\\` to see commits, and \`git diff\` (with stat) to understand the scope of changes. The user explicitly frames this as needing to know 'what changes were made vs what actually exists on main.' Always establish this baseline diff context first before analyzing or discussing PR content. + +* **Always conduct thorough PR reviews with severity-classified findings**: PR review standards: (1) Compare branch vs main first (\`git log main..origin/\\`, \`git diff --stat\`). (2) Verify every PR description claim against actual source files at specific line numbers — never trust PR metadata. (3) Classify findings as BLOCKING vs NON-BLOCKING with file paths and line numbers. (4) Flag LLM-generated planning artifacts (e.g., DOCS-AUDIT.md) as blocking violations of repo conventions. (5) Investigate root causes — check bundle output, trace esbuild variable renaming, identify silent regressions. (6) Run relevant check scripts and grep codebase directly rather than reasoning from PR metadata. + * **Always create a dedicated branch when updating fossilize versions**: When a new version of fossilize is released, always create a branch named \`chore/fossilize-{version}\` tracking origin/main, update the dependency, remove any functionality now handled natively by fossilize (e.g., \`stripCachedNodeBinaries()\` removed in 0.7.0), verify the build succeeds, then commit with \`chore: update fossilize to X.Y.Z\`. Follow this exact pattern: branch → update dep → remove superseded code → build verify → commit → PR. + +* **Always document invariants and non-obvious design decisions as inline code comments or JSDoc**: When implementing functions, types, or test utilities with non-obvious invariants, the user consistently adds explicit inline comments or JSDoc explaining \*why\* a design choice was made — not just what it does. Examples: why a function always returns non-null, why a branch is intentionally omitted, why a specific API is used over an alternative, and what would break if the pattern changed. These comments are written as assertions ('Always returned (never null)...', 'Always restore — never delete.') and reference the downstream consumer or failure mode. When generating or modifying code, always include this kind of explanatory commentary for invariants, idempotency guarantees, and intentional omissions. + * **Always explore e2e test infrastructure thoroughly before debugging or modifying tests**: When approaching e2e test work, always explore the full infrastructure before making changes: \`test/e2e/\` (14 files: api, auth, bundle, completion, delta-upgrade, event, issue, library, log, multiregion, project, skill-eval, telemetry-exit, trace), \`test/fixture.ts\` (getCliCommand, runCli, createE2EContext), \`test/helpers.ts\` (useTestConfigDir, useEnvSandbox, resetHostScopingState, mintSntrysToken, extractFetchUrl), \`test/mocks/\` (server.ts, routes.js, multiregion.ts), \`src/bin.ts\`, \`src/cli.ts\`. Key: \`getCliCommand()\` returns \`\[SENTRY\_CLI\_BINARY]\` if set, else \`\[process.execPath, 'run', 'src/bin.ts']\`. \`createE2EContext.run()\` sets \`SENTRY\_AUTH\_TOKEN: ''\`, \`SENTRY\_TOKEN: ''\`, \`SENTRY\_CLI\_NO\_TELEMETRY: '1'\`. \`test:e2e\` runs without \`--isolate --parallel\`. Map full infrastructure before proposing fixes. @@ -237,6 +273,9 @@ * **Always investigate bundle resolution issues by inspecting minified variable names and esbuild's static analysis limitations**: When debugging 'Cannot find module' errors in bundled output, the user consistently digs into the root cause at the esbuild level: checking whether require calls use bare \`require()\` vs renamed aliases like \`\_require\`, inspecting minified bundle output for renamed variable patterns, and verifying that esbuild only statically resolves bare \`require()\` calls. The user expects the assistant to check the actual bundle contents (grep for minified names, count createRequire occurrences, verify no relative requires remain unresolved) rather than assuming the fix worked. The fix pattern is always: use bare \`require\` (not \`\_require\` or other aliases) for local relative imports so esbuild can inline them at bundle time. + +* **Always investigate root cause by tracing through multiple specific code layers before accepting a fix**: When facing a runtime bug (especially undefined values from framework internals), the user consistently demands thorough investigation across multiple layers — framework source code (node\_modules), wrapper utilities, bundler config, and call sites — before accepting any fix. The user explicitly rejects surface-level explanations and pushes for tracing the exact code path that produces the unexpected value. Only after exhausting the investigation does the user accept a defensive fix strategy. When directing investigation, the user specifies concrete areas to search (e.g., 4 specific code locations). Always read and analyze the relevant framework internals, not just application code. + * **Always investigate root causes before accepting PR fixes at face value**: When reviewing PRs, the user consistently digs past the stated fix to verify whether the implementation actually solves the root cause. They examine bundle output, run smoke tests, check CI logs, and trace failure modes (e.g., esbuild variable renaming, wrong \`createRequire\` anchor, silent runtime failures). They expect the reviewer/assistant to identify not just surface bugs but also latent/silent failures introduced by the fix. Reviews should include: confirming the fix works end-to-end, identifying any new failure modes introduced, and flagging silent regressions (e.g., features that appear to work but silently fall back or skip logic). @@ -285,9 +324,18 @@ * **Always stage all modified files before committing, not just already-staged ones**: When preparing to commit, the user reviews git status and expects ALL modified files to be staged together — not just files already in the index. If unstaged modified files exist alongside staged ones, the user treats this as an incomplete commit state that needs to be resolved before proceeding. The user reviews the full list of changed files (staged + unstaged) as a checklist against completed tasks, and expects the commit to encompass all related changes from the session as a single coherent unit. + +* **Always store plans as markdown files in the \`.opencode/plans\` directory with timestamp-prefixed filenames**: When working in plan mode, the user expects plans to be written to \`.opencode/plans/\` as markdown files. Filenames follow the pattern \`{timestamp}-{slug}.md\` (e.g., \`1779289703678-sentryclirc-migration.md\`). Some plans use descriptive slugs without timestamps (e.g., \`require-conventional-pr-title.md\`). Plans are created before implementation begins, and the assistant should call \`plan\_exit\` when done planning. Plans may be edited iteratively during the planning phase before switching to build mode. + + +* **Always switch from plan mode to build mode before executing changes**: The user consistently uses a two-phase workflow: first planning (read-only exploration, writing a plan file), then explicitly approving a switch to build/agent mode before any changes are executed. When the user approves the mode switch, the assistant should immediately begin executing the existing plan file — typically by re-reading the key files to be modified. Never execute changes while still in plan mode, even if the plan is complete and approved. Wait for the explicit mode-switch approval before acting. + * **Always track migration progress with explicit completion criteria and remaining blockers**: The Bun→Node migration is complete only when \`Bun.build({ compile: true })\` is replaced by fossilize in \`script/build.ts\`. As of the current session, \`script/build.ts\` already uses fossilize (\`--no-bundle\`, \`--out-dir dist-bin\`, \`--node-version lts\`) with esbuild for bundling — the migration is complete. NODE\_VERSION='lts' in build.ts. The user expects the assistant to track this state across sessions and confirm the migration is done. When resuming sessions, verify \`script/build.ts\` does not contain \`Bun.build({ compile: true })\` before declaring migration complete. + +* **Always track pre-existing failures separately from introduced regressions**: When running tests, the user consistently distinguishes between failures that existed before their changes and failures caused by their changes. They verify pre-existing failures by checking out main/stashing changes and confirming the same failures reproduce. Only new failures introduced by the current branch are treated as actionable. When reporting test results, always clarify which failures are pre-existing (with evidence) versus newly introduced, and never treat pre-existing failures as blockers for the current fix. + * **Always update dependencies promptly after releasing new versions**: When the user releases a new version of a tool they own (e.g., fossilize), they immediately update dependent projects to use that new version. This includes bumping the version in package files, creating a dedicated branch with a descriptive name (e.g., \`chore/tool-x.y.z\`), and opening a pull request. The commit message follows conventional commit format: \`chore: update \ to \ (\)\`. The assistant should proactively handle the full update workflow: fetch latest main, create the branch, update the dependency, commit, push, and open a PR. @@ -315,6 +363,9 @@ * **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. + +* **Follow the established git workflow (branch, PR, review)**: Behavioral pattern detected across 5 sessions (action: enforced-workflow). The user consistently demonstrates this behavior. + * **Never merge a PR if CI is failing**: NEVER merge a PR if CI is failing unless the user explicitly says to ignore specific failures in that session. This is an absolute directive repeated across 20+ sessions. diff --git a/src/lib/error-reporting.ts b/src/lib/error-reporting.ts index 7eea6c598..03cc87c28 100644 --- a/src/lib/error-reporting.ts +++ b/src/lib/error-reporting.ts @@ -21,14 +21,17 @@ import * as Sentry from "@sentry/node-core/light"; import { ApiError, AuthError, + CliError, ContextError, DeviceFlowError, + HostScopeError, OutputError, ResolutionError, SeerError, TimeoutError, UpgradeError, ValidationError, + WizardError, } from "./errors.js"; // --------------------------------------------------------------------------- @@ -101,6 +104,43 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void { // Grouping tags // --------------------------------------------------------------------------- +/** Endpoint normalization patterns — compiled once at module scope. */ +const ENDPOINT_PATTERNS: [RegExp, string][] = [ + [/\/organizations\/[^/]+/, "/organizations/{org}"], + [/\/projects\/[^/]+\/[^/]+/, "/projects/{org}/{project}"], + [/\/issues\/[^/]+/, "/issues/{id}"], + [/\/events\/[^/]+/, "/events/{id}"], + [/\/groups\/[^/]+/, "/groups/{id}"], + [/\/releases\/[^/]+/, "/releases/{version}"], + [/\/teams\/[^/]+\/[^/]+/, "/teams/{org}/{team}"], + [/\/dashboards\/[^/]+/, "/dashboards/{id}"], + [/\/customers\/[^/]+/, "/customers/{org}"], +]; + +/** + * Strip remaining bare numeric segments (e.g. /12345/) but preserve + * the API version prefix /0/ which is always the second segment. + */ +const BARE_NUMERIC_SEGMENT_RE = /(?<=\/api\/0\/.*)\/\d+(?=\/|$)/g; + +/** + * Normalize an API endpoint path by parameterizing variable segments. + * + * Replaces org slugs, project slugs, issue IDs, event IDs, and other + * entity identifiers with placeholders so that server-side fingerprint + * rules can sub-group `ApiError` by endpoint shape rather than exact path. + * + * `"/api/0/projects/my-org/my-project/events/abc123/"` → + * `"/api/0/projects/{org}/{project}/events/{id}/"` + */ +export function normalizeEndpoint(endpoint: string): string { + let result = endpoint; + for (const [pattern, replacement] of ENDPOINT_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result.replace(BARE_NUMERIC_SEGMENT_RE, "/{id}"); +} + /** * Strip quoted substrings, numeric/hex IDs, and org/project paths from a * resource string to produce a stable "kind" for grouping. @@ -111,14 +151,24 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void { * `"not found in neurio/installer-app"` → `"not found"` */ export function extractResourceKind(resource: string): string { - return resource - .replace(/'[^']*'/g, "") - .replace(/"[^"]*"/g, "") - .replace(/\b[0-9a-f]{16,32}\b/gi, "") - .replace(/\bin\s+[\w-]+\/[\w-]+/g, "") - .replace(/\b\d+\b/g, "") - .replace(/\s+/g, " ") - .trim(); + return ( + resource + .replace(/'[^']*'/g, "") + .replace(/"[^"]*"/g, "") + .replace(/\b[0-9a-f]{16,32}\b/gi, "") + .replace(/\bin\s+[\w-]+(?:\/[\w-]+)*/g, "") + // Strip hyphenated slugs after known entity names (e.g., "Organization my-company"). + // Requires at least one hyphen to avoid stripping English words ("Project not found"). + // Safe for current callers: resource values with slugs use quotes (stripped above), + // and headline values don't start with entity names. + .replace( + /\b(Organization|Dashboard|Dashboards|Project|Team)\s+[\w][\w-]*-[\w-]*/gi, + "$1" + ) + .replace(/\b\d+\b/g, "") + .replace(/\s+/g, " ") + .trim() + ); } /** @@ -140,6 +190,64 @@ export function extractMessagePrefix(message: string, maxWords = 3): string { .join(" "); } +/** + * Derive a stable `cli_error.kind` grouping key from an error instance. + * + * Returns `undefined` when the error is not a recognized CLI error class + * (the caller should still set `cli_error.class` for basic grouping). + */ +function deriveErrorKind(error: Error): string | undefined { + if (error instanceof ContextError) { + return error.resource; + } + if (error instanceof ResolutionError) { + return ( + extractResourceKind(error.resource) + + " " + + extractResourceKind(error.headline) + ); + } + // Fall back to the first few words of the message when no field is set + // (e.g. validateHexId throws with no `field`, so using field would + // collapse every unfielded ValidationError into one group). + if (error instanceof ValidationError) { + return error.field ?? extractMessagePrefix(error.message); + } + if (error instanceof ApiError) { + return String(error.status); + } + if (error instanceof SeerError) { + return error.reason; + } + if (error instanceof AuthError) { + return error.reason; + } + if (error instanceof UpgradeError) { + return error.reason; + } + if (error instanceof DeviceFlowError) { + return error.code; + } + if (error instanceof TimeoutError) { + return "timeout"; + } + if (error instanceof HostScopeError) { + return "host_scope"; + } + if (error instanceof WizardError) { + return "wizard"; + } + // Catch-all for bare CliError — must be checked AFTER all subclasses + // because instanceof matches the entire prototype chain. + // ConfigError and OutputError intentionally fall through here: + // ConfigError has no structured field beyond message; OutputError is + // silenced by classifySilenced() before reaching deriveErrorKind(). + if (error instanceof CliError) { + return extractMessagePrefix(error.message, 4); + } + return; +} + /** * Set `cli_error.*` tags on a Sentry scope for an error that will be * captured. These tags are matched by server-side fingerprint rules to @@ -149,6 +257,7 @@ export function extractMessagePrefix(message: string, maxWords = 3): string { * - `cli_error.class` — error class name (e.g. `"ContextError"`) * - `cli_error.kind` — stable grouping key derived from structured fields * - `cli_error.api_status` — HTTP status (ApiError only) + * - `cli_error.api_endpoint` — normalized API path (ApiError only) */ function setGroupingTags(scope: Sentry.Scope, error: unknown): void { if (!(error instanceof Error)) { @@ -157,36 +266,16 @@ function setGroupingTags(scope: Sentry.Scope, error: unknown): void { scope.setTag("cli_error.class", error.name); - if (error instanceof ContextError) { - scope.setTag("cli_error.kind", error.resource); - } else if (error instanceof ResolutionError) { - scope.setTag( - "cli_error.kind", - extractResourceKind(error.resource) + - " " + - extractResourceKind(error.headline) - ); - } else if (error instanceof ValidationError) { - // Fall back to the first few words of the message when no field is set - // (e.g. validateHexId throws with no `field`, so using field would - // collapse every unfielded ValidationError into one group). - scope.setTag( - "cli_error.kind", - error.field ?? extractMessagePrefix(error.message) - ); - } else if (error instanceof ApiError) { + const kind = deriveErrorKind(error); + if (kind !== undefined) { + scope.setTag("cli_error.kind", kind); + } + + if (error instanceof ApiError) { scope.setTag("cli_error.api_status", String(error.status)); - scope.setTag("cli_error.kind", String(error.status)); - } else if (error instanceof SeerError) { - scope.setTag("cli_error.kind", error.reason); - } else if (error instanceof AuthError) { - scope.setTag("cli_error.kind", error.reason); - } else if (error instanceof UpgradeError) { - scope.setTag("cli_error.kind", error.reason); - } else if (error instanceof DeviceFlowError) { - scope.setTag("cli_error.kind", error.code); - } else if (error instanceof TimeoutError) { - scope.setTag("cli_error.kind", "timeout"); + if (error.endpoint) { + scope.setTag("cli_error.api_endpoint", normalizeEndpoint(error.endpoint)); + } } } @@ -273,5 +362,12 @@ export function enrichEventWithGroupingTags( event.tags = event.tags ?? {}; event.tags["cli_error.class"] = exc.type; + // Set kind from exception message prefix so server-side rules can group + // non-CliError exceptions (TypeError, Error, WizardCancelledError, etc.) + // that bypass reportCliError (uncaught exceptions, unhandled rejections). + if (exc.value) { + event.tags["cli_error.kind"] = extractMessagePrefix(exc.value, 4); + } + return event; } diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index ab5fb08ca..5876fa2d7 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -313,6 +313,35 @@ export function isEpipeError(event: Sentry.ErrorEvent): boolean { return false; } +/** + * Detect EBADF (bad file descriptor) errors in Sentry events. + * + * These occur when the init wizard's stdin reopen (`stdin-reopen.ts`) queues + * reads on a destroyed file descriptor. Same class of OS-level noise as EPIPE + * — not actionable, just different fd numbers producing duplicate issues. + * + * @internal Exported for testing + */ +export function isEbadfError(event: Sentry.ErrorEvent): boolean { + const exceptions = event.exception?.values; + if (exceptions) { + for (const ex of exceptions) { + if (ex.value?.includes("EBADF")) { + return true; + } + } + } + + const systemError = event.contexts?.node_system_error as + | { code?: string } + | undefined; + if (systemError?.code === "EBADF") { + return true; + } + + return false; +} + /** * Check if an error is a user-caused (401–499) API error. * @@ -612,6 +641,13 @@ export function initSentry( return null; } + // EBADF errors come from the init wizard's stdin reopen queuing reads + // on a destroyed fd. Same class of OS-level noise as EPIPE — different + // fd numbers just produce duplicate issues. Not actionable — drop them. + if (isEbadfError(event)) { + return null; + } + // Normalize relative frame paths to absolute. Bun's compiled binaries // with sourcemap: "linked" produce relative paths like "dist-bin/bin.js" // in Error.stack. Sentry's symbolicator only matches absolute paths diff --git a/test/lib/error-reporting.property.test.ts b/test/lib/error-reporting.property.test.ts index 2da0426df..9b4a740c9 100644 --- a/test/lib/error-reporting.property.test.ts +++ b/test/lib/error-reporting.property.test.ts @@ -25,6 +25,9 @@ const slugArb = array( .map((chars) => chars.join("")) .filter((s) => !(s.startsWith("-") || s.endsWith("-")) && s.length > 0); +/** Slug that contains at least one hyphen (matches the entity-name strip regex). */ +const hyphenatedSlugArb = slugArb.filter((s) => s.includes("-")); + /** 32-character lowercase hex id (trace/event/log id). */ const hexIdArb = stringMatching(/^[0-9a-f]{32}$/).filter( (s) => s.length === 32 @@ -130,4 +133,45 @@ describe("extractResourceKind — property tests", () => { { numRuns: DEFAULT_NUM_RUNS } ); }); + + test("bare slug after 'in' (no slash) is stripped for any slug", () => { + fcAssert( + property(slugArb, slugArb, (a, b) => { + expect(extractResourceKind(`not found in ${a}`)).toBe( + extractResourceKind(`not found in ${b}`) + ); + expect(extractResourceKind(`not found in ${a}`)).toBe("not found"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("hyphenated slug after entity name is stripped for any slug", () => { + fcAssert( + property(hyphenatedSlugArb, hyphenatedSlugArb, (a, b) => { + expect(extractResourceKind(`Organization ${a}`)).toBe( + extractResourceKind(`Organization ${b}`) + ); + expect(extractResourceKind(`Organization ${a}`)).toBe("Organization"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("Dashboard with numeric ID and slug produces same kind", () => { + fcAssert( + property( + numericIdArb, + slugArb, + numericIdArb, + slugArb, + (n1, s1, n2, s2) => { + expect(extractResourceKind(`Dashboard ${n1} in ${s1}`)).toBe( + extractResourceKind(`Dashboard ${n2} in ${s2}`) + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); }); diff --git a/test/lib/error-reporting.test.ts b/test/lib/error-reporting.test.ts index 9cc34deb9..731d3c072 100644 --- a/test/lib/error-reporting.test.ts +++ b/test/lib/error-reporting.test.ts @@ -16,17 +16,21 @@ import { enrichEventWithGroupingTags, extractMessagePrefix, extractResourceKind, + normalizeEndpoint, reportCliError, } from "../../src/lib/error-reporting.js"; import { ApiError, AuthError, + CliError, ConfigError, ContextError, + HostScopeError, OutputError, ResolutionError, SeerError, ValidationError, + WizardError, } from "../../src/lib/errors.js"; // --------------------------------------------------------------------------- @@ -37,12 +41,12 @@ describe("extractResourceKind", () => { test("strips single-quoted user data", () => { expect( extractResourceKind("Project 'api-track' not found in organization 'foo'") - ).toBe("Project not found in organization"); + ).toBe("Project not found"); }); test("strips double-quoted user data", () => { expect(extractResourceKind('Event "abc123" not found in org "foo"')).toBe( - "Event not found in org" + "Event not found" ); }); @@ -75,10 +79,23 @@ describe("extractResourceKind", () => { ); }); - test("does not strip 'in' without org/project path", () => { - expect(extractResourceKind("not found in organization")).toBe( - "not found in organization" - ); + test("strips 'in ' without org/project slash", () => { + expect(extractResourceKind("not found in organization")).toBe("not found"); + expect(extractResourceKind("not found in my-org")).toBe("not found"); + }); + + test("strips bare slugs after known entity names", () => { + expect(extractResourceKind("Organization my-company")).toBe("Organization"); + expect(extractResourceKind("Dashboard my-dash-123")).toBe("Dashboard"); + expect(extractResourceKind("Dashboards in my-org")).toBe("Dashboards"); + expect(extractResourceKind("Team backend-team")).toBe("Team"); + }); + + test("strips 'in ' combined with entity names and numeric IDs", () => { + expect(extractResourceKind("Dashboard 42 in my-org")).toBe("Dashboard"); + expect( + extractResourceKind("Organization my-org not found or has no dashboards") + ).toBe("Organization not found or has no dashboards"); }); test("handles empty input", () => { @@ -130,6 +147,64 @@ describe("extractMessagePrefix", () => { }); }); +// --------------------------------------------------------------------------- +// normalizeEndpoint +// --------------------------------------------------------------------------- + +describe("normalizeEndpoint", () => { + test("parameterizes org slug in organizations path", () => { + expect(normalizeEndpoint("/api/0/organizations/my-org/issues/")).toBe( + "/api/0/organizations/{org}/issues/" + ); + }); + + test("parameterizes org and project in projects path", () => { + expect( + normalizeEndpoint("/api/0/projects/my-org/my-project/events/abc123/") + ).toBe("/api/0/projects/{org}/{project}/events/{id}/"); + }); + + test("parameterizes issue, event, group, release IDs", () => { + expect(normalizeEndpoint("/api/0/issues/12345/")).toBe( + "/api/0/issues/{id}/" + ); + expect(normalizeEndpoint("/api/0/groups/99/events/abc/")).toBe( + "/api/0/groups/{id}/events/{id}/" + ); + expect(normalizeEndpoint("/api/0/releases/1.0.0/")).toBe( + "/api/0/releases/{version}/" + ); + }); + + test("parameterizes teams path", () => { + expect(normalizeEndpoint("/api/0/teams/my-org/backend/")).toBe( + "/api/0/teams/{org}/{team}/" + ); + }); + + test("parameterizes dashboards path", () => { + expect(normalizeEndpoint("/api/0/dashboards/42/")).toBe( + "/api/0/dashboards/{id}/" + ); + }); + + test("parameterizes customers path", () => { + expect(normalizeEndpoint("/api/0/customers/my-org/")).toBe( + "/api/0/customers/{org}/" + ); + }); + + test("parameterizes bare numeric segments", () => { + expect(normalizeEndpoint("/api/0/some/123/thing/456")).toBe( + "/api/0/some/{id}/thing/{id}" + ); + }); + + test("leaves paths without variable segments unchanged", () => { + expect(normalizeEndpoint("/api/0/auth/")).toBe("/api/0/auth/"); + }); +}); + // --------------------------------------------------------------------------- // classifySilenced // --------------------------------------------------------------------------- @@ -227,6 +302,54 @@ describe("enrichEventWithGroupingTags", () => { const result = enrichEventWithGroupingTags(event); expect(result.tags).toBeUndefined(); }); + + test("sets cli_error.kind from exception value", () => { + const event = makeEvent("TypeError"); + event.exception!.values![0]!.value = + "Cannot read properties of undefined (reading 'replaceAll')"; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("Cannot read properties of"); + }); + + test("sets cli_error.kind for fetch failed TypeError", () => { + const event = makeEvent("TypeError"); + event.exception!.values![0]!.value = "fetch failed"; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("fetch failed"); + }); + + test("sets cli_error.kind for Error with variable project count", () => { + const event1 = makeEvent("Error"); + event1.exception!.values![0]!.value = + "Failed to fetch issues from 3 project(s): Failed to list issues: 400 Bad Request"; + const event2 = makeEvent("Error"); + event2.exception!.values![0]!.value = + "Failed to fetch issues from 1 project(s): Failed to list issues: 400 Bad Request"; + const result1 = enrichEventWithGroupingTags(event1); + const result2 = enrichEventWithGroupingTags(event2); + expect(result1.tags?.["cli_error.kind"]).toBe( + result2.tags?.["cli_error.kind"] + ); + }); + + test("does not set cli_error.kind when value is missing", () => { + const event = { + exception: { values: [{ type: "Error" }] }, + } as Sentry.ErrorEvent; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.class"]).toBe("Error"); + expect(result.tags?.["cli_error.kind"]).toBeUndefined(); + }); + + test("does not override existing tags from reportCliError", () => { + const event = makeEvent("ContextError", { + "cli_error.class": "ContextError", + "cli_error.kind": "Organization", + }); + event.exception!.values![0]!.value = "some different message"; + const result = enrichEventWithGroupingTags(event); + expect(result.tags?.["cli_error.kind"]).toBe("Organization"); + }); }); // --------------------------------------------------------------------------- @@ -345,9 +468,59 @@ describe("reportCliError integration", () => { expect(captureSpy).toHaveBeenCalled(); }); - test("captures ApiError(400)", () => { - reportCliError(new ApiError("failed", 400, undefined, "/api/0/foo/")); - expect(captureSpy).toHaveBeenCalled(); + test("captures ApiError(400) with normalized endpoint tag", () => { + const err = new ApiError( + "failed", + 400, + undefined, + "/api/0/organizations/my-org/issues/" + ); + const { tags } = capturedScopeTags(err); + expect(tags["cli_error.api_status"]).toBe("400"); + expect(tags["cli_error.kind"]).toBe("400"); + expect(tags["cli_error.api_endpoint"]).toBe( + "/api/0/organizations/{org}/issues/" + ); + }); + + test("ApiError without endpoint does not set api_endpoint tag", () => { + const { tags } = capturedScopeTags(new ApiError("failed", 500)); + expect(tags["cli_error.api_status"]).toBe("500"); + expect(tags["cli_error.api_endpoint"]).toBeUndefined(); + }); + + test("HostScopeError gets kind=host_scope", () => { + const { tags } = capturedScopeTags( + new HostScopeError("URL argument", "https://other.sentry.io", "sentry.io") + ); + expect(tags["cli_error.class"]).toBe("HostScopeError"); + expect(tags["cli_error.kind"]).toBe("host_scope"); + }); + + test("WizardError gets kind=wizard", () => { + const { tags } = capturedScopeTags( + new WizardError("Workflow returned an error") + ); + expect(tags["cli_error.class"]).toBe("WizardError"); + expect(tags["cli_error.kind"]).toBe("wizard"); + }); + + test("bare CliError gets kind from message prefix", () => { + const { tags } = capturedScopeTags( + new CliError("Failed to create project 'my-app' in my-org.") + ); + expect(tags["cli_error.class"]).toBe("CliError"); + expect(tags["cli_error.kind"]).toBe("Failed to create project"); + }); + + test("bare CliError kind is stable across different user inputs", () => { + const a = capturedScopeTags( + new CliError("Failed to create project 'app-a' in org-a.") + ).tags; + const b = capturedScopeTags( + new CliError("Failed to create project 'app-b' in org-b.") + ).tags; + expect(a["cli_error.kind"]).toBe(b["cli_error.kind"]); }); test.each([ diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index b4026fe49..8d1321361 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -23,6 +23,8 @@ import { createTracedDatabase, getSentryTracePropagationTargets, initSentry, + isEbadfError, + isEpipeError, isUserApiError, recordApiErrorOnSpan, resetReadonlyWarning, @@ -364,6 +366,69 @@ describe("isUserApiError", () => { }); }); +describe("isEpipeError", () => { + test("detects EPIPE in exception message", () => { + const event = { + exception: { values: [{ value: "write EPIPE" }] }, + } as Sentry.ErrorEvent; + expect(isEpipeError(event)).toBe(true); + }); + + test("detects EPIPE in node_system_error context", () => { + const event = { + contexts: { node_system_error: { code: "EPIPE" } }, + } as Sentry.ErrorEvent; + expect(isEpipeError(event)).toBe(true); + }); + + test("returns false for non-EPIPE errors", () => { + const event = { + exception: { values: [{ value: "something else" }] }, + } as Sentry.ErrorEvent; + expect(isEpipeError(event)).toBe(false); + }); +}); + +describe("isEbadfError", () => { + test("detects EBADF in exception message", () => { + const event = { + exception: { + values: [ + { value: "EBADF: bad file descriptor, scandir '//dev/fd/22'" }, + ], + }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(true); + }); + + test("detects EBADF in Bun-style message", () => { + const event = { + exception: { + values: [{ value: "EBADF: bad file descriptor, stat '//dev/fd/10'" }], + }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(true); + }); + + test("detects EBADF in node_system_error context", () => { + const event = { + contexts: { node_system_error: { code: "EBADF" } }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(true); + }); + + test("returns false for non-EBADF errors", () => { + const event = { + exception: { values: [{ value: "EPIPE: broken pipe" }] }, + } as Sentry.ErrorEvent; + expect(isEbadfError(event)).toBe(false); + }); + + test("returns false for events without exceptions or contexts", () => { + expect(isEbadfError({} as Sentry.ErrorEvent)).toBe(false); + }); +}); + describe("recordApiErrorOnSpan", () => { function createMockSpan() { const attributes: Record = {};