diff --git a/AGENTS.md b/AGENTS.md index 4df3a3adb..4d64f1eac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -984,101 +984,93 @@ mock.module("./some-module", () => ({ ### Architecture - -* **Centralized search query sanitization in src/lib/search-query.ts**: \`sanitizeQuery()\` in \`src/lib/search-query.ts\` is the Stricli \`parse\` function on all 7 \`--query\` flags (issue list, trace list, span list, log list, trace logs, event list, issue events). Uses pre-compiled PEG parser \[\[019d917d-cf52-7e98-b3db-b25979d445f2]] for structural classification, then walks typed AST nodes for OR→in-list rewriting. Dashboard \`widget add/edit\` excluded (their \`--query\` means aggregate expressions). Also exports \`SEARCH\_SYNTAX\_REFERENCE\` for empty JSON envelopes. Serialization via \`serializeNode()\` reconstructs query strings from AST nodes — unchanged nodes preserve original text, merged nodes build \`key:\[val1,val2]\` format. + +* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file. - -* **Dashboard widget interval computed from terminal width and layout before API calls**: Dashboard widget interval from terminal width: \`computeOptimalInterval()\` in \`src/lib/api/dashboards.ts\` calculates chart interval before API calls. Formula: \`colWidth = floor(layout.w / 6 \* termWidth)\`, \`chartWidth = colWidth - 4 - gutterW\`, \`idealSeconds = periodSeconds / chartWidth\`. Snaps to nearest Sentry bucket (1m–1d). \`periodToSeconds()\` parses \`"24h"\`, \`"7d"\` etc. \`queryWidgetTimeseries\` uses \`params.interval ?? widget.interval\`. + +* **Bun compiled binary sourcemap options and size impact**: Binary build (\`script/build.ts\`) is a two-step process: (1) esbuild bundles TS → single minified JS + external \`.map\` (switched from \`Bun.build()\` due to identifier collision bug oven-sh/bun#14585). esbuild config: \`platform: "node"\`, \`format: "esm"\`, \`external: \["bun:\*"]\`, \`target: "esnext"\`. \`mkdirSync("dist-bin")\` required since esbuild doesn't auto-create output dirs. (2) \`injectDebugId()\` injects debug IDs, \`uploadSourcemaps()\` uploads to Sentry with URL prefix \`~/$bunfs/root/\`. (3) \`Bun.build()\` with \`compile: true\` produces native binaries — \`minify: false\` to avoid double-minification. Sourcemap never embedded; uploaded then deleted. Bun binaries use \`/$bunfs/root/bin.js\` as virtual path. \`binpunch\` hole-punches unused ICU data. esbuild produces 27k+ names in sourcemaps vs Bun's empty names array — critical for Sentry stack trace resolution. - -* **DSN org prefix normalization in arg-parsing.ts**: DSN org prefix normalization — four code paths: (1) \`extractOrgIdFromHost\` strips \`o\` prefix during DSN parsing. (2) \`stripDsnOrgPrefix()\` handles user-typed \`o1081365/\` in \`parseOrgProjectArg()\`. (3) \`normalizeNumericOrg()\` in \`resolve-target.ts\` resolves bare numeric IDs via DB cache or uncached API call. (4) Dashboard's \`resolveOrgFromTarget()\` pipes through \`resolveEffectiveOrg()\`. Critical: many API endpoints reject numeric org IDs with 404/403 — always normalize to slugs before API calls. + +* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. - -* **GHCR versioned nightly tags for delta upgrade support**: GHCR nightly with delta upgrades: Three tag types: \`:nightly\` (rolling), \`:nightly-\\` (immutable), \`:patch-\\` (delta). Delta patches use TRDIFF10 (zstd-compressed), ~50KB vs ~29MB full. Client via \`Bun.zstdDecompressSync()\`. N-1 only, full fallback, SHA-256 verify, 60% size threshold. npm/Node excluded. Test: \`mockGhcrNightlyVersion()\`. + +* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. - -* **Issue list auto-pagination beyond API's 100-item cap**: Sentry API silently caps \`limit\` at 100 per request. \`listIssuesAllPages()\` auto-paginates using Link headers, bounded by MAX\_PAGINATION\_PAGES (50). \`API\_MAX\_PER\_PAGE\` constant is shared across all paginated consumers. \`--limit\` means total results everywhere (max 1000, default 25). Org-all mode uses \`fetchOrgAllIssues()\`; explicit \`--cursor\` does single-page fetch to preserve cursor chain. + +* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTL). \`validateChainStep\` returns a discriminated union \`ChainStepResult\` with typed failure reasons (\`version-mismatch\`, \`missing-layer\`, \`size-exceeded\`) for debug logging. CI tag filtering uses \`grep '^nightly-\[0-9]'\` to exclude non-versioned tags. - -* **resolveProjectBySlug carries full projectData to avoid redundant getProject calls**: resolveProjectBySlug carries full projectData to skip redundant API calls: Returns \`{ org, project, projectData: SentryProject }\` from \`findProjectsBySlug()\`. \`ResolvedOrgProject\`/\`ResolvedTarget\` have optional \`projectData?\` (populated only in project-search path). Downstream commands use \`resolved.projectData ?? await getProject(org, project)\` to save ~500-800ms. + +* **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. - -* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: Self-hosted OAuth requires Sentry 26.1.0+ with \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\` env vars. Users must create a public OAuth app in Settings → Developer Settings. Client ID NOT optional for self-hosted. Fallback: \`sentry auth login --token\`. \`getSentryUrl()\`/\`getClientId()\` read lazily so URL parsing from arguments can set \`SENTRY\_URL\` after import. + +* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only. - -* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`. + +* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Auth retry goes through full chain. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode. - -* **Sentry dashboard API rejects discover/transaction-like widget types — use spans**: Sentry API dataset gotchas — valid values and deprecated types: (1) Events/Explore API accepts: \`spans\`, \`transactions\`, \`logs\`, \`errors\`, \`discover\`. \`spansIndexed\` is INVALID (returns unhelpful 500). Valid list in \`EVENTS\_API\_DATASETS\` constant. (2) Dashboard \`widgetType\`: \`discover\` and \`transaction-like\` rejected as deprecated — use \`spans\`. \`WIDGET\_TYPES\` (active) vs \`ALL\_WIDGET\_TYPES\` (includes deprecated for parsing). \`validateWidgetEnums()\` rejects deprecated for creation. Tests must use \`error-events\` not \`discover\`. (3) \`sort\` param only on \`spans\` dataset — guard with \`dataset === 'spans'\`. (4) \`tracemetrics\` uses comma-separated aggregates; only line/area/bar/table/big\_number displays. + +* **SQLite DB functions are synchronous — async signatures are historical artifacts**: All \`src/lib/db/\` functions do synchronous SQLite operations (both \`bun:sqlite\` and the \`node:sqlite\` polyfill's \`DatabaseSync\` are sync). Many functions still have \`async\` signatures — this is a historical artifact from PR #89 which migrated config storage from JSON files (using async \`Bun.file().text()\` / \`Bun.write()\`) to SQLite. The function signatures were preserved to minimize diff size and never cleaned up. These can safely be converted to synchronous. Exceptions that ARE legitimately async: \`clearAuth()\` (cache dir cleanup), \`getCachedDetection()\`/\`getCachedProjectRoot()\`/\`setCachedProjectRoot()\` (stat for mtime), \`refreshToken()\`/\`performTokenRefresh()\` (HTTP calls). - -* **Sentry issue stats field: time-series controlled by groupStatsPeriod**: Issue stats and list layout: \`stats\` depends on \`groupStatsPeriod\` (\`""\`, \`"14d"\`, \`"24h"\`, \`"auto"\`). Critical: \`count\` is period-scoped — use \`lifetime.count\` for true total. \`--compact\` is tri-state (\`optional: true\`): explicit overrides, \`undefined\` triggers \`shouldAutoCompact(rowCount)\` — compact if \`3N + 3 > termHeight\`. TREND column hidden < 100 cols. Stricli boolean flags with \`optional: true\` produce \`boolean | undefined\` enabling this auto-detect pattern. - - -* **Sentry search in-list syntax constraints from PEG grammar**: Search query parsing uses a pre-compiled PEG grammar (\`script/search-query.pegjs\`, ~120 lines) instead of regex tokenization. Generated parser at \`src/generated/search-parser.js\` (~31KB), built by \`script/generate-parser.ts\` via Peggy. Grammar nodes: \`text\_filter\`, \`comparison\_filter\`, \`text\_in\_filter\`, \`boolean\_op\`, \`free\_text\`, \`paren\_group\`. Key gotcha: \`free\_text\` must match whole words to prevent "error" splitting at "or". \`is:\`/\`has:\` detected post-parse in TypeScript. Paren groups are opaque — OR inside them cannot be rewritten and must throw \`ValidationError\`. AND inside parens passes through (can't strip from opaque \`raw\` text). \`hasOrInParenGroups()\` scans recursively for OR only in paren groups. The \`generate:parser\` script runs before \`generate:sdk\` in all build chains. Grammar lives in \`script/\` (build input, not runtime) — generated files gitignored under \`src/generated/\`. - - -* **SKILL.md is fully generated — edit source files, not output**: SKILL.md is fully generated — edit sources not output: Skill files under \`plugins/sentry-cli/skills/sentry-cli/\` generated by \`bun run generate:skill\`. CI auto-commits. Edit sources: (1) \`docs/src/content/docs/agent-guidance.md\` for SKILL.md content, (2) \`src/commands/\*/\` flag \`brief\` strings for reference descriptions, (3) \`docs/src/content/docs/commands/\*.md\` for examples. \`bun run check:skill\` fails if stale. - - -* **Stricli route errors are uninterceptable — only post-run detection works**: Stricli error propagation gaps and fuzzy matching: (1) Route failures uninterceptable — Stricli writes stderr, returns \`ExitCode.UnknownCommand\` (-5 / 251 in Bun). Only post-\`run()\` \`process.exitCode\` check works. (2) \`OutputError\` calls \`process.exit()\` immediately, bypassing telemetry. (3) \`defaultCommand: 'help'\` bypasses built-in fuzzy matching for top-level typos — fixed by \`resolveCommandPath()\` in \`introspect.ts\` with \`fuzzyMatch()\` suggestions (up to 3). JSON includes \`suggestions\` array. (4) Plural alias detection in \`app.ts\`. +### Decision - -* **Three Sentry APIs for span custom attributes with different capabilities**: Three Sentry span APIs with different capabilities: (1) \`/trace/{traceId}/\` — hierarchical tree; \`additional\_attributes\` enumerates requested attrs; returns \`measurements\` (zero-filled on non-browser, stripped by \`filterSpanMeasurements()\`). (2) \`/projects/{org}/{project}/trace-items/{itemId}/\` — single span full detail; ALL attributes as \`{name,type,value}\[]\` automatically. (3) \`/events/?dataset=spans\&field=X\` — list/search; explicit \`field\` params. \`--fields\` flag has dual role in span list: filters JSON output AND requests extra API fields via \`extractExtraApiFields()\`. \`FIELD\_GROUP\_ALIASES\` supports shorthand expansion. + +* **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. - -* **withAuthGuard returns discriminated Result type, not fallback+onError**: \`withAuthGuard\(fn)\` in \`src/lib/errors.ts\` returns a discriminated Result: \`{ ok: true, value: T } | { ok: false, error: unknown }\`. AuthErrors always re-throw (triggers bin.ts auto-login). All other errors are captured. Callers inspect \`result.ok\` to degrade gracefully. Used across 12+ files. + +* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. -### Decision +### Gotcha - -* **400 Bad Request from Sentry API indicates a CLI bug, not a user error**: Telemetry error capture — 400 convention: 400 = CLI bug (capture to Sentry), 401-499 = user error (skip). \`isUserApiError()\` uses \`> 400\` (exclusive). \`isExpectedUserError()\` guard in \`app.ts\` skips ContextError, ResolutionError, ValidationError, SeerError, 401-499 ApiErrors. Keep capturing 400, 5xx, unknown. Skipped errors recorded as breadcrumbs. For \`ApiError\`, call \`Sentry.setContext('api\_error', {...})\` before \`captureException\` — SDK doesn't auto-capture custom properties. + +* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime. - -* **CLI UX philosophy: auto-recover when intent is clear, warn gently**: Core UX principle: don't fail or educate users with errors if their intent is clear. Do the intent and gently nudge them via \`log.warn()\` to stderr. Keep errors in Sentry telemetry for UX visibility and product decisions (e.g., SeerError kept for demand/upsell tracking). Two recovery tiers: (1) auto-correct when semantics are identical (AND→space), (2) auto-recover with warning when match is possible but semantics differ (OR→space, with explicit warning about union→intersection change). Only throw helpful errors when intent genuinely can't be fulfilled at all. Model after \`gh\` CLI conventions. AI agents are primary consumers of these auto-recoveries — they construct natural queries with OR/AND. + +* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` - -* **Trace-related commands must handle project consistently across CLI**: Trace/log commands and project scoping: \`getDetailedTrace\` accepts optional numeric \`projectId\` (not hardcoded \`-1\`). Resolve slug→ID via \`getProject()\`. \`formatSimpleSpanTree\` shows orphan annotation only when \`projectFiltered\` is set. \`buildProjectQuery()\` in \`arg-parsing.ts\` prepends \`project:\\` to queries (used by \`trace/logs.ts\`, \`log/list.ts\`). Multi-project: \`--query 'project:\[cli,backend]'\`. Trace-logs endpoint (\`/organizations/{org}/trace-logs/\`) is org-scoped — uses \`resolveOrg()\` not \`resolveOrgAndProject()\`. Endpoint is PRIVATE (no \`@sentry/api\` types); hand-written \`TraceLogSchema\` in \`src/types/sentry.ts\` required. + +* **Bun.build() minifier has identifier collision bug — use esbuild for bundling step**: Bun's bundler has an unfixed identifier collision bug (oven-sh/bun#14585) where minified output produces name collisions across module scopes, causing runtime \`X is not a function\` errors. PR #17930 merged a workaround but the Bun team acknowledged the real bug in the renamer/hoisting code remains. Additionally, Bun's sourcemaps produce an empty \`names\` array, making Sentry stack traces useless. Fix: use esbuild for Step 1 (TS→JS bundling) with \`platform: "node"\`, \`format: "esm"\`, \`external: \["bun:\*"]\`, then feed the output to \`Bun.build({ compile: true })\` for Step 2. esbuild produces correct minification and rich sourcemaps (27k+ names). Size difference is negligible (~1.7% smaller bundle). -### Gotcha + +* **Dashboard tracemetrics dataset uses comma-separated aggregate format**: SDK v10+ custom metrics (, , ) emit envelope items. Dashboard widgets for these MUST use with aggregate format — e.g., . The parameter must match the SDK emission exactly: if no unit specified, for memory metrics, for uptime. only supports , , , , display types — no or . Widgets with always require . Sort expressions must reference aggregates present in . - -* **Biome lint: Response.redirect() required, nested ternaries forbidden**: Biome lint rules that frequently trip up this codebase: (1) \`useResponseRedirect\`: use \`Response.redirect(url, status)\` not \`new Response\`. (2) \`noNestedTernary\`: use \`if/else\`. (3) \`noComputedPropertyAccess\`: use \`obj.property\` not \`obj\["property"]\`. (4) Max cognitive complexity 15 per function — extract helpers to stay under. + +* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`. - -* **Bugbot flags defensive null-checks as dead code — keep them with JSDoc justification**: Cursor Bugbot and Sentry Seer repeatedly flag false positives: (1) defensive null-checks as "dead code" — keep with JSDoc explaining the guard exists for future safety, especially when removing requires \`!\` assertions banned by \`noNonNullAssertion\`. (2) stderr spinner output during \`--json\` mode — always false positive since progress goes to stderr, JSON to stdout. (3) \`Number(projectData.id)\` without \`isFinite\` validation — Sentry project IDs are always numeric strings from the API; adding guards is pure defensive noise. Reply explaining rationale and resolve. When fixing real bugs from bots, make separate fixup commits per review round (not amend). + +* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. - -* **Bun mock.module for node:tty requires default export and class stubs**: Bun testing gotchas: (1) \`mock.module()\` for CJS built-ins requires \`default\` re-export plus all named exports — missing any causes SyntaxError. (2) \`Bun.mmap()\` always opens PROT\_WRITE — macOS SIGKILL, Linux ETXTBSY. Fix: \`new Uint8Array(await Bun.file(path).arrayBuffer())\`. (3) Wrap \`Bun.which()\` with optional \`pathEnv\` for deterministic testing. + +* **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\`. - -* **generate:docs requires generate:parser — CI jobs must chain them**: \`generate:docs\` (which chains \`generate:command-docs → generate:skill → generate:docs-sections\`) transitively imports \`search-query.ts\` which imports the generated PEG parser. On fresh CI checkouts, \`src/generated/search-parser.js\` doesn't exist. Fix: \`generate:docs\` script now starts with \`generate:parser\`. CI jobs that call \`generate:docs\` directly (preview, validate-generated, build-docs) previously failed with \`Cannot find module '../generated/search-parser.js'\`. The top-level scripts (dev, build, typecheck, test) already had \`generate:parser\` wired in separately. + +* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. - -* **Sentry issue descriptions must not contain real org/project names (PII)**: Sentry issue events contain real organization and project slugs which are PII. When referencing Sentry issues in PR descriptions, commit messages, or code comments, always redact real org/project names with generic placeholders (e.g., \`'my-org'\`, \`'my-project'\`). Use \`\*\*\*\` or descriptive placeholders in issue titles. This applies to both automated tooling output and manual references. The user caught real names like \`d4swing\`, \`webscnd\`, \`heyinc\` leaking into a PR description. + +* **Stale nightly-test GHCR tag breaks delta patch generation via sort -V**: A stale \`nightly-test\` tag in GHCR poisoned ALL nightly delta patches. \`sort -V\` places \`nightly-test\` after all \`nightly-0.x.x\` tags. The \`generate-patches\` CI job runs BEFORE \`publish-nightly\`, so the current version's tag doesn't exist yet — the loop never breaks, and \`PREV\_TAG\` becomes \`nightly-test\` (which contains 0-byte binaries). Patches are generated from empty files, producing full-size patches that exceed the client's 60% budget. Fix: changed \`grep '^nightly-'\` to \`grep '^nightly-\[0-9]'\` in all 3 locations (ci.yml generate-patches, ci.yml publish-nightly, cleanup-nightlies.yml). Also need to manually delete the tag (GHCR package version ID 706511735). The cleanup workflow never removed it because it always sorted into the "keep latest 30" set. - -* **Sentry issue list --query passes OR/AND operators to API causing 400**: Sentry search does NOT support AND/OR (\`allow\_boolean=False\`). \`sanitizeQuery()\` uses PEG parser \[\[019d917d-cf52-7e98-b3db-b25979d445f2]]. AND stripped transparently. OR attempts rewrite: \`key:val1 OR key:val2\` → \`key:\[val1,val2]\`. Merging blocked for: \`is:\`/\`has:\` keys, negated qualifiers, comparison filters, wildcards (in both \`text\_filter\` and \`text\_in\_filter\` values), free-text tokens, different keys across OR boundary. OR inside paren groups always throws — even if top-level OR is rewritable. All-OR input (\`OR OR OR\`) returns null from \`tryRewriteOr\` (empty result check). Unmatched parens cause PEG \`SyntaxError\` — caught and passed through to API. Successful rewrites warn via \`logger.warn()\`. Failed rewrites throw \`ValidationError\`. + +* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`. ### Pattern - -* **Branch naming and commit message conventions for Sentry CLI**: Branch naming: \`feat/\\` or \`fix/\-\\`. Commit format: \`type(scope): description (#issue)\`. Types: fix, refactor, meta, release, feat. PRs as drafts via \`gh pr create --draft\`. Plans via \`git notes add\`. PR review: separate commit per round (not amend), reply via REST, resolve threads via GraphQL \`resolveReviewThread\`. + +* **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. - -* **Codecov patch coverage only counts test:unit and test:isolated, not E2E**: CI coverage merges \`test:unit\` (\`test/lib test/commands test/types --coverage\`) and \`test:isolated\` (\`test/isolated --coverage\`) into \`coverage/merged.lcov\`. E2E tests (\`test/e2e\`) are NOT included in coverage reports. So func tests that spy on exports (e.g., \`spyOn(apiClient, 'getLogs')\`) give zero coverage to the mocked function's body. To cover \`api-client.ts\` function bodies in unit tests, mock \`globalThis.fetch\` + \`setOrgRegion()\` + \`setAuthToken()\` and call the real function. + +* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. - -* **Pagination contextKey must include all query-varying parameters with escaping**: Pagination \`contextKey\` must encode every query-varying parameter (sort, query, period) with \`escapeContextKeyValue()\` (replaces \`|\` with \`%7C\`). Always provide a fallback before escaping since \`flags.period\` may be \`undefined\` in tests despite having a default: \`flags.period ? escapeContextKeyValue(flags.period) : "90d"\`. + +* **PR workflow: wait for Seer and Cursor BugBot before resolving**: CI includes Seer Code Review and Cursor Bugbot as advisory checks (~2-3 min, only on ready-for-review PRs). Workflow: push → wait for all CI (including npm build) → check inline review comments from Seer/BugBot → fix valid findings → repeat. Bugbot sometimes catches real logic bugs, not just style — always review before merging. Use \`gh pr checks \ --watch\` to monitor. Fetch comments via \`gh api repos/OWNER/REPO/pulls/NUM/comments\`. - -* **Redact sensitive flags in raw argv before sending to telemetry**: Telemetry argv redaction and context: \`withTelemetry\` calls \`initTelemetryContext()\` — sets user ID, email, runtime, is\_self\_hosted. Org from \`getDefaultOrganization()\` (SQLite). \`SENSITIVE\_FLAGS\` in \`telemetry.ts\` (currently \`token\`) — scans for \`--token\`/\`-token\`, replaces value with \`\[REDACTED]\`. Handles \`--flag value\` and \`--flag=value\`. Command tag format: \`sentry.issue.list\` (dot-joined with \`sentry.\` prefix). + +* **Sentry SDK tree-shaking patches must be regenerated via bun patch workflow**: The CLI uses \`patchedDependencies\` in \`package.json\` to tree-shake unused exports from \`@sentry/core\` and \`@sentry/node-core\` (AI integrations, feature flags, profiler, etc.). When bumping SDK versions: (1) remove old patches and \`patchedDependencies\` entries, (2) \`rm -rf ~/.bun/install/cache/@sentry\` to clear bun's cache (edits persist in cache otherwise), (3) \`bun install\` fresh, (4) \`bun patch @sentry/core\` then edit files and \`bun patch --commit\`, repeat for node-core. Key preserved exports: \`\_INTERNAL\_safeUnref\`, \`\_INTERNAL\_safeDateNow\` (core), \`nodeRuntimeMetricsIntegration\` (node-core). Manually generating patch files with \`git diff\` may fail — bun expects specific hash formats. Always use \`bun patch --commit\` to generate patches. - -* **Stricli parse functions can perform validation and sanitization at flag-parse time**: Stricli's \`parse\` function on \`kind: "parsed"\` flags runs during argument parsing before \`func()\` executes. It can throw errors (including \`ValidationError\`) and log warnings. When a parse function's signature matches \`(raw: string) => T\`, it works directly as a Stricli parse function. Current uses: \`parseCursorFlag\` (cursor validation), \`sanitizeQuery\` (query sanitization with OR-rewrite), \`parsePeriod\` (period validation returning \`TimeRange\`). \`--sort\` uses \`parseSort\`/\`parseSortFlag\`, \`--limit\` uses \`numberParser\`/\`parseLimit\`. All widespread flags now use parse-time validation — no remaining \`parse: String\` on shared flag constants. For optional period flags (log list, dashboard view), \`flags.period\` is \`TimeRange | undefined\` and commands use pre-parsed \`TIME\_RANGE\_\*\` constants as runtime defaults. \`formatTimeRangeFlag()\` converts \`TimeRange\` back to display strings for hints. + +* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Schema v12 replaced \`pagination\_cursors.cursor TEXT\` with \`cursor\_stack TEXT\` (JSON array) + \`page\_index INTEGER\`. Stack-based API in \`src/lib/db/pagination.ts\`: \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/previous/first/last) to \`{cursor, direction}\`. \`advancePaginationState(key, contextKey, direction, nextCursor)\` pushes/pops the stack — back-then-forward truncates stale entries. \`hasPreviousPage(key, contextKey)\` checks \`page\_index > 0\`. \`clearPaginationState(key)\` removes state. \`parseCursorFlag\` in \`list-command.ts\` accepts next/prev/previous/first/last keywords. \`paginationHint()\` in \`org-list.ts\` builds bidirectional hints (\`-c prev | -c next\`). JSON envelope includes \`hasPrev\` boolean. All 7 list commands (trace, span, issue, project, team, repo, dashboard) use this stack API. \`resolveCursor()\` must be called inside \`org-all\` override closures. -### Preference + +* **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). - -* **Project motto: long-term solutions unless survival requires short-term**: User's guiding principle: "Always think about the long term as long as we can have a long term." Meaning: don't fear practical short-term decisions when under resource pressure, but when there's no pressure, always choose the sustainable long-term solution over quick hacks. Applied when deciding to implement a proper PEG parser instead of extending a regex reject-list — no resource pressure meant the right thing was the full parser. + +* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`). diff --git a/src/commands/cli/defaults.ts b/src/commands/cli/defaults.ts index 8d1782716..9bca46424 100644 --- a/src/commands/cli/defaults.ts +++ b/src/commands/cli/defaults.ts @@ -13,14 +13,17 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { normalizeUrl } from "../../lib/constants.js"; +import { parseCustomHeaders } from "../../lib/custom-headers.js"; import { clearAllDefaults, type DefaultsState, getAllDefaults, + getDefaultHeaders, getDefaultOrganization, getDefaultProject, getDefaultUrl, getTelemetryPreference, + setDefaultHeaders, setDefaultOrganization, setDefaultProject, setDefaultUrl, @@ -44,7 +47,7 @@ import { computeTelemetryEffective } from "../../lib/telemetry.js"; // --------------------------------------------------------------------------- /** Canonical key names matching DefaultsState fields */ -type DefaultKey = "organization" | "project" | "telemetry" | "url"; +type DefaultKey = "organization" | "project" | "telemetry" | "url" | "headers"; /** Handler for reading, writing, and clearing a single default */ type DefaultHandler = { @@ -119,6 +122,15 @@ const DEFAULTS_REGISTRY: Record = { }, clear: () => setDefaultUrl(null), }, + headers: { + get: getDefaultHeaders, + set: (value) => { + // Validate the header string by parsing it — throws ConfigError on bad input + parseCustomHeaders(value); + setDefaultHeaders(value); + }, + clear: () => setDefaultHeaders(null), + }, }; // --------------------------------------------------------------------------- @@ -183,6 +195,7 @@ export const defaultsCommand = buildCommand({ "sentry cli defaults project my-proj # Set default project\n" + "sentry cli defaults telemetry off # Disable telemetry\n" + "sentry cli defaults url https://... # Set Sentry URL (self-hosted)\n" + + "sentry cli defaults headers 'X-IAP: t' # Set custom headers (self-hosted)\n" + "sentry cli defaults org --clear # Clear a specific default\n" + "sentry cli defaults --clear --yes # Clear all defaults\n" + "```\n\n" + @@ -192,7 +205,8 @@ export const defaultsCommand = buildCommand({ "| `org` | Default organization slug |\n" + "| `project` | Default project slug |\n" + "| `telemetry` | Telemetry preference (on/off, yes/no, true/false, 1/0) |\n" + - "| `url` | Sentry instance URL (for self-hosted installations) |", + "| `url` | Sentry instance URL (for self-hosted installations) |\n" + + "| `headers` | Custom HTTP headers for self-hosted proxies (semicolon-separated `Name: Value`) |", }, output: { human: formatDefaultsResult, @@ -246,7 +260,7 @@ export const defaultsCommand = buildCommand({ guardNonInteractive(flags); if (!isConfirmationBypassed(flags)) { const confirmed = await log.prompt( - "This will clear all defaults (organization, project, telemetry, URL). Continue?", + "This will clear all defaults (organization, project, telemetry, URL, headers). Continue?", { type: "confirm" } ); if (confirmed !== true) { diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 67a7eda58..87a872fd2 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -9,6 +9,7 @@ import { listAnOrganization_sIssues } from "@sentry/api"; import type { SentryIssue } from "../../types/index.js"; +import { applyCustomHeaders } from "../custom-headers.js"; import { ApiError } from "../errors.js"; import { resolveOrgRegion } from "../region.js"; @@ -425,9 +426,9 @@ export async function getSharedIssue( shareId: string ): Promise<{ groupID: string }> { const url = `${baseUrl}/api/0/shared/issues/${encodeURIComponent(shareId)}/`; - const response = await fetch(url, { - headers: { "Content-Type": "application/json" }, - }); + const headers = new Headers({ "Content-Type": "application/json" }); + applyCustomHeaders(headers); + const response = await fetch(url, { headers }); if (!response.ok) { if (response.status === 404) { diff --git a/src/lib/custom-headers.ts b/src/lib/custom-headers.ts new file mode 100644 index 000000000..48c2f414d --- /dev/null +++ b/src/lib/custom-headers.ts @@ -0,0 +1,220 @@ +/** + * Custom Headers for Self-Hosted Sentry + * + * Parses `SENTRY_CUSTOM_HEADERS` env var (or `defaults.headers` from SQLite) + * and injects user-specified HTTP headers into all requests to self-hosted + * Sentry instances. Designed for environments behind reverse proxies + * (e.g., Google IAP, Cloudflare Access) that require extra headers. + * + * Format: semicolon-separated `Name: Value` pairs (newlines also accepted). + * + * @example + * ```bash + * # Single header + * SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123" + * + * # Multiple headers + * SENTRY_CUSTOM_HEADERS="X-IAP-Token: abc123; X-Forwarded-For: 10.0.0.1" + * + * # Via defaults command + * sentry cli defaults headers "X-IAP-Token: abc123" + * ``` + */ + +import { getConfiguredSentryUrl } from "./constants.js"; +import { getDefaultHeaders } from "./db/defaults.js"; +import { getEnv } from "./env.js"; +import { ConfigError } from "./errors.js"; +import { logger } from "./logger.js"; +import { isSentrySaasUrl } from "./sentry-urls.js"; + +const log = logger.withTag("custom-headers"); + +/** + * Header names that must not be overridden via custom headers. + * These are managed by the CLI's own request pipeline and overriding + * them would break authentication, content negotiation, or tracing. + */ +const FORBIDDEN_HEADER_NAMES = new Set([ + "authorization", + "host", + "content-type", + "content-length", + "user-agent", + "sentry-trace", + "baggage", +]); + +/** + * RFC 7230 token characters for header field names. + * Header names consist of visible ASCII characters except delimiters. + */ +const VALID_HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~\w]+$/; + +/** Splits on semicolons and newlines (both valid header separators). */ +const HEADER_SEPARATOR_RE = /[;\n]/; + +/** Strips trailing carriage return from a line (Windows line endings). */ +const TRAILING_CR_RE = /\r$/; + +/** Cached parsed headers (from env var or defaults). `undefined` = not yet parsed. */ +let cachedHeaders: readonly [string, string][] | undefined; + +/** Tracks the raw source string that produced `cachedHeaders`, for invalidation. */ +let cachedRawSource: string | undefined; + +/** Whether the SaaS warning has already been logged this session. */ +let saasWarningLogged = false; + +/** + * Parse a raw custom headers string into validated name/value pairs. + * + * Accepts semicolon-separated or newline-separated `Name: Value` entries. + * Empty segments and whitespace-only segments are silently skipped. + * + * @param raw - Raw header string (from env var or defaults) + * @returns Array of `[name, value]` tuples in declaration order + * @throws {ConfigError} On malformed segments or forbidden header names + */ +export function parseCustomHeaders(raw: string): readonly [string, string][] { + const results: [string, string][] = []; + + // Split on semicolons and newlines + const segments = raw.split(HEADER_SEPARATOR_RE); + + for (const segment of segments) { + const trimmed = segment.replace(TRAILING_CR_RE, "").trim(); + if (!trimmed) { + continue; + } + + const colonIndex = trimmed.indexOf(":"); + if (colonIndex === -1) { + throw new ConfigError( + `Invalid header in SENTRY_CUSTOM_HEADERS: '${trimmed}'. Expected 'Name: Value' format.` + ); + } + + const name = trimmed.slice(0, colonIndex).trim(); + const value = trimmed.slice(colonIndex + 1).trim(); + + if (!name) { + throw new ConfigError( + `Invalid header in SENTRY_CUSTOM_HEADERS: empty header name in '${trimmed}'.` + ); + } + + if (!VALID_HEADER_NAME_RE.test(name)) { + throw new ConfigError( + `Invalid header name '${name}' in SENTRY_CUSTOM_HEADERS. Header names must contain only alphanumeric characters, hyphens, and RFC 7230 token characters.` + ); + } + + if (FORBIDDEN_HEADER_NAMES.has(name.toLowerCase())) { + throw new ConfigError( + `Cannot override reserved header '${name}' in SENTRY_CUSTOM_HEADERS. This header is managed by the CLI.` + ); + } + + results.push([name, value]); + } + + return results; +} + +/** + * Check whether the current target is a self-hosted Sentry instance. + * + * Self-hosted = `SENTRY_HOST` or `SENTRY_URL` is set to a non-SaaS URL. + * Returns false if no custom URL is configured (implying SaaS) or if the + * configured URL points to `*.sentry.io`. + */ +function isSelfHosted(): boolean { + const configured = getConfiguredSentryUrl(); + if (!configured) { + return false; + } + return !isSentrySaasUrl(configured); +} + +/** + * Resolve the raw custom headers string from env var or SQLite defaults. + * + * Priority: `SENTRY_CUSTOM_HEADERS` env var > `defaults.headers` in SQLite. + * Returns undefined when no headers are configured. + */ +function resolveRawHeaders(): string | undefined { + const envValue = getEnv().SENTRY_CUSTOM_HEADERS; + if (envValue?.trim()) { + return envValue.trim(); + } + + const dbValue = getDefaultHeaders(); + if (dbValue?.trim()) { + return dbValue.trim(); + } + + return; +} + +/** + * Get the parsed custom headers for the current session. + * + * Returns an empty array when: + * - No custom headers are configured (env var or defaults) + * - The target is not a self-hosted instance (warns once if headers are set) + * + * Parsed results are cached; the self-hosted guard is re-evaluated per call + * because `SENTRY_HOST` can be set dynamically by URL argument parsing. + */ +export function getCustomHeaders(): readonly [string, string][] { + const raw = resolveRawHeaders(); + if (!raw) { + return []; + } + + // Self-hosted guard: warn once and skip on SaaS + if (!isSelfHosted()) { + if (!saasWarningLogged) { + saasWarningLogged = true; + log.warn( + "SENTRY_CUSTOM_HEADERS is set but no self-hosted Sentry instance is configured. Headers will be ignored." + ); + } + return []; + } + + // Return cached result if the raw source hasn't changed + if (cachedHeaders !== undefined && cachedRawSource === raw) { + return cachedHeaders; + } + + cachedHeaders = parseCustomHeaders(raw); + cachedRawSource = raw; + return cachedHeaders; +} + +/** + * Apply custom headers to a `Headers` instance. + * + * Reads from the env var or SQLite defaults, validates, and sets each header. + * No-op when no custom headers are configured or when targeting SaaS. + * + * @param headers - The `Headers` instance to modify in-place + */ +export function applyCustomHeaders(headers: Headers): void { + const customHeaders = getCustomHeaders(); + for (const [name, value] of customHeaders) { + headers.set(name, value); + } +} + +/** + * Reset module-level caches. Exported for testing only. + * @internal + */ +export function _resetCustomHeadersCache(): void { + cachedHeaders = undefined; + cachedRawSource = undefined; + saasWarningLogged = false; +} diff --git a/src/lib/db/defaults.ts b/src/lib/db/defaults.ts index bb5fcf711..542f8b5c9 100644 --- a/src/lib/db/defaults.ts +++ b/src/lib/db/defaults.ts @@ -15,6 +15,7 @@ const DEFAULTS_ORG = "defaults.org"; const DEFAULTS_PROJECT = "defaults.project"; const DEFAULTS_TELEMETRY = "defaults.telemetry"; const DEFAULTS_URL = "defaults.url"; +const DEFAULTS_HEADERS = "defaults.headers"; /** All metadata keys used for defaults (for bulk operations) */ const ALL_DEFAULTS_KEYS = [ @@ -22,6 +23,7 @@ const ALL_DEFAULTS_KEYS = [ DEFAULTS_PROJECT, DEFAULTS_TELEMETRY, DEFAULTS_URL, + DEFAULTS_HEADERS, ]; /** State of all persistent defaults */ @@ -34,6 +36,8 @@ export type DefaultsState = { telemetry: "on" | "off" | null; /** Default Sentry instance URL, or null if unset */ url: string | null; + /** Custom HTTP headers for self-hosted proxy auth, or null if unset */ + headers: string | null; }; /** Parse a raw telemetry metadata value to a typed "on" | "off" | null. */ @@ -91,6 +95,16 @@ export function getDefaultUrl(): string | null { return m.get(DEFAULTS_URL) ?? null; } +/** + * Get the default custom headers string, or null if not set. + * Format: semicolon-separated `Name: Value` pairs. + */ +export function getDefaultHeaders(): string | null { + const db = getDatabase(); + const m = getMetadata(db, [DEFAULTS_HEADERS]); + return m.get(DEFAULTS_HEADERS) ?? null; +} + /** * Get all persistent defaults as a structured object. * Used by the `sentry cli defaults` show mode and JSON output. @@ -104,6 +118,7 @@ export function getAllDefaults(): DefaultsState { project: m.get(DEFAULTS_PROJECT) ?? null, telemetry: parseTelemetryValue(telVal), url: m.get(DEFAULTS_URL) ?? null, + headers: m.get(DEFAULTS_HEADERS) ?? null, }; } @@ -154,6 +169,19 @@ export function setDefaultUrl(url: string | null): void { } } +/** + * Set or clear the default custom headers. Pass `null` to clear. + * Value should be semicolon-separated `Name: Value` pairs. + */ +export function setDefaultHeaders(value: string | null): void { + const db = getDatabase(); + if (value === null) { + clearMetadata(db, [DEFAULTS_HEADERS]); + } else { + setMetadata(db, { [DEFAULTS_HEADERS]: value }); + } +} + // --------------------------------------------------------------------------- // Bulk operations // --------------------------------------------------------------------------- diff --git a/src/lib/env-registry.ts b/src/lib/env-registry.ts index b285f0ba1..8740a2104 100644 --- a/src/lib/env-registry.ts +++ b/src/lib/env-registry.ts @@ -85,6 +85,18 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Client ID of a public OAuth application on your Sentry instance. **Required for [self-hosted Sentry](./self-hosted/)** (26.1.0+) to use `sentry auth login` with the device flow. See the [Self-Hosted guide](./self-hosted/#1-create-a-public-oauth-application) for how to create one.", example: "your-oauth-client-id", }, + // -- Custom headers -- + { + name: "SENTRY_CUSTOM_HEADERS", + description: + "Custom HTTP headers to include in all requests to your Sentry instance. " + + "**Only applies to [self-hosted Sentry](./self-hosted/).** Ignored when targeting sentry.io.\n\n" + + "Use semicolon-separated `Name: Value` pairs. Useful for environments behind " + + "reverse proxies that require additional headers for authentication " + + "(e.g., Google IAP, Cloudflare Access).\n\n" + + "Can also be set persistently with `sentry cli defaults headers`.", + example: '"X-IAP-Token: my-proxy-token"', + }, // -- Paths -- { name: "SENTRY_CONFIG_DIR", diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 176515795..6c98407be 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -2442,6 +2442,7 @@ const DEFAULT_LABELS: Record = { project: "Project", telemetry: "Telemetry", url: "URL", + headers: "Headers", }; /** @@ -2458,39 +2459,31 @@ function telemetryOverrideNote( return ` ${colorTag("muted", `(overridden: disabled via ${envVar})`)}`; } +/** Build the rows for the "show" mode of the defaults command. */ +function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { + const d = data.defaults; + const notSet = colorTag("muted", "not set"); + const telLabel = d.telemetry ?? "on (default)"; + + return [ + ["Organization", d.organization ? safeCodeSpan(d.organization) : notSet], + ["Project", d.project ? safeCodeSpan(d.project) : notSet], + [ + "Telemetry", + `${escapeMarkdownInline(String(telLabel))}${telemetryOverrideNote(data.telemetryEffective)}`, + ], + ["URL", d.url ? safeCodeSpan(d.url) : notSet], + ["Headers", d.headers ? safeCodeSpan(d.headers) : notSet], + ]; +} + /** * Format defaults command result as rendered markdown. */ export function formatDefaultsResult(data: DefaultsResult): string { switch (data.action) { - case "show": { - const rows: [string, string][] = []; - const d = data.defaults; - - rows.push([ - "Organization", - d.organization - ? safeCodeSpan(d.organization) - : colorTag("muted", "not set"), - ]); - rows.push([ - "Project", - d.project ? safeCodeSpan(d.project) : colorTag("muted", "not set"), - ]); - - const telLabel = d.telemetry ?? "on (default)"; - rows.push([ - "Telemetry", - `${escapeMarkdownInline(String(telLabel))}${telemetryOverrideNote(data.telemetryEffective)}`, - ]); - - rows.push([ - "URL", - d.url ? safeCodeSpan(d.url) : colorTag("muted", "not set"), - ]); - - return renderMarkdown(mdKvTable(rows, "Defaults")); - } + case "show": + return renderMarkdown(mdKvTable(buildDefaultsShowRows(data), "Defaults")); case "set": { const label = diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index 294bf1263..bcf79cc0f 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -12,6 +12,7 @@ import { TokenResponseSchema, } from "../types/index.js"; import { DEFAULT_SENTRY_URL, getConfiguredSentryUrl } from "./constants.js"; +import { getCustomHeaders } from "./custom-headers.js"; import { setAuthToken } from "./db/auth.js"; import { getEnv } from "./env.js"; import { ApiError, AuthError, ConfigError, DeviceFlowError } from "./errors.js"; @@ -85,8 +86,19 @@ async function fetchWithConnectionError( url: string, init: RequestInit ): Promise { + // Inject custom headers for self-hosted proxies (IAP, mTLS, etc.) + let effectiveInit = init; + const customHeaders = getCustomHeaders(); + if (customHeaders.length > 0) { + const merged = new Headers(init.headers); + for (const [name, value] of customHeaders) { + merged.set(name, value); + } + effectiveInit = { ...init, headers: merged }; + } + try { - return await fetch(url, init); + return await fetch(url, effectiveInit); } catch (error) { const isConnectionError = error instanceof Error && diff --git a/src/lib/sentry-client.ts b/src/lib/sentry-client.ts index 7a748b717..8b011ca45 100644 --- a/src/lib/sentry-client.ts +++ b/src/lib/sentry-client.ts @@ -14,6 +14,7 @@ import { getConfiguredSentryUrl, getUserAgent, } from "./constants.js"; +import { applyCustomHeaders } from "./custom-headers.js"; import { getAuthToken, refreshToken } from "./db/auth.js"; import { logger } from "./logger.js"; import { getCachedResponse, storeCachedResponse } from "./response-cache.js"; @@ -97,6 +98,9 @@ function prepareHeaders( headers.set("baggage", traceData.baggage); } + // Inject user-configured custom headers for self-hosted proxies (IAP, mTLS, etc.) + applyCustomHeaders(headers); + return headers; } diff --git a/test/commands/cli/defaults.test.ts b/test/commands/cli/defaults.test.ts index cd274fa53..026dc0dea 100644 --- a/test/commands/cli/defaults.test.ts +++ b/test/commands/cli/defaults.test.ts @@ -10,6 +10,7 @@ import chalk from "chalk"; import { clearAllDefaults, getAllDefaults, + getDefaultHeaders, getDefaultOrganization, getDefaultProject, getDefaultUrl, @@ -114,6 +115,7 @@ describe("defaults storage", () => { project: "test-project", telemetry: "off", url: "https://sentry.example.com", + headers: null, }); }); @@ -124,6 +126,7 @@ describe("defaults storage", () => { project: null, telemetry: null, url: null, + headers: null, }); }); @@ -139,6 +142,7 @@ describe("defaults storage", () => { expect(getDefaultProject()).toBeNull(); expect(getTelemetryPreference()).toBeUndefined(); expect(getDefaultUrl()).toBeNull(); + expect(getDefaultHeaders()).toBeNull(); }); }); diff --git a/test/lib/custom-headers.property.test.ts b/test/lib/custom-headers.property.test.ts new file mode 100644 index 000000000..af554b40a --- /dev/null +++ b/test/lib/custom-headers.property.test.ts @@ -0,0 +1,140 @@ +/** + * Property-Based Tests for Custom Headers Parsing + * + * Uses fast-check to verify parsing invariants that should hold + * for any valid header input. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + nat, + property, + tuple, +} from "fast-check"; +import { parseCustomHeaders } from "../../src/lib/custom-headers.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// --------------------------------------------------------------------------- +// Arbitraries +// --------------------------------------------------------------------------- + +/** Characters valid in HTTP header names (subset of RFC 7230 token chars) */ +const headerNameChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.!#$%&'*+^`|~"; + +/** Header names that cannot be used (reserved by the CLI) */ +const FORBIDDEN_NAMES = new Set([ + "authorization", + "host", + "content-type", + "content-length", + "user-agent", + "sentry-trace", + "baggage", +]); + +/** Generate a valid header name (1-30 chars, not forbidden) */ +const headerNameArb = array(constantFrom(...headerNameChars.split("")), { + minLength: 1, + maxLength: 30, +}) + .map((chars) => chars.join("")) + .filter((name) => !FORBIDDEN_NAMES.has(name.toLowerCase())); + +/** Characters valid in header values (printable ASCII, no semicolons or newlines) */ +const headerValueChars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !@#$%^&*()-_=+[]{}|:',.<>?/`~"; + +/** Generate a header value (0-80 chars, no semicolons/newlines) */ +const headerValueArb = array(constantFrom(...headerValueChars.split("")), { + minLength: 0, + maxLength: 80, +}).map((chars) => chars.join("")); + +/** Generate a single valid header pair */ +const headerPairArb = tuple(headerNameArb, headerValueArb); + +/** Generate a list of 1-5 header pairs */ +const headerListArb = array(headerPairArb, { minLength: 1, maxLength: 5 }); + +/** Separator: semicolon or newline */ +const separatorArb = constantFrom("; ", ";\n", "\n"); + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +describe("property: parseCustomHeaders", () => { + test("round-trip: format then parse returns same name/value pairs", () => { + fcAssert( + property(headerPairArb, ([name, value]) => { + const formatted = `${name}: ${value}`; + const result = parseCustomHeaders(formatted); + expect(result).toEqual([[name, value.trim()]]); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("count: number of valid segments equals number of returned headers", () => { + fcAssert( + property(headerListArb, separatorArb, (pairs, sep) => { + const formatted = pairs + .map(([name, value]) => `${name}: ${value}`) + .join(sep); + const result = parseCustomHeaders(formatted); + expect(result.length).toBe(pairs.length); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("order: headers appear in the same order as input", () => { + fcAssert( + property(headerListArb, (pairs) => { + const formatted = pairs + .map(([name, value]) => `${name}: ${value}`) + .join("; "); + const result = parseCustomHeaders(formatted); + for (let i = 0; i < pairs.length; i++) { + expect(result[i]?.[0]).toBe(pairs[i]?.[0]); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("values are always trimmed", () => { + fcAssert( + property( + headerNameArb, + headerValueArb, + nat({ max: 5 }), + nat({ max: 5 }), + (name, value, leadingSpaces, trailingSpaces) => { + const padded = `${" ".repeat(leadingSpaces)}${value}${" ".repeat(trailingSpaces)}`; + const formatted = `${name}:${padded}`; + const result = parseCustomHeaders(formatted); + expect(result[0]?.[1]).toBe(value.trim()); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty segments between separators are skipped", () => { + fcAssert( + property(headerPairArb, ([name, value]) => { + // Add empty segments via double-separators + const formatted = `; ;${name}: ${value}; ; `; + const result = parseCustomHeaders(formatted); + expect(result.length).toBe(1); + expect(result[0]?.[0]).toBe(name); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/custom-headers.test.ts b/test/lib/custom-headers.test.ts new file mode 100644 index 000000000..9399984a9 --- /dev/null +++ b/test/lib/custom-headers.test.ts @@ -0,0 +1,307 @@ +/** + * Unit Tests for Custom Headers + * + * Tests parseCustomHeaders() parsing, getCustomHeaders() env/DB integration, + * and applyCustomHeaders() header injection. + * + * Note: Core round-trip and invariant properties are tested via property-based + * tests in custom-headers.property.test.ts. These tests focus on edge cases, + * error messages, and integration behavior not covered by property generators. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + _resetCustomHeadersCache, + applyCustomHeaders, + getCustomHeaders, + parseCustomHeaders, +} from "../../src/lib/custom-headers.js"; +import { setDefaultHeaders } from "../../src/lib/db/defaults.js"; +import { useTestConfigDir } from "../helpers.js"; + +// --------------------------------------------------------------------------- +// parseCustomHeaders — parsing logic +// --------------------------------------------------------------------------- + +describe("parseCustomHeaders", () => { + test("parses single header", () => { + const result = parseCustomHeaders("X-Custom: value"); + expect(result).toEqual([["X-Custom", "value"]]); + }); + + test("parses multiple headers separated by semicolon", () => { + const result = parseCustomHeaders( + "X-First: one; X-Second: two; X-Third: three" + ); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ["X-Third", "three"], + ]); + }); + + test("parses multiple headers separated by newline", () => { + const result = parseCustomHeaders("X-First: one\nX-Second: two"); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ]); + }); + + test("parses mixed semicolon and newline separators", () => { + const result = parseCustomHeaders( + "X-First: one; X-Second: two\nX-Third: three" + ); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ["X-Third", "three"], + ]); + }); + + test("handles value containing colons", () => { + const result = parseCustomHeaders("X-Token: abc:def:ghi"); + expect(result).toEqual([["X-Token", "abc:def:ghi"]]); + }); + + test("trims whitespace from name and value", () => { + const result = parseCustomHeaders(" X-Custom : some value "); + expect(result).toEqual([["X-Custom", "some value"]]); + }); + + test("skips empty segments", () => { + const result = parseCustomHeaders("X-First: one;; ;X-Second: two"); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ]); + }); + + test("handles Windows line endings (\\r\\n)", () => { + const result = parseCustomHeaders("X-First: one\r\nX-Second: two"); + expect(result).toEqual([ + ["X-First", "one"], + ["X-Second", "two"], + ]); + }); + + test("returns empty array for empty string", () => { + expect(parseCustomHeaders("")).toEqual([]); + }); + + test("returns empty array for whitespace-only string", () => { + expect(parseCustomHeaders(" \n ; ")).toEqual([]); + }); + + test("allows header value to be empty", () => { + const result = parseCustomHeaders("X-Empty:"); + expect(result).toEqual([["X-Empty", ""]]); + }); + + // Error cases + + test("throws ConfigError on segment without colon", () => { + expect(() => parseCustomHeaders("bad-header-no-colon")).toThrow( + /Expected 'Name: Value' format/ + ); + }); + + test("throws ConfigError on empty header name", () => { + expect(() => parseCustomHeaders(": value-only")).toThrow( + /empty header name/ + ); + }); + + test("throws ConfigError on header name with spaces", () => { + expect(() => parseCustomHeaders("Bad Name: value")).toThrow( + /Header names must contain only/ + ); + }); + + // Forbidden headers + const forbiddenHeaders = [ + "Authorization", + "Host", + "Content-Type", + "Content-Length", + "User-Agent", + "sentry-trace", + "baggage", + ]; + + for (const header of forbiddenHeaders) { + test(`throws ConfigError for forbidden header: ${header}`, () => { + expect(() => parseCustomHeaders(`${header}: some-value`)).toThrow( + /Cannot override reserved header/ + ); + }); + } + + test("forbidden header check is case-insensitive", () => { + expect(() => parseCustomHeaders("AUTHORIZATION: token")).toThrow( + /Cannot override reserved header/ + ); + expect(() => parseCustomHeaders("content-type: json")).toThrow( + /Cannot override reserved header/ + ); + }); +}); + +// --------------------------------------------------------------------------- +// getCustomHeaders — env var and DB integration +// --------------------------------------------------------------------------- + +describe("getCustomHeaders", () => { + useTestConfigDir("custom-headers-test-", { isolateProjectRoot: true }); + + let savedHeaders: string | undefined; + let savedHost: string | undefined; + let savedUrl: string | undefined; + + beforeEach(() => { + savedHeaders = process.env.SENTRY_CUSTOM_HEADERS; + savedHost = process.env.SENTRY_HOST; + savedUrl = process.env.SENTRY_URL; + delete process.env.SENTRY_CUSTOM_HEADERS; + delete process.env.SENTRY_HOST; + delete process.env.SENTRY_URL; + _resetCustomHeadersCache(); + }); + + afterEach(() => { + if (savedHeaders !== undefined) { + process.env.SENTRY_CUSTOM_HEADERS = savedHeaders; + } else { + delete process.env.SENTRY_CUSTOM_HEADERS; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } else { + delete process.env.SENTRY_HOST; + } + if (savedUrl !== undefined) { + process.env.SENTRY_URL = savedUrl; + } else { + delete process.env.SENTRY_URL; + } + _resetCustomHeadersCache(); + }); + + test("returns empty array when no env var or defaults set", () => { + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns empty array when headers set but no SENTRY_HOST (SaaS mode)", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: value"; + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns empty array when SENTRY_HOST is sentry.io", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: value"; + process.env.SENTRY_HOST = "sentry.io"; + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns empty array when SENTRY_HOST is subdomain of sentry.io", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: value"; + process.env.SENTRY_HOST = "us.sentry.io"; + expect(getCustomHeaders()).toEqual([]); + }); + + test("returns parsed headers when SENTRY_HOST is self-hosted", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: abc123"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + expect(getCustomHeaders()).toEqual([["X-IAP-Token", "abc123"]]); + }); + + test("returns parsed headers when SENTRY_URL is self-hosted", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-IAP-Token: abc123"; + process.env.SENTRY_URL = "https://sentry.example.com"; + expect(getCustomHeaders()).toEqual([["X-IAP-Token", "abc123"]]); + }); + + test("env var takes priority over SQLite defaults", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + process.env.SENTRY_CUSTOM_HEADERS = "X-Env: from-env"; + setDefaultHeaders("X-Db: from-db"); + expect(getCustomHeaders()).toEqual([["X-Env", "from-env"]]); + }); + + test("falls back to SQLite defaults when env var not set", () => { + process.env.SENTRY_HOST = "https://sentry.example.com"; + setDefaultHeaders("X-Db: from-db"); + expect(getCustomHeaders()).toEqual([["X-Db", "from-db"]]); + }); + + test("caches parsed result across calls", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Cache: test"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + const first = getCustomHeaders(); + const second = getCustomHeaders(); + // Same reference (cached) + expect(first).toBe(second); + }); +}); + +// --------------------------------------------------------------------------- +// applyCustomHeaders — header injection +// --------------------------------------------------------------------------- + +describe("applyCustomHeaders", () => { + let savedHeaders: string | undefined; + let savedHost: string | undefined; + + beforeEach(() => { + savedHeaders = process.env.SENTRY_CUSTOM_HEADERS; + savedHost = process.env.SENTRY_HOST; + _resetCustomHeadersCache(); + }); + + afterEach(() => { + if (savedHeaders !== undefined) { + process.env.SENTRY_CUSTOM_HEADERS = savedHeaders; + } else { + delete process.env.SENTRY_CUSTOM_HEADERS; + } + if (savedHost !== undefined) { + process.env.SENTRY_HOST = savedHost; + } else { + delete process.env.SENTRY_HOST; + } + _resetCustomHeadersCache(); + }); + + test("applies custom headers to Headers instance", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: hello; X-Other: world"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders(headers); + + expect(headers.get("X-Test")).toBe("hello"); + expect(headers.get("X-Other")).toBe("world"); + }); + + test("does not clobber unrelated existing headers", () => { + process.env.SENTRY_CUSTOM_HEADERS = "X-Test: hello"; + process.env.SENTRY_HOST = "https://sentry.example.com"; + + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders(headers); + + expect(headers.get("Accept")).toBe("application/json"); + expect(headers.get("X-Test")).toBe("hello"); + }); + + test("no-op when no custom headers configured", () => { + delete process.env.SENTRY_CUSTOM_HEADERS; + delete process.env.SENTRY_HOST; + + const headers = new Headers({ Accept: "application/json" }); + applyCustomHeaders(headers); + + // Only the original header + const keys = [...headers.keys()]; + expect(keys).toEqual(["accept"]); + }); +});