Guidelines for AI agents working in this codebase.
Sentry CLI is a command-line interface for Sentry, built with Node.js, pnpm, and Stricli. It lives in packages/cli of a pnpm workspace; the docs site lives in apps/cli-docs.
- Zero-config experience - Auto-detect project context from DSNs in source code and env files
- AI-powered debugging - Integrate Seer AI for root cause analysis and fix plans
- Developer-friendly - Follow
ghCLI conventions for intuitive UX - Agent-friendly - JSON output and predictable behavior for AI coding agents
- Fast - Native binaries via Node SEA (fossilize), SQLite caching for API responses
- DSN Auto-Detection - Scans
.envfiles and source code (any file type inTEXT_EXTENSIONS,src/lib/scan/constants.ts) to find Sentry DSNs - Project Root Detection - Walks up from CWD to find project boundaries using VCS, language, and build markers
- Directory Name Inference - Fallback project matching using bidirectional word boundary matching
- Multi-Region Support - Automatic region detection with fan-out to regional APIs (us.sentry.io, de.sentry.io)
- Monorepo Support - Generates short aliases for multiple projects
- Seer AI Integration -
issue explainandissue plancommands for AI analysis - OAuth Device Flow - Secure authentication without browser redirects
Before working on this codebase, read the Cursor rules:
.cursor/rules/oxc.mdc- Code style, formatting, linting rules
Note: Always check
package.jsonfor the latest scripts. All commands run frompackages/cli(or viapnpm --filter sentry run <script>from the repo root).
# Development
pnpm install # Install dependencies (from repo root)
pnpm run dev # Run CLI in dev mode
pnpm run cli <args> # Run the CLI with arguments
# Build
pnpm run build # Build for current platform
pnpm run build:all # Build for all platforms
# Type Checking
pnpm run typecheck # Check types (tsc)
# Linting & Formatting
pnpm run lint # Check Oxlint and Oxfmt
pnpm run lint:fix # Auto-fix issues (run before committing)
# Testing (Vitest)
pnpm run test:unit # Run unit tests
pnpm run test:e2e # Run e2e tests
pnpm exec vitest run test/lib/foo.test.ts # Run a single test file (skips generate steps)
pnpm exec vitest # Watch modeCRITICAL: All packages must be in devDependencies, never dependencies. Everything is bundled at build time via esbuild. CI enforces this with pnpm run check:deps.
When adding a package, always use pnpm add -D <package> (the -D flag).
When the @sentry/api SDK provides types for an API response, import them directly from @sentry/api instead of creating redundant Valibot schemas in src/types/sentry.ts.
CRITICAL: This project runs on Node.js. Use Node.js standard-library
(node:*) APIs; there is no Bun runtime. (This project was migrated from Bun to
Node — do not reintroduce Bun.* globals, bun:test, or bun CLI commands.)
| Task | Use This |
|---|---|
| Read file | await readFile(path, "utf-8") (node:fs/promises) |
| Write file | await writeFile(path, content) (node:fs/promises) |
| Check file exists | existsSync(path) (node:fs) |
| Spawn process | spawn() / execFile() (node:child_process) |
| Shell commands | execSync() (node:child_process) |
| Find executable | which helper in src/lib/which.ts |
| Glob patterns | the scanner in src/lib/scan/ |
| Sleep | await setTimeout(ms) (node:timers/promises) |
| Parse JSON file | JSON.parse(await readFile(path, "utf-8")) |
Use node:fs for directory creation with permissions:
import { mkdirSync } from "node:fs";
mkdirSync(dir, { recursive: true, mode: 0o700 });For shell commands, prefer execFileSync with array args over execSync with a
string when passing user-controlled values, to avoid shell injection:
import { execFileSync } from "node:child_process";
const result = execFileSync("id", ["-u", "username"], { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });The full project-structure tree — including the live command/subcommand list and the
domain API modules — is generated from the route tree and lives in
apps/cli-docs/src/content/docs/contributing.md
(the project-structure block produced by script/generate-docs-sections.ts). It is
kept in sync automatically, so it is not duplicated here to avoid drift. For the
current command list run ls src/commands/ or sentry --help.
Top-level layout:
src/bin.ts— entry point;src/app.ts— Stricli application setup;src/context.ts— dependency-injection context.src/commands/— one directory per command group (agent-conversation,alert,auth,build,cli,code-mappings,dart-symbol-map,dashboard,debug-files,docs,dsn,event,feedback,integration,issue,local,log,monitor,org,platform,proguard,project,react-native,release,replay,repo,snapshots,sourcemap,span,status,team,trace,trial) plus standalone command files (api.ts,explore.ts,help.ts,info.ts,init.ts,mcp.ts,schema.ts,wasm-split.ts, plus the hiddenbash-hook.ts,send-event.ts, andsend-envelope.ts).src/lib/— shared utilities. Key subtrees:api/(domain API modules),db/(SQLite layer),dsn/(DSN detection with one language-agnostic regex),scan/(file walker and scanned extensions), andformatters/(output formatting). See the file-locations table below and the JSDoc in each module for details.src/types/— TypeScript types and Valibot schemas.test/— tests mirroringsrc/(unit,*.property.test.ts,*.model-based.test.ts,e2e/,fixtures/,mocks/).../../apps/cli-docs/— documentation site (Astro + Starlight);script/— build/utility scripts;.cursor/rules/— Cursor AI rules;lint-rules/cli-oxlint-plugin.js— CLI-specific lint rules.
Commands use Stricli wrapped by src/lib/command.ts.
CRITICAL: Import buildCommand from ../../lib/command.js, NEVER from @stricli/core directly — the wrapper adds telemetry, --json/--fields injection, and output rendering.
Pattern:
import { buildCommand } from "../../lib/command.js";
import type { SentryContext } from "../../context.js";
import { CommandOutput } from "../../lib/formatters/output.js";
export const myCommand = buildCommand({
docs: {
brief: "Short description",
fullDescription: "Detailed description",
},
output: {
human: formatMyData, // (data: T) => string
jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => unknown
jsonExclude: ["humanOnlyField"], // optional: strip keys from JSON
},
parameters: {
flags: {
limit: { kind: "parsed", parse: Number, brief: "Max items", default: 10 },
},
},
async *func(this: SentryContext, flags) {
const data = await fetchData();
yield new CommandOutput(data);
return { hint: "Tip: use --json for machine-readable output" };
},
});Key rules:
- Functions are
async *func()generators — yieldnew CommandOutput(data), return{ hint }. output.humanreceives the same data object that gets serialized to JSON — no divergent-data paths.- The wrapper auto-injects
--jsonand--fieldsflags. Do NOT add your ownjsonflag. - Do NOT use
stdout.write()orif (flags.json)branching — the wrapper handles it.
Command files in src/commands/ should focus on three concerns:
- Argument parsing — positional args, flags, URL detection
- API orchestration — fetching data, error handling, enrichment
- Output dispatch —
yield new CommandOutput(data)
Formatting and rendering logic belongs in src/lib/formatters/<domain>.ts. If a command file exceeds ~400 lines, extract formatting helpers into a dedicated formatter module.
Reference: src/lib/formatters/replay.ts (extracted from replay/view.ts), src/lib/formatters/trace.ts, src/lib/formatters/human.ts.
Lint enforcement: stderr.write() is banned in command files (GritQL rule). Use logger for diagnostics and CommandOutput for data output.
Route groups use Stricli's buildRouteMap wrapped by src/lib/route-map.ts.
CRITICAL: Import buildRouteMap from ../../lib/route-map.js, NEVER from @stricli/core directly — the wrapper auto-injects standard subcommand aliases based on which route keys exist:
| Route | Auto-aliases |
|---|---|
list |
ls |
view |
show |
delete |
remove, rm |
create |
new |
Manually specified aliases in aliases are merged with (and take precedence over) auto-generated ones. Do NOT manually add aliases that are already in the standard set above.
import { buildRouteMap } from "../../lib/route-map.js";
export const myRoute = buildRouteMap({
routes: {
list: listCommand,
view: viewCommand,
create: createCommand,
},
defaultCommand: "view",
// No need for aliases — ls, show, and new are auto-injected.
// Only add aliases for non-standard mappings:
// aliases: { custom: "list" },
docs: {
brief: "Manage my resources",
},
});Reading the command tree: code in src/lib/ reads the top-level route map
with getRoutes() from src/lib/route-registry.ts, never by importing
app.ts. Every command reaches src/lib/ through buildCommand, so that
import would pull every command into any bundle built from one of them.
app.ts registers its map with setRoutes(); a test that needs the real tree
imports src/app.js. test/lib/route-registry.test.ts fails when a module in
src/lib/ reaches app.ts; the only exception is sdk-invoke.ts, the
library-mode runner that executes the whole application.
Required identifiers (trace IDs, span IDs) should be positional args, not
flags. Every command that takes trace, span, replay, log, or event IDs parses
them with parseIdArguments (or parseIdArgument for exactly one) from
src/lib/id-arguments.ts, so they all take the same forms:
<id>,<org>/<id>,<org>/<project>/<id>,<target> <id>, a Sentry URL<id> <org>/<project>, reversed, with a warning- more IDs after any of those (with
multiple), each maybe repeating the target; one argument may hold several lines, or IDs after a space - extra arguments are ignored with a warning; slashes pasted after an ID are ignored
The command passes its kind of ID from ID_KINDS (what the ID looks like,
how to validate it, which part of a URL holds one) and how it takes IDs:
optional when it can list a project's items instead (log list,
span list), needsProject when <x>/<id> should name project <x>
rather than org <x>, validate: false to recover malformed IDs once the
target is known. The result is a target string for parseOrgProjectInput
and the IDs. Add a kind to ID_KINDS rather than validating IDs in a command;
the sentry-cli/no-ad-hoc-id-arguments lint rule rejects hand-written ID
parsing (parseSlashSeparatedArg, HEX_ID_RE, ...) in commands and kinds
defined outside ID_KINDS. A kind without names gets a hint to search
(search) when text is passed for an ID: validate() throws it, so it comes
after any recovery.
Some kinds name items without an ID (IdKind.names): a dashboard by its
title (ID_KINDS.dashboard; unquoted words after a target are one title),
an issue by search text (ID_KINDS.issue). Then what is not an ID
comes back as name. A bare first word before a name comes back as
nameWithTarget too: the parser does not decide whether it names an org, so
dashboard commands check once they resolve (resolveDashboardInput), and
fall back to the whole input as the title. Dashboard commands take
dashboardArguments from commands/dashboard/arguments.ts; dashboard list
reads a target and a title filter instead (parseDashboardListArgs).
event view checks for an issue before an event ID, with
ID_KINDS.issue.distinctForm (parseEventViewInput).
test/lib/argument-conventions.test.ts feeds the same inputs to every
command that takes IDs, issues, or releases, through the command's own
positional; add a new command to its tables, and encode a legitimate
difference (such as needsProject) in its row.
Issue positionals: issue IDs, short IDs, selectors, issue URLs, and
search text are parsed by parseIssueArgument (src/lib/issue-arguments.ts)
with ID_KINDS.issue. An identifier is one whole token, which
src/lib/issue-identifier.ts takes apart with grammar/issue-argument.pegjs;
text with spaces is a search, which resolves to the one issue it matches.
A command that takes one issue declares issuePositional({ command }) (or
issueParameter inside a tuple) from src/commands/issue/positional.ts: func takes an
IssueTarget and awaits target.resolved for the org and issue. A command
that resolves it another way (resolveOrgAndIssueId, or recovery hints that
keep its other arguments) passes requireIssueArgument(target.parsed, ...)
instead of the raw string.
A command that takes several issues (issue view, issue explain,
issue merge) declares issuesPositional(...): every whitespace-separated
word is an issue (no search text), parsed before func runs, and func
resolves each IssueArgument from await issues.resolved itself.
import { ID_KINDS, parseIdArgument } from "../../lib/id-arguments.js";
// ["my-org/my-project/abc123..."] → { target: "my-org/my-project", id: "abc123...", warnings: [] }
const { target, id, baseUrl, warnings } = parseIdArgument(args, ID_KINDS.replay, {
usageHint: USAGE_HINT,
});
const parsed = parseOrgProjectInput(target);Reference: replay/view.ts, span/view.ts, log/view.ts, and
trace-target.ts for trace targets.
Target positionals: A single [<org>/][<project>] positional uses a
parser from src/lib/target-positional.ts, which binds the resolver:
orgOptionalTarget (org, optional project), projectBoundTarget (one org and
project), or orgProjectTarget(resolve) for a command-specific resolver (see
project view). An org alone (org view) uses orgTarget; a
boundArguments parser reads one with parseOrgInput and resolves it with
resolveOrgInput (see trial start). func takes the matching type
(OrgOptionalTarget, ProjectBoundTarget, OrgProjectTarget<R>,
OrgTarget), reads the parsed form as
target.parsed (e.g. target.parsed.type), and gets the org and project
with await target.resolved. It never calls parseOrgProjectArg or a resolver
itself, and an omitted target arrives as auto-detect. The
sentry-cli/require-target-resolution lint rule fails a function that takes
one of these types and never reads .resolved. It also rejects, in commands,
what leaves a target for func to parse: parseOrgProjectArg, the
resolve*FromArg helpers, and withCompletionTarget (the bound parsers tag
the kind). Neither rule has an allow-list: no command may
parse a target or an ID by hand.
Release positionals: [<org>/]<version> uses
releaseParameter(USAGE_HINT, brief) from src/commands/release/parse.ts
inside a tuple positional. func takes a ReleaseTarget and gets
{ version, org, detectedFrom } from await target.resolved; a blank version
fails the parse.
Alert rule positionals: <org>/<project>/<rule-id-or-name> (issue
alerts) and <org>/<rule-id-or-name> (metric alerts) use
issueRuleParameter / metricRuleParameter from the alert rule-resolve.ts
files inside a tuple positional. Rule names may contain spaces, so they keep
their own syntax instead of ID_KINDS. func takes an AlertRuleArgument
and gets { ref, target } from await rule.resolved.
List commands take their target from listTargetPositional() in
src/lib/list-command.ts (LIST_TARGET_POSITIONAL,
LIST_ORG_TARGET_POSITIONAL, or listTargetPositional({ orgScoped, names, multi }) for org-only completion, name globs, and comma-separated projects).
dispatchOrgScopedList resolves each target mode its own way, so func
takes a ListCommandTarget and passes const { parsed, filter } = await target.resolved to it; resolving only applies a pasted URL's server. The
lint rule covers ListCommandTarget too.
Resolvers need no command name: they read it from this.commandPrefix, which
Stricli's forCommand and the SDK invoker set (test mock contexts set it
too). In a destructive command (buildDeleteCommand, or buildCommand with
destructive: true), an auto-detected target fails before func runs, so
those commands don't call requireExplicitTarget for it.
boundPositional in src/lib/positional-parse.ts builds these parsers.
Stricli runs the parse in its argument scanner, so invalid input fails like
any other argument: Failed to parse "<input>" for <placeholder>: … and exit
code 252. The parse must have no side effects; anything that changes state
(such as applying a pasted Sentry URL's server) goes in the resolver, which
runs on the first resolved read, inside func after the auth guard.
target.raw is what the user typed; shared code that handles any positional
uses rawPositional(value). buildCommand runs these parsers on positionals
that arrive as strings or are omitted, so the SDK invoke path and tests that
call command.loader() keep passing raw strings.
Arity-dependent positionals: when what an argument means depends on how
many were given ([<org>/<project>] <replay-id>, log list's optional
trailing trace ID), declare the positional with boundArguments from
src/lib/positional-parse.ts (see replayArguments in replay/view.ts,
log list, and trace view). It is an array positional whose parse sees every argument at
once and whose resolve binds like a target positional's: func takes one
BoundArguments<Parsed, Resolved> (alias it per command and add the alias to
the lint rule's type list), reads target.parsed, and awaits
target.resolved. A Stricli parser sees one argument at a time, so
buildCommand runs the parse before func (and before the auth guard). A
parse error becomes Stricli's ArgumentParseError, which prints and exits
252 like a scanner error; next to a help argument it turns into the
command's help instead. The same purity rule applies: return a pasted URL's server
(baseUrl) and any warnings from the parse, and apply them in resolve
(parseTraceTargetInput and parseDualModeInput in trace-target.ts do
this for trace targets). Commands that recover a malformed ID (trace view,
span view, log view, event view) parse it unvalidated and validate or recover it in
resolve, which can look the prefix up: parseTraceArgumentsInput and
resolveTraceArguments do this for trace IDs. A missing ID or a bad target
still fails the parse.
Which argument is the ID and which is its target is decided by
grammar/id-arguments.pegjs, not by counting arguments in TypeScript. The
grammar knows no kind of ID: the command's ID_KINDS entry tells it what an
ID looks like, so the syntax cannot drift between commands. It accepts any
input; errors come from parseIdArguments, never Peggy's own syntax errors.
Shell completion: Every visible positional and every visible
value-taking flag declares a completion kind, a key of COMPLETERS in
src/lib/complete.ts; test/lib/completion-coverage.test.ts walks the
route tree and fails, naming the command and the placeholder or flag, for
any without one. The sentry-cli/require-completion-parser lint rule
rejects, in src/commands/ and src/lib/, the parsers that cannot carry a
kind: built-ins (parse: String, Number, Boolean, Date, parseInt,
JSON.parse, …), inline functions (parse: (value) => …), and
boundArguments without a kind. A flag with nothing to suggest says so
with parseText or textParser(parse). The rare parameter that must stay
without a kind goes in lint-rules/completion-exemptions.json with its
file, command, parameter (--flag or <placeholder>), and reason; both the
lint rule and the coverage test read it, and the test fails on an entry
that no longer needs exempting. An oxlint-disable-next-line sentry-cli/require-completion-parser -- reason comment silences only the
lint rule; the coverage test cannot see comments, so the parameter still
needs a kind or an allow-list entry. Pick the parser by what the argument
takes:
| Argument | Parser | Completes |
|---|---|---|
| Org, project, issue, dashboard target | the bound parsers above (boundPositional(kind, …), boundArguments({ kind, … }), listTargetPositional, issuePositional, releaseParameter) |
ORG_TARGET org slugs; ORG_PROJECT_TARGET org/, org/project, aliases; ISSUE_TARGET plus cached issue short IDs; DASHBOARD_TARGET cached dashboards |
File or directory path (--output, --ignore-file) |
parseFilePath |
entries of the partial's directory, directories with a trailing / |
| Directory | parseDirectoryPath |
directories only |
Target or directory (init) |
boundArguments({ kind: ORG_PROJECT_OR_DIRECTORY_TARGET, … }) |
org/project targets, or directories once the word looks like a path (./, /, ~/), so a suggestion is never read as a target |
| Fixed values | choiceParser(values), or choiceParser(values, parse) to keep a command's own validating parser (choiceParser(VALID_SORT_VALUES, parseSort)). Take the values from the module that defines them |
those values; a variadic positional completes only its first word (cli defaults <key> <value>). Without parse the parser returns its input, so the command still validates it |
Comma-separated fixed values (--scope, --features) |
choiceListParser(values) (or (values, parse)) |
the next item of the list, leaving out the items already in it (org:read,org:w → org:read,org:write) |
| Platform | parsePlatform |
platform identifiers, common ones first |
Something that already exists in Sentry, as a flag value (--environment, --release, --team, --owner, --integration, --app, --dsn, --workflow, --project, issue tags <key>) |
parseEnvironment, parseEnvironmentList, parseRelease, parseTeam, parseOwner (team:<id>), parseIntegration, parseSentryApp, parseAppLinkField (name=value), parseDsn, parseWorkflowList, parseProjectSlug, parseProjectList, parseTagKey |
cached entities of the org the command's target names, else of every org, each once; the *List parsers complete the last item of a comma-separated list |
Field or aggregate of a dataset (explore --field/--sort, dashboard widget --group-by/--query/--sort, alert metrics --aggregate) |
fieldParser, fieldOrAggregateParser, aggregateParser (fn(field)), or aggregateShorthandParser (fn:field), given a DatasetCompletion: what completes on each dataset (start from DATASET_FIELDS), the command's --dataset aliases, and its default dataset |
the fields of the dataset the --dataset typed names (else the default; every dataset when there is none): cached attribute keys of spans, logs, and trace metrics, cached issue tag keys for errors, or a fixed set (replays), matched one . segment at a time (span.d finds span.duration, never span.op); then its whole aggregates (aggregates), and its aggregate functions as fn(), and fn(field) once a name is typed in full (p95 → p95(span.duration)). Widgets take the functions of aggregateFunctionsOf in types/dashboard.ts; alert metrics --aggregate takes the narrower ones metric alerts accept (METRIC_ALERT_FIELD_COMPLETION). A word starting with - completes flag names, so a descending sort's - is typed after completing |
Issue as a flag value (issue merge --into) |
parseIssueReference |
what ISSUE_TARGET completes |
Git ref, commit, or repository (--base-ref, --head-sha, --repo, --commit REPO@SHA, set-commits --from) |
parseVcsRef (branch and tag names as the remote knows them), parseGitRef (refs git resolves locally), parseGitCommit, parseGitRepo (owner/repo of the remotes), parseRepoCommitList |
read from git in the shell's directory; nothing outside a work tree |
A structured value with its own grammar (issue archive --until, api <endpoint>) |
parseArchiveUntil, parseApiEndpoint |
the grammar: forever, auto, 10x, 10x/5m, …; API schema paths one segment at a time, with cached orgs and projects for their placeholders |
Local session (local session view, local event list, …) |
parseLocalSession |
the running Local daemon's session IDs, described by label and state, then labels only one session has (none with whitespace). Read from local-sessions.json, an owner-only snapshot the daemon rewrites when a session is created, closed, or expires and removes on shutdown; it holds IDs, labels, states, and expiries, never capabilities, and is ignored once its daemon's PID has exited. <session envelope> and <session event> complete only the session: envelope and event IDs exist only in the daemon's memory |
| Stored account | parseAccountSelector |
stored accounts (auth switch, auth logout, org list --account) |
| CLI command path | parseCommandPath |
route names, one segment per word (help) |
| URL | parseUrl (the command validates it) |
nothing |
| Free text, IDs with no cache, names of new things | parseText |
nothing. Before reaching for it, check whether the value is really free: a value with a fixed set (in the module that validates it), a grammar, or a source (cache, git, schema) gets a kind that completes it |
Numbers and other values a command parses itself (parsePort, parseLimit) |
textParser(parse) |
nothing |
Import the pre-tagged parsers; never tag String or write
withCompletionTarget in a command (the lint rules above reject it there),
and never leave parse: String on a flag. Org, project, and issue targets
stay bound parsers. enum flags complete their values like
choiceParser. buildCommand tags the injected --fields once: with an
output schema, it completes the schema's field paths, nested ones in dot
notation (--fields id,ti → id,title, project.s → project.slug);
without one, nothing.
Shared value parsers are not tagged where they live; PARSER_TARGETS in
completion-target.ts lists them instead, with a kind or the values they
accept, so every flag that parses with sanitizeIssueQuery completes issue
search terms (is:unr → is:unresolved), every flag that parses with
parsePeriod completes relative periods, parseCursorFlag completes
next/prev/first, parseLogSort its directions, numberParser
nothing, and parseLoginEmail completes stored accounts. Fixed values
(search keys, period units) come from the modules that define them
through COMPLETION_VALUES. A new shared parser goes in PARSER_TARGETS;
its module must not import completion-target.ts, or the two would load
in a cycle.
Cached entities (issues, dashboards, environments, releases, teams,
monitors, alert workflows, integrations, Sentry Apps and their issue-link
form fields, DSNs, issue tag keys, attribute keys of each trace item
dataset) come from the completion_cache table. The
authenticated fetch in src/lib/sentry-client.ts offers every API response to
src/lib/api/completion-seed.ts, which stores entities from the @sentry/api
operations it lists (by URL template, type-checked against the SDK, and the
query parameters that pick what a response holds, such as the attribute
keys' dataset), so API functions never seed by hand. Attribute keys are
also fetched ahead of time by src/lib/completion-warmup.ts, which
auth login and cli setup call: the default org plus up to three
recently used orgs, all datasets in parallel, aborted after a second. To complete a new
entity type, add it to CompletionEntityKind, its operations in completion-seed.ts (one
response may seed several kinds), a tagged parser, and an entityCompleter
(target positionals) or flagEntityCompleter (flag values) entry in
src/lib/complete.ts. To add a
kind that is not an entity, add its entry to COMPLETERS
(src/lib/complete.ts), whose keys are the CompletionTarget type, a
*_TARGET name for it in completion-target.ts, and a pre-tagged parser.
pnpm run generate:completions collects every command's kinds, choice
values, and visible flags (with briefs and aliases), and every route group's
subcommands, into the gitignored src/generated/completion-targets.json
that the __complete fast path reads. Words that name a nested route group
(alert issues) complete its subcommands, and a partial starting with -
completes the command's flags not given yet. The bash, zsh, and fish
scripts (src/lib/completions.ts) embed only the command and subcommand
names and ask __complete for every later word, so every shell completes
every depth the same way. Completers must stay light (synchronous node:fs
reads, SQLite, no network); ones that need a parser (arg-parsing.ts, the
search grammar) import it when called, so the fast path only loads it for
the arguments that need it.
The fast path runs on every TAB press, so it loads only src/lib/complete.ts
and what that module imports. src/bin.ts and the npm dist/bin.cjs check
for __complete first and call runCompletion without loading cli.ts;
the npm package ships it as the separate dist/complete.cjs bundle. That
graph must stay free of @sentry/node, telemetry, the API client, and the
command tree: the database modules get spans and cache metrics through
withDbSpan and recordCacheHit from src/lib/db/index.ts, which require
telemetry lazily and skip it when telemetry is off (runCompletion turns it
off; dist/complete.cjs is built with SENTRY_CLI_COMPLETION_BUILD set to
true, so the bundle leaves telemetry out), and logger.ts takes the Sentry
reporter from initSentry.
test/script/cli-startup.test.ts bundles the graph and fails on any package
not in its COMPLETION_PACKAGES list, naming the importer, or on more source
modules than COMPLETION_MODULE_BUDGET. Defer a heavy dependency with a
dynamic import() in the completer that needs it rather than adding it to
the list.
Split by argument type — do not mix the conventions:
- Required positionals → space-separated variadic. Declare the arg as
variadic and accept repeated tokens:
issue merge A B C,project create web:javascript api:python-django. Do not split positional values on commas; commas may be part of the value. Every project passed toproject createrequires aname:platformpair — there is no space-separated form, with or without an explicit org. Project names cannot contain whitespace. - Exception: opted-in
org/projectlist selectors. Commands whose API can query several projects together may parse their single optional target token as a list because Sentry slugs cannot contain commas. For example,issue list acme/web,apiselects two projects. Pass{ multi: true }toparseOrgProjectArg; never enable it for mutation or single-project commands. - Optional flags → comma-separated (sometimes also repeatable). Split the
flag value on
,:--features errors,tracing, set-commits--path a,b,auth login --scope a,b. Usevalue.split(",")(repeatable array flags:flags.x.flatMap((v) => v.split(","))).
Reference: project/create.ts, release/set-commits.ts (--path), auth/login.ts (--scope)
All non-trivial human output must use the markdown rendering pipeline:
- Build markdown strings with helpers:
mdKvTable(),colorTag(),escapeMarkdownCell(),renderMarkdown() - NEVER use raw
muted()/ chalk in output strings — usecolorTag("muted", text)inside markdown - Tree-structured output (box-drawing characters) that can't go through
renderMarkdown()should use theplainSafeMutedpattern:isPlainOutput() ? text : muted(text) isPlainOutput()precedence:SENTRY_PLAIN_OUTPUT>NO_COLOR>FORCE_COLOR(TTY only) >!isTTYisPlainOutput()lives insrc/lib/formatters/plain-detect.ts(re-exported frommarkdown.tsfor compat)
Reference: formatters/trace.ts (formatAncestorChain), formatters/human.ts (plainSafeMuted)
Never parse markdown structure (headings, code fences, lists) with regexes. Use the marked lexer, which is already a dependency:
- Scripts: use the helpers in
script/markdown.ts(markdownHeadings(),blankCodeBlocks()), ormarked.lexer()directly for other token types. - Runtime code: follow
src/lib/formatters/markdown.tsandsrc/lib/release-notes.ts. - Regexes are fine on the plain text of a parsed token, such as matching heading text.
The sentry-cli/no-regex-markdown-parsing lint rule flags regexes that match headings or code fences in src/ and script/.
Mutation (create/delete) commands use shared infrastructure from src/lib/mutate-command.ts,
paralleling list-command.ts for list commands.
Delete commands MUST use buildDeleteCommand() instead of buildCommand(). It:
- Auto-injects
--yes,--force,--dry-runflags with-y,-f,-naliases - Runs a non-interactive safety guard before
func()— refuses to proceed if stdin is not a TTY and--yes/--forcewas not passed (dry-run bypasses) - Options to skip specific injections (
noForceFlag,noDryRunFlag,noNonInteractiveGuard)
import { buildDeleteCommand, confirmByTyping, isConfirmationBypassed, requireExplicitTarget } from "../../lib/mutate-command.js";
export const deleteCommand = buildDeleteCommand({
// Same args as buildCommand — flags/aliases auto-injected
async *func(this: SentryContext, flags, target) {
requireExplicitTarget(parsed, "Entity", "sentry entity delete <target>");
if (flags["dry-run"]) { yield preview; return; }
if (!isConfirmationBypassed(flags)) {
if (!await confirmByTyping(expected, promptMessage)) return;
}
await doDelete();
},
});Create commands import DRY_RUN_FLAG and DRY_RUN_ALIASES for consistent dry-run support:
import { DRY_RUN_FLAG, DRY_RUN_ALIASES } from "../../lib/mutate-command.js";
// In parameters:
flags: { "dry-run": DRY_RUN_FLAG, team: { ... } },
aliases: { ...DRY_RUN_ALIASES, t: "team" },Key utilities in mutate-command.ts:
isConfirmationBypassed(flags)— true if--yesor--forceis setguardNonInteractive(flags)— throws in non-interactive mode without--yesconfirmByTyping(expected, message)— type-out confirmation promptrequireExplicitTarget(parsed, entityType, usage)— blocks auto-detect for safetyDESTRUCTIVE_FLAGS/DESTRUCTIVE_ALIASES— spreadable bundles for manual use
All list commands with API pagination MUST use the shared cursor-stack
infrastructure for bidirectional pagination (-c next / -c prev):
import { LIST_CURSOR_FLAG } from "../../lib/list-command.js";
import {
buildPaginationContextKey, resolveCursor,
advancePaginationState, hasPreviousPage,
} from "../../lib/db/pagination.js";
export const PAGINATION_KEY = "my-entity-list";
// In buildCommand:
flags: { cursor: LIST_CURSOR_FLAG },
aliases: { c: "cursor" },
// In func():
const contextKey = buildPaginationContextKey("entity", `${org}/${project}`, {
sort: flags.sort, q: flags.query,
});
const { cursor, direction } = resolveCursor(flags.cursor, PAGINATION_KEY, contextKey);
const { data, nextCursor } = await listEntities(org, project, { cursor, ... });
advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor);
const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey);
const hasMore = !!nextCursor;Cursor stack model: The DB stores a JSON array of page-start cursors
plus a page index. Each entry is an opaque string — plain API cursors,
compound cursors (issue list), or extended cursors with mid-page bookmarks
(dashboard list). -c next increments the index, -c prev decrements it,
-c first resets to 0. The stack truncates on back-then-forward to avoid
stale entries. "last" is a silent alias for "next".
Hint rules: Show -c prev when hasPreviousPage() returns true.
Show -c next when hasMore is true. Include both nextCursor and
hasPrev in the JSON envelope.
Navigation hint generation: Use paginationHint() from
src/lib/list-command.ts to build bidirectional navigation strings.
Pass it pre-built prevHint/nextHint command strings and it returns
the combined "Prev: X | Next: Y" string (or single-direction, or "").
Do NOT assemble navParts arrays manually — the shared helper ensures
consistent formatting across all list commands.
import { paginationHint } from "../../lib/list-command.js";
const nav = paginationHint({
hasPrev,
hasMore,
prevHint: `sentry entity list ${org}/ -c prev`,
nextHint: `sentry entity list ${org}/ -c next`,
});
if (items.length === 0 && nav) {
hint = `No entities on this page. ${nav}`;
} else if (hasMore) {
header = `Showing ${items.length} entities (more available)\n${nav}`;
} else if (nav) {
header = `Showing ${items.length} entities\n${nav}`;
}Three abstraction levels for list commands (prefer the highest level that fits your use case):
-
buildOrgListCommand(team/repo list) — Fully automatic. Pagination hints, cursor management, JSON envelope, and human formatting are all handled internally. New simple org-scoped list commands should use this. -
dispatchOrgScopedListwith overrides (project/issue list) — Automatic for most modes; custom"org-all"override callsresolveCursor+advancePaginationState+paginationHintmanually. -
buildListCommandwith manual pagination (trace/span/dashboard list) — Command manages its own pagination loop. Must callresolveCursor,advancePaginationState,hasPreviousPage, andpaginationHintdirectly.
Auto-pagination for large limits:
When --limit exceeds API_MAX_PER_PAGE (100), list commands MUST transparently
fetch multiple pages to fill the requested limit. Always do this with
paginate() from src/lib/api/infrastructure.ts; never write the cursor loop
yourself. The sentry-cli/no-manual-pagination lint rule rejects loops that
follow cursors and count toward a limit.
const { data, nextCursor } = await paginate(
{ limit: flags.limit, cursor },
(perPage, pageCursor) =>
listPaginated(org, { perPage, cursor: pageCursor }),
);Pass perPage to the request unchanged. Sentry's page-number paginators
(OffsetPaginator, used by projects, teams, members, repositories, and
dashboard revisions) encode the page size in the cursor
(<per_page>:<page>:<is_prev>), so a cursor is only valid with the
per_page that produced it. paginate() reads that page size back from the
cursor, so a later -c next reuses it even when --limit changes. A fresh
walk splits --limit evenly over the fewest requests (201 → 3 × 67).
The cursors paginate() returns are Sentry cursors, so every CLI version and
the API accept them, and stored -c next history works across upgrades and
downgrades. When a call stops inside a page, the cursor points at that page
and records how many of its rows were already returned in is_prev, in
binary with _0 for 1 and 0 for 0 after a leading 0 (84:2:0_00_000_00
skips 82 = 0b1010010 rows). Sentry parses is_prev with Python's int(),
which reads any zeros joined by single underscores as 0, so it is still a
next-page cursor. encodeSkip() in src/lib/api/infrastructure.ts documents
the format.
paginate() refetches the page and skips them; anything else that sends the
cursor (with the page size it encodes, if any) gets those rows again, never
fewer. Keep returned cursors valid Sentry
cursors. Test pagination changes against the paginator models in
test/lib/paginate.property.test.ts.
Reference template: trace/list.ts, span/list.ts, dashboard/revisions.ts
Use shared validators from src/lib/hex-id.ts:
validateHexId(value, label)— 32-char hex IDs (trace IDs, log IDs). Auto-strips UUID dashes.validateSpanId(value)— 16-char hex span IDs. Auto-strips dashes.validateTraceId(value)— thin wrapper aroundvalidateHexIdinsrc/lib/trace-id.ts.
All normalize to lowercase. Throw ValidationError on invalid input.
Use "date" for timestamp-based sort (not "time"). Export sort types from the API layer (e.g., SpanSortValue from api/traces.ts), import in commands. This matches issue list, trace list, and span list.
All command docs and skill files are generated via pnpm run generate:docs (which runs generate:banner, generate:completions, generate:command-docs, generate:skill, then generate:docs-sections). This runs automatically as part of dev, build, typecheck, and test scripts.
- Command docs (
apps/cli-docs/src/content/docs/commands/*.md) and the configuration page (apps/cli-docs/src/content/docs/configuration.md) are gitignored and generated from CLI metadata + hand-written fragments inapps/cli-docs/src/fragments/(paths live inscript/paths.ts). - Skill files (
plugins/sentry-cli/skills/sentry-cli/) are committed (consumed by external plugin systems) and auto-committed by CI when stale. - Examples belong to the command: add them to
docs.examples({ description, command }[], seesrc/commands/agent-conversation/list.ts). They render in--help, the generated docs, and the skill references. Don't hand-write examples infullDescription(anExamples:block, or indentedsentry …lines under any label) or an Examples heading in a fragment; thesentry-cli/no-examples-in-full-descriptionlint rule andpnpm run check:fragmentsreject both. - Target syntax belongs to the positional: describe accepted target forms in the positional
brief(useORG_ALL_TARGET_BRIEForPROJECT_TARGET_BRIEFfromsrc/lib/list-command.tsfor[<org>/][<project>]targets), not in aTarget patterns:block. The brief renders in--help, the generated docs, and the skill references. - Use fragments in
apps/cli-docs/src/fragments/commands/only for prose the command metadata can't carry: guides, permissions, caveats. pnpm run check:fragmentsalso rejects any shell code block in a fragment that runs asentrycommand, whatever its heading. Multi-step workflows belong indocs.examplestoo: passcommandan array of steps (seerelease createandlocal run), and every step is checked and shown in--help, the docs, and the skill. Reserve<!-- allow-commands: <reason> -->(on the line directly above the block) for sample output and guides that interleave non-sentrycommands.pnpm run check:fragmentsvalidates fragment ↔ route consistency.- Positional
placeholdervalues must be descriptive:"org/project/trace-id"not"args". - Don't restate a value flag's
defaultin itsbrief; help and docs already show it (test/lib/flag-briefs.test.ts). pnpm run check:doc-examplesalso checks string-literalsdk.run(…)calls in TypeScript fences and@exampleJSDoc (src/index.ts). They are found by parsing with the TypeScript compiler (script/sdk-run-calls.ts); don't add regex-based source parsing.- Lists that mirror code (scanned DSN extensions, alias examples, destructive commands, detected agents, the
library-usage.mdsdk.run()sample built fromissue listexamples) are generated sections inscript/generate-docs-sections.ts; add new mirrors there instead of hand-writing them.
All config and API types use Valibot schemas:
import { type InferOutput, object, string, optional, number } from "valibot";
export const MySchema = object({
field: string(),
optional: optional(number()),
});
export type MyType = InferOutput<typeof MySchema>;
// Validate data
import { safeParse } from "valibot";
const result = safeParse(MySchema, data);
if (result.success) {
// result.output is typed
}- Define Valibot schemas alongside types in
src/types/*.ts - Key type files:
sentry.ts(API types),config.ts(configuration),oauth.ts(auth flow),seer.ts(Seer AI) - Re-export from
src/types/index.ts - Use
typeimports:import type { MyType } from "../types/index.js"
Use the upsert() helper from src/lib/db/utils.ts to reduce SQL boilerplate:
import { upsert, runUpsert } from "../db/utils.js";
// Generate UPSERT statement
const { sql, values } = upsert("table", { id: 1, name: "foo" }, ["id"]);
db.query(sql).run(...values);
// Or use convenience wrapper
runUpsert(db, "table", { id: 1, name: "foo" }, ["id"]);
// Exclude columns from update
const { sql, values } = upsert(
"users",
{ id: 1, name: "Bob", created_at: now },
["id"],
{ excludeFromUpdate: ["created_at"] }
);All CLI errors extend the CliError base class from src/lib/errors.ts:
// Error hierarchy in src/lib/errors.ts
// Exit codes are defined in the EXIT constant object — use EXIT.* constants
// when constructing errors, never hardcode numeric exit codes outside errors.ts.
CliError (base, exitCode=1)
├── HostScopeError (exitCode=13)
├── ApiError (exitCode=30 — HTTP/API failures)
├── AuthError (exitCode=10–12 by reason — 'not_authenticated' | 'expired' | 'invalid')
├── ConfigError (exitCode=20 — configuration/DSN)
├── OutputError (exitCode=60 — data rendered, but operation failed)
├── ContextError (exitCode=22 — missing context)
├── ResolutionError (exitCode=23 — value provided but not found)
├── ValidationError (exitCode=21 — input validation)
├── DeviceFlowError (exitCode=51 — OAuth flow)
├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled')
├── TimeoutError (exitCode=31 — operation timed out)
├── UpgradeError (exitCode=50 — upgrade failures)
└── WizardError (exitCode=61–64 by workflow step — init wizard error)Exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. See
EXITinsrc/lib/errors.tsand https://cli.sentry.dev/exit-codes/ for the full reference.
Choosing between ContextError, ResolutionError, and ValidationError:
| Scenario | Error Class | Example |
|---|---|---|
| User omitted a required value | ContextError |
No org/project provided |
| User provided a value that wasn't found | ResolutionError |
Project 'cli' not found |
| User input is malformed | ValidationError |
Invalid hex ID format |
ContextError rules:
commandmust be a single-line CLI usage example (e.g.,"sentry org view <slug>")- Constructor throws if
commandcontains\n(catches misuse in tests) - Pass
alternatives: []when defaults are irrelevant (e.g., for missing Trace ID, Event ID) - Use
" and "inresourcefor plural grammar:"Trace ID and span ID"→ "are required"
CI enforcement: pnpm run check:errors scans for ContextError with multiline commands and CliError with ad-hoc "Try:" strings. Silent catch blocks are enforced separately by the no-silent-catch CLI Oxlint rule (see below).
// Usage examples
throw new ContextError("Organization", "sentry org view <org-slug>");
throw new ContextError("Trace ID", "sentry trace view <trace-id>", []); // no alternatives
throw new ResolutionError("Project 'cli'", "not found", "sentry issue list <org>/cli", [
"No project with this slug found in any accessible organization",
]);
throw new ValidationError("Invalid trace ID format", "traceId");Fuzzy suggestions in resolution errors:
When a user-provided name/title doesn't match any entity, use fuzzyMatch() from
src/lib/fuzzy.ts to suggest similar candidates instead of listing all entities
(which can be overwhelming). Show at most 5 fuzzy matches.
Reference: resolveDashboardId() in src/commands/dashboard/resolve.ts.
Silent catch blocks are prohibited in src/ production code. Both empty catch {} blocks and blocks that only return without logging discard errors and make debugging impossible.
Every catch block must either:
- Re-throw the error
- Log with
log.debug()orlog.warn()for diagnostic visibility - Return a fallback value with a
log.debug()call explaining the suppression
// WRONG — error vanishes silently
try { data = await fetchOptionalData(); }
catch { return []; }
// RIGHT — error is visible in debug logs
try { data = await fetchOptionalData(); }
catch (error) {
log.debug("Failed to fetch optional data", error);
return [];
}Use logger.withTag("command-name") for tagged logging in command files.
CI enforcement: the no-silent-catch CLI Oxlint rule
(lint-rules/cli-oxlint-plugin.js, registered in the root .oxlintrc.json) flags catch
blocks — statement and .catch() form — that are empty, comment-only, or
return-only without surfacing the error. The pre-existing backlog is
grandfathered in place with inline
// oxlint-disable-next-line sentry-cli/no-silent-catch -- <reason> comments.
Because pnpm run lint reports unused suppressions as errors, an orphaned
suppression (left behind when a grandfathered catch is fixed) fails — so the backlog
can only shrink, the same ratchet the old JSON baseline provided, with no
separate script or baseline file to maintain. Fix a grandfathered catch by
adding logging/re-throwing and deleting its suppression line; only add a new
suppression for a genuinely intentional silent catch, with a real reason.
When a user provides the wrong type of identifier (e.g., an issue short ID where a trace ID is expected), commands should auto-recover when the user's intent is unambiguous:
- Detect the actual entity type using helpers like
looksLikeIssueShortId(),SPAN_ID_RE,HEX_ID_RE, or non-hex character checks. - Resolve the input to the correct type (e.g., issue → latest event → trace ID).
- Warn via
log.warn()explaining what happened. - Show the result with a return
hintnudging toward the correct command.
When recovery is ambiguous or impossible, keep the existing error but add entity-aware suggestions (e.g., "This looks like a span ID").
Detection helpers:
looksLikeIssueShortId(value)— uppercase dash-separated (e.g.,CLI-G5)SPAN_ID_RE.test(value)— 16-char hex (span ID)HEX_ID_RE.test(value)— 32-char hex (trace/event/log ID)/[^0-9a-f]/.test(normalized)— non-hex characters → likely a slug/name
Reference implementations:
event/view.ts— issue short ID → latest event redirectspan/view.ts—traceId/spanIdslash format → auto-splittrace/view.ts— issue short ID → issue's trace redirecthex-id.ts— entity-aware error hints invalidateHexId/validateSpanId
All config operations are async. Always await:
const token = await getAuthToken();
const isAuth = await isAuthenticated();
await setAuthToken(token, expiresIn);Before creating a new src/lib/*.ts utility file, check whether existing shared modules already cover your use case:
| If you need... | Check first... |
|---|---|
| Duration formatting | src/lib/formatters/time-utils.ts (formatDurationCompact, formatDurationVerbose) |
| Hex ID validation/normalization | src/lib/hex-id.ts (validateHexId, tryNormalizeHexId, normalizeHexId) |
| Relative time display | src/lib/formatters/time-utils.ts (formatRelativeTime) |
| Table/markdown output | src/lib/formatters/ directory |
| Pagination | src/lib/db/pagination.ts, src/lib/list-command.ts |
| Error classes | src/lib/errors.ts (never create ad-hoc error types) |
| Search query building | src/lib/search-query.ts (wraps @sentry/toolkit-core/search-query), src/lib/arg-parsing.ts |
If an existing module covers ≥80% of what you need, extend it with new exported functions rather than creating a new file. New files are appropriate when the domain is genuinely new (e.g., replay-search.ts for replay-specific field resolution).
Every new src/lib/**/*.ts file must start with a module-level JSDoc comment describing the module's purpose.
- Use
.jsextension for local imports (ESM requirement) - Group: external packages first, then local imports
- Use
typekeyword for type-only imports
import { object, string, optional } from "valibot";
import { buildCommand } from "../../lib/command.js";
import type { SentryContext } from "../../context.js";
import { getAuthToken } from "../../lib/db/auth.js";parseOrgProjectArg() owns target syntax: a value without / is a
project-search; <org>/ is org-all; <org>/<project> is explicit.
classifyProjectSearchTarget() owns the lookup order for project-search:
exact project(s), exact organization, fuzzy project, then not found.
Commands select a capability wrapper instead of performing API lookups:
resolveProjectBoundSlug(),resolveProjectBoundTarget(), andresolveProjectBoundFromArg()require one project. An organization match is an actionable error.resolveProjectBoundTargets()resolves one or more project targets.resolveOrgOptionalTarget()/resolveOrgOptionalFromArg()allow either an organization or a project.resolveOrgOnlyTarget()/resolveOrgOnlyFromArg()return the effective organization while preserving project-first precedence for bare targets.resolveProjectIdsInOrg()/findProjectsInOrg()resolve a comma-separatedorg/a,bselector: numeric IDs for a single request (unknown slugs are an error), or the projects themselves plus the slugs that returned 404.
Do not call findProjectsBySlug() or implement org fallback directly in a
command. sentry init is the intentional not-found exception: it calls the
classifier with fuzzy recovery disabled, then treats not-found as a new
project name. Issue-short-ID recovery in commands/issue/utils.ts is not CLI
target resolution and may perform its own project lookup. The
no-direct-target-resolution CLI Oxlint rule enforces this command-layer
boundary.
Two abstraction levels exist for list commands:
-
src/lib/list-command.ts—buildOrgListCommandfactory + shared Stricli parameter constants (LIST_TARGET_POSITIONALandlistTargetPositional,LIST_JSON_FLAG,LIST_CURSOR_FLAG,buildListLimitFlag). Use this for simple entity lists liketeam listandrepo list. -
src/lib/org-list.ts—dispatchOrgScopedListwithOrgListConfigand a 4-mode handler map:auto-detect,explicit,org-all,project-search. Complex commands (project list,issue list) calldispatchOrgScopedListwith anoverridesmap directly instead of usingbuildOrgListCommand.
Key rules when writing overrides:
- Each mode handler receives a
HandlerContext<T>with narrowedparsed,cwd,flags, and optional request-scoped project-search classification. Access parsed fields viactx.parsed.org,ctx.parsed.projectSlug, etc. — no manualExtract<>casts needed. - Commands with extra fields (e.g.,
stderr,setContext) spread the context and add them:(ctx) => handle({ ...ctx, flags, stderr, setContext }). Overridectx.flagswith the command-specific flags type when needed. resolveCursor()must be called inside theorg-alloverride closure, not beforedispatchOrgScopedList, so that--cursorvalidation errors fire correctly for non-org-all modes.- For an unscoped bare target,
dispatchOrgScopedListapplies the shared classifier before dispatch: an organization match becomesorg-all, while project results are passed request-scoped to the handler. Scoped and display-name searches stay inproject-searchand are classified there.
- Standalone list commands (e.g.,
span list,trace list) that don't use org-scoped dispatch wire pagination directly infunc(). See the "List Command Pagination" section above for the pattern.
Different Sentry API endpoints use different project filtering mechanisms. Never apply both simultaneously:
| API Endpoint | Project filter | Helper |
|---|---|---|
Discover/Events (queryEvents) |
project:<slug> in query string |
buildProjectQuery() |
Replay index (listReplays) |
projectSlugs parameter |
Direct parameter |
Issue index (listIssuesPaginated) |
project parameter or query string |
Varies by mode |
When adding a new dataset to explore, verify which filtering mechanism the underlying API expects and handle it in resolveDatasetConfig. The explore command centralizes dataset-specific behavior (sort, query, fetch, field validation) in resolveDatasetConfig — add new datasets there rather than scattering if (dataset === ...) checks through the func body.
- Prefer JSDoc over inline comments.
- Code should be readable without narrating what it already says.
Add JSDoc comments on:
- Every exported function, class, and type (and important internal ones).
- Types/interfaces: document each field/property (what it represents, units, allowed values, meaning of
null, defaults).
Include in JSDoc:
- What it does
- Key business rules / constraints
- Assumptions and edge cases
- Side effects
- Why it exists (when non-obvious)
Inline comments are allowed only when they add information the code cannot express:
- "Why" - business reason, constraint, historical context
- Non-obvious behavior - surprising edge cases
- Workarounds - bugs in dependencies, platform quirks
- Hardcoded values - why hardcoded, what would break if changed
Inline comments are NOT allowed if they just restate the code:
// Bad:
if (!person) // if no person
i++ // increment i
return result // return result
// Good:
// Required by GDPR Article 17 - user requested deletion
await deleteUserData(userId)- ASCII art section dividers - Do not use decorative box-drawing characters like
─────────to create section headers. Use standard JSDoc comments or simple// Section Namecomments instead.
Minimal comments, maximum clarity. Comments explain intent and reasoning, not syntax.
Prefer property-based and model-based testing over traditional unit tests. These approaches find edge cases automatically and provide better coverage with less code.
fast-check Documentation: https://fast-check.dev/docs/core-blocks/arbitraries/
- Model-Based Tests - For stateful systems (database, caches, state machines)
- Property-Based Tests - For pure functions, parsing, validation, transformations
- Unit Tests - Only for trivial cases or when properties are hard to express
| Type | Pattern | Location |
|---|---|---|
| Property-based | *.property.test.ts |
test/lib/ |
| Model-based | *.model-based.test.ts |
test/lib/db/ |
| Unit tests | *.test.ts |
test/ (mirrors src/) |
| E2E tests | *.test.ts |
test/e2e/ |
Tests that need a database or config directory must use useTestConfigDir() from test/helpers.ts. This helper:
- Creates a unique temp directory in
beforeEach - Sets
SENTRY_CONFIG_DIRto point at it - Restores (never deletes) the env var in
afterEach - Closes the database and cleans up temp files
NEVER do any of these in test files:
delete process.env.SENTRY_CONFIG_DIR— This pollutes other test files that load after yoursconst baseDir = process.env[CONFIG_DIR_ENV_VAR]!at module scope — This captures a value that may be stale- Manual
beforeEach/afterEachthat sets/deletesSENTRY_CONFIG_DIR
Why: Vitest runs each test file in a worker pool (see test:unit in package.json), so each file runs in its own worker process. That bounds most cross-file leaks to a single worker, but process.env is still shared within a file's lifecycle — if your afterEach deletes the env var, the next describe/test's module-level code (or a beforeEach that re-reads env) gets undefined, causing TypeError: The "paths[0]" property must be of type string. Also, TEST_TMP_DIR is namespaced by VITEST_POOL_ID in test/constants.ts so parallel workers don't wipe each other's temp state during preload.
// CORRECT: Use the helper
import { useTestConfigDir } from "../helpers.js";
const getConfigDir = useTestConfigDir("my-test-prefix-");
// If you need the directory path in a test:
test("example", () => {
const dir = getConfigDir();
});
// WRONG: Manual env var management
beforeEach(() => { process.env.SENTRY_CONFIG_DIR = tmpDir; });
afterEach(() => { delete process.env.SENTRY_CONFIG_DIR; }); // BUG!Use property-based tests when verifying invariants that should hold for any valid input.
import { describe, expect, test } from "vitest";
import { constantFrom, assert as fcAssert, property, tuple } from "fast-check";
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";
// Define arbitraries (random data generators)
const slugArb = array(constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), {
minLength: 1,
maxLength: 15,
}).map((chars) => chars.join(""));
describe("property: myFunction", () => {
test("is symmetric", () => {
fcAssert(
property(slugArb, slugArb, (a, b) => {
// Properties should always hold regardless of input
expect(myFunction(a, b)).toBe(myFunction(b, a));
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});
test("round-trip: encode then decode returns original", () => {
fcAssert(
property(validInputArb, (input) => {
const encoded = encode(input);
const decoded = decode(encoded);
expect(decoded).toEqual(input);
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});
});Good candidates for property-based testing:
- Parsing functions (DSN, issue IDs, aliases)
- Encoding/decoding (round-trip invariant)
- Symmetric operations (a op b = b op a)
- Idempotent operations (f(f(x)) = f(x))
- Validation functions (valid inputs accepted, invalid rejected)
See examples: test/lib/dsn.property.test.ts, test/lib/alias.property.test.ts, test/lib/issue-id.property.test.ts
Use model-based tests for stateful systems where sequences of operations should maintain invariants.
import { describe, expect, test } from "vitest";
import {
type AsyncCommand,
asyncModelRun,
asyncProperty,
commands,
assert as fcAssert,
} from "fast-check";
import { createIsolatedDbContext, DEFAULT_NUM_RUNS } from "../../model-based/helpers.js";
// Define a simplified model of expected state
type DbModel = {
entries: Map<string, string>;
};
// Define commands that operate on both model and real system
class SetCommand implements AsyncCommand<DbModel, RealDb> {
constructor(readonly key: string, readonly value: string) {}
check = () => true;
async run(model: DbModel, real: RealDb): Promise<void> {
// Apply to real system
await realSet(this.key, this.value);
// Update model
model.entries.set(this.key, this.value);
}
toString = () => `set("${this.key}", "${this.value}")`;
}
class GetCommand implements AsyncCommand<DbModel, RealDb> {
constructor(readonly key: string) {}
check = () => true;
async run(model: DbModel, real: RealDb): Promise<void> {
const realValue = await realGet(this.key);
const expectedValue = model.entries.get(this.key);
// Verify real system matches model
expect(realValue).toBe(expectedValue);
}
toString = () => `get("${this.key}")`;
}
describe("model-based: database", () => {
test("random sequences maintain consistency", () => {
fcAssert(
asyncProperty(commands(allCommandArbs), async (cmds) => {
const cleanup = createIsolatedDbContext();
try {
await asyncModelRun(
() => ({ model: { entries: new Map() }, real: {} }),
cmds
);
} finally {
cleanup();
}
}),
{ numRuns: DEFAULT_NUM_RUNS }
);
});
});Good candidates for model-based testing:
- Database operations (auth, caches, regions)
- Stateful caches with invalidation
- Systems with cross-cutting invariants (e.g., clearAuth also clears regions)
See examples: test/lib/db/model-based.test.ts, test/lib/db/dsn-cache.model-based.test.ts
Use test/model-based/helpers.ts for shared utilities:
import { createIsolatedDbContext, DEFAULT_NUM_RUNS } from "../model-based/helpers.js";
// Create isolated DB for each test run (prevents interference)
const cleanup = createIsolatedDbContext();
try {
// ... test code
} finally {
cleanup();
}
// Use consistent number of runs across tests
fcAssert(property(...), { numRuns: DEFAULT_NUM_RUNS }); // 50 runsUse traditional unit tests only when:
- Testing trivial logic with obvious expected values
- Properties are difficult to express or would be tautological
- Testing error messages or specific output formatting
- Integration with external systems (E2E tests)
When a *.property.test.ts file exists for a module, do not add unit tests that re-check the same invariants with hardcoded examples. Before adding a unit test, check whether the companion property file already generates random inputs for that invariant.
Unit tests that belong alongside property tests:
- Edge cases outside the property generator's range (e.g., self-hosted DSNs when the arbitrary only produces SaaS ones)
- Specific output format documentation (exact strings, column layouts, rendered vs plain mode)
- Concurrency/timing behavior that property tests cannot express
- Integration tests exercising multiple functions together (e.g.,
writeJsonListenvelope shape)
Unit tests to avoid when property tests exist:
- "returns true for valid input" / "returns false for invalid input" — the property test already covers this with random inputs
- Basic round-trip assertions — property tests check
decode(encode(x)) === xfor allx - Hardcoded examples of invariants like idempotency, symmetry, or subset relationships
When adding property tests for a function that already has unit tests, remove the unit tests that become redundant. Add a header comment to the unit test file noting which invariants live in the property file:
/**
* Note: Core invariants (round-trips, validation, ordering) are tested via
* property-based tests in foo.property.test.ts. These tests focus on edge
* cases and specific output formatting not covered by property generators.
*/import { describe, expect, test, vi } from "vitest";
describe("feature", () => {
test("should return specific value", async () => {
expect(await someFunction("input")).toBe("expected output");
});
});
// Mock modules when needed
vi.mock("./some-module", () => ({
default: () => "mocked",
}));| What | Where |
|---|---|
| Add new command | src/commands/<domain>/ |
| Add API types | src/types/sentry.ts |
| Add config types | src/types/config.ts |
| Add Seer types | src/types/seer.ts |
| Add utility | src/lib/ |
| Add DSN language support | TEXT_EXTENSIONS in src/lib/scan/constants.ts |
| Add DB operations | src/lib/db/ |
| Build scripts | script/ |
| Add property tests | test/lib/<name>.property.test.ts |
| Add model-based tests | test/lib/db/<name>.model-based.test.ts |
| Add unit tests | test/ (mirror src/ structure) |
| Add E2E tests | test/e2e/ |
| Test helpers | test/model-based/helpers.ts |
| Add documentation | apps/cli-docs/src/content/docs/ |
| Command examples | docs.examples on the command |
| Hand-written command doc content (guides, caveats) | apps/cli-docs/src/fragments/commands/ |
Automated bug-fix PRs (e.g. Cursor BugBot) must follow these rules to avoid the duplication and staleness that caused five overlapping PRs to pile up:
-
Check for existing work first. Before opening a PR, search open PRs and recently-closed PRs/issues for the same file + symbol:
gh pr list --state open --search "in:title <file-or-symbol>" gh issue list --state all --search "<symbol>"
If an open PR already touches the target function, comment on it or extend it instead of opening a duplicate. Multiple BugBot PRs independently re-fixed the same
JSON.parseguard,withTTYhelper, and pagination code. -
Rebase before review. A PR that is many commits behind
mainmay fail CI on unrelated drift (e.g. a lint error in a file the PR never touched) and its fix may already be superseded. Rebase ontomainand re-verify the bug still exists before requesting review. Verify against currentmain, not the snapshot the PR was generated from. -
Separate correctness fixes from opinion. A real bug (wrong output, crash, skipped data) is in scope. A subjective UX change (different hint wording, different default) is not a bug —
main's current behavior is often deliberate. Do not bundle UX opinions into bug-fix PRs; they waste review cycles and are usually dropped. -
Prefer shared helpers over re-deriving fixes. If a correct implementation already exists (e.g.
autoPaginate()for pagination,safeParseJson()for cached JSON), use it rather than hand-rolling a one-off fix. The recurring pagination-overshoot and parse-crash bugs were classes solved once centrally.
For long-term knowledge entries managed by lore (gotchas, patterns, decisions, architecture), see .lore.md in the project root.