Skip to content

Commit 2988043

Browse files
feat(trial): auto-prompt for Seer trial + sentry trial list/start commands (#399)
## Summary When Seer commands (`issue explain`, `issue plan`) fail with a budget or enablement error in an interactive terminal, the CLI now checks for an available Seer product trial, prompts the user, and retries the command on success. Additionally, new `sentry trial list` and `sentry trial start` commands give users proactive control over product trials. ## Auto-prompt flow Mirrors the existing `executeWithAutoAuth` middleware pattern: ``` main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand() ``` 1. Seer command fails with `SeerError` (`no_budget` or `not_enabled`) 2. `isTrialEligible()` checks: eligible reason + org slug available + interactive TTY 3. Fetches trial availability via `GET /api/0/customers/{org}/` (control silo) 4. Finds an unstarted Seer trial (prefers `seerUsers`, falls back to `seerAutofix`) 5. Prompts user with consola confirm 6. Starts trial via `PUT /api/0/customers/{org}/product-trial/` 7. Retries the original command through the full middleware chain Non-eligible errors (`ai_disabled`, missing org slug, non-TTY, API failures) pass through unchanged. ## Trial commands ### `sentry trial list [org]` Lists all product trials for an org with status indicators: - ○ Available (cyan) — not yet started - ● Active (green) — started, with days remaining - − Expired (muted) — ended Supports `--json` and `--fields` for machine consumption. ### `sentry trial start <name> [org]` Starts a named product trial. Supported names: `seer`, `replays`, `performance`, `spans`, `profiling`, `logs`. Features smart argument swap detection — `sentry trial start my-org seer` works the same as `sentry trial start seer my-org`. ## Key design decisions - **`isTrialEligible()` accepts `unknown`** — does the `instanceof SeerError` check internally so callers don't need to narrow first - **Control silo routing** — `/customers/` is a billing endpoint, uses `getControlSiloUrl()` not region URLs - **Consola logger** — all user-facing output uses `log.info()`/`log.warn()`/`log.success()` per project conventions (no raw `stderr.write`) - **Graceful degradation** — API failures during trial check silently fall through to the normal error display - **`ai_disabled` excluded** — admin's explicit choice to disable AI; trial wouldn't override it - **UTC timezone** — `getTrialStatus()` uses `setUTCHours()` for consistent date comparison - **Soft trial hints in error messages** — `SeerError.format()` says "You may be eligible" + `sentry trial list` (not a direct "start" since trial may not exist) ## New files | File | Description | |------|-------------| | `src/lib/seer-trial.ts` | `isTrialEligible()` + `promptAndStartTrial()` | | `src/lib/trials.ts` | Trial name mapping, status helpers, `findAvailableTrial()` | | `src/commands/trial/index.ts` | Route map for trial subcommands | | `src/commands/trial/list.ts` | `sentry trial list` command | | `src/commands/trial/start.ts` | `sentry trial start` command | | `src/lib/arg-parsing.ts` | `detectSwappedTrialArgs()` | ## Modified files | File | Changes | |------|---------| | `src/lib/api-client.ts` | `getProductTrials()` + `startProductTrial()` using control silo | | `src/lib/errors.ts` | Soft trial hint in `SeerError.format()` | | `src/bin.ts` | `executeWithSeerTrialPrompt()` middleware | | `src/app.ts` | Register trial routes + `trials` alias | | `src/types/sentry.ts` | `ProductTrialSchema`, `CustomerTrialInfoSchema` | ## Tests - **20 unit tests** for trial prompt flow (`seer-trial.test.ts`) - **10 unit tests** for trial API client (`api-client.seer-trial.test.ts`) - **30 unit tests** for trial helpers (`trials.test.ts`) - **4 property-based tests** for eligibility invariants (`seer-trial.property.test.ts`) - **9 unit tests** for `trial list` command (`trial/list.test.ts`) - **10 unit tests** for `trial start` command (`trial/start.test.ts`) - **4 unit tests** for `detectSwappedTrialArgs` (`arg-parsing.test.ts`) - 87 trial-related tests total, all passing --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 809ef5f commit 2988043

File tree

22 files changed

+2268
-30
lines changed

22 files changed

+2268
-30
lines changed

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -643,10 +643,10 @@ mock.module("./some-module", () => ({
643643
<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 -->
644644
* **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.
645645
646-
### Decision
646+
<!-- lore:019ce0bb-f35d-7380-b661-8dc56f9938cf -->
647+
* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: The CLI's error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\` errors) are caught by the inner wrapper; auth errors bubble up to the outer wrapper. After successful auth login retry, the retry also goes through \`executeWithSeerTrialPrompt\` (not \`runCommand\` directly) so the full middleware chain applies. Trial check API: \`GET /api/0/customers/{org}/\`\`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start trial: \`PUT /api/0/customers/{org}/product-trial/\`. The \`/customers/\` endpoint is getsentry SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` errors are excluded (admin's explicit choice). \`startSeerTrial\` accepts \`category\` from the trial object — don't hardcode it.
647648
648-
<!-- lore:019cc2ef-9be5-722d-bc9f-b07a8197eeed -->
649-
* **All view subcommands should use \<target> \<id> positional pattern**: All \`\* view\` subcommands should follow a consistent \`\<target> \<id>\` positional argument pattern where target is the optional \`org/project\` specifier. During migration, use opportunistic argument swapping with a stderr warning when args are in wrong order. This is an instance of the broader CLI UX auto-correction pattern: safe when input is already invalid, correction is unambiguous, warning goes to stderr. Normalize at command level, keep parsers pure. Model after \`gh\` CLI conventions.
649+
### Decision
650650
651651
<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d -->
652652
* **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.
@@ -689,7 +689,7 @@ mock.module("./some-module", () => ({
689689
* **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.
690690
691691
<!-- lore:5ac4e219-ea1f-41cb-8e97-7e946f5848c0 -->
692-
* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in the getsentry/cli repo, the CI pipeline includes Seer Code Review and Cursor Bugbot as advisory checks. Both typically take 2-3 minutes but may not trigger on draft PRs — only ready-for-review PRs reliably get bot reviews. The workflow is: push → wait for all CI (including npm build jobs which test the actual bundle) → check for inline review comments from Seer/BugBot → fix if needed → repeat. Use \`gh pr checks \<PR> --watch\` to monitor. Review comments are fetched via \`gh api repos/OWNER/REPO/pulls/NUM/comments\` and \`gh api repos/OWNER/REPO/pulls/NUM/reviews\`.
692+
* **PR workflow: wait for Seer and Cursor BugBot before resolving**: After pushing a PR in getsentry/cli, CI includes Seer Code Review and Cursor Bugbot as advisory checks (~2-3 min). They may not trigger on draft PRs — use \`gh pr ready\` first. Workflow: push → \`gh pr checks \<PR> --watch\` → check for bot review comments → fix → repeat. Use GraphQL \`reviewThreads\` query with \`isResolved\` filter to find unresolved comments. Reply to bot comments after fixing.
693693
694694
<!-- lore:019cb162-d3ad-7b05-ab4f-f87892d517a6 -->
695695
* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: List commands with cursor pagination use \`buildPaginationContextKey(type, identifier, flags)\` for composite context keys and \`parseCursorFlag(value)\` accepting \`"last"\` magic value. Critical: \`resolveCursor()\` must be called inside the \`org-all\` override closure, not before \`dispatchOrgScopedList\` — otherwise cursor validation errors fire before the correct mode-specific error.

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,26 @@ View logs associated with a trace
667667
- `--json - Output as JSON`
668668
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
669669

670+
### Trial
671+
672+
Manage product trials
673+
674+
#### `sentry trial list <org>`
675+
676+
List product trials
677+
678+
**Flags:**
679+
- `--json - Output as JSON`
680+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
681+
682+
#### `sentry trial start <name> <org>`
683+
684+
Start a product trial
685+
686+
**Flags:**
687+
- `--json - Output as JSON`
688+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
689+
670690
### Init
671691

672692
Initialize Sentry in your project
@@ -794,6 +814,18 @@ List recent traces in a project
794814
- `--json - Output as JSON`
795815
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
796816

817+
### Trials
818+
819+
List product trials
820+
821+
#### `sentry trials <org>`
822+
823+
List product trials
824+
825+
**Flags:**
826+
- `--json - Output as JSON`
827+
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`
828+
797829
### Whoami
798830

799831
Show the currently authenticated user

src/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { teamRoute } from "./commands/team/index.js";
2828
import { listCommand as teamListCommand } from "./commands/team/list.js";
2929
import { traceRoute } from "./commands/trace/index.js";
3030
import { listCommand as traceListCommand } from "./commands/trace/list.js";
31+
import { trialRoute } from "./commands/trial/index.js";
32+
import { listCommand as trialListCommand } from "./commands/trial/list.js";
3133
import { CLI_VERSION } from "./lib/constants.js";
3234
import {
3335
AuthError,
@@ -49,6 +51,7 @@ const PLURAL_TO_SINGULAR: Record<string, string> = {
4951
teams: "team",
5052
logs: "log",
5153
traces: "trace",
54+
trials: "trial",
5255
};
5356

5457
/** Top-level route map containing all CLI commands */
@@ -65,6 +68,7 @@ export const routes = buildRouteMap({
6568
event: eventRoute,
6669
log: logRoute,
6770
trace: traceRoute,
71+
trial: trialRoute,
6872
init: initCommand,
6973
api: apiCommand,
7074
issues: issueListCommand,
@@ -74,6 +78,7 @@ export const routes = buildRouteMap({
7478
teams: teamListCommand,
7579
logs: logListCommand,
7680
traces: traceListCommand,
81+
trials: trialListCommand,
7782
whoami: whoamiCommand,
7883
},
7984
defaultCommand: "help",

src/bin.ts

Lines changed: 95 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AuthError, formatError, getExitCode } from "./lib/errors.js";
66
import { error } from "./lib/formatters/colors.js";
77
import { runInteractiveLogin } from "./lib/interactive-login.js";
88
import { getEnvLogLevel, setLogLevel } from "./lib/logger.js";
9+
import { isTrialEligible, promptAndStartTrial } from "./lib/seer-trial.js";
910
import { withTelemetry } from "./lib/telemetry.js";
1011
import { startCleanupOldBinary } from "./lib/upgrade.js";
1112
import {
@@ -29,26 +30,65 @@ function handleStreamError(err: NodeJS.ErrnoException): void {
2930
process.stdout.on("error", handleStreamError);
3031
process.stderr.on("error", handleStreamError);
3132

32-
/** Run CLI command with telemetry wrapper */
33-
async function runCommand(args: string[]): Promise<void> {
34-
await withTelemetry(async (span) =>
35-
run(app, args, buildContext(process, span))
36-
);
37-
}
33+
/**
34+
* Error-recovery middleware for the CLI.
35+
*
36+
* Each middleware wraps command execution and may intercept specific errors
37+
* to perform recovery actions (e.g., login, start trial) then retry.
38+
*
39+
* Middlewares are applied innermost-first: the last middleware in the array
40+
* wraps the outermost layer, so it gets first crack at errors. This means
41+
* auth recovery (outermost) can catch errors from both the command AND
42+
* the trial prompt retry.
43+
*
44+
* @param next - The next function in the chain (command or inner middleware)
45+
* @param args - CLI arguments for retry
46+
* @returns A function with the same signature, with error recovery added
47+
*/
48+
type ErrorMiddleware = (
49+
next: (argv: string[]) => Promise<void>,
50+
args: string[]
51+
) => Promise<void>;
3852

3953
/**
40-
* Execute command with automatic authentication.
54+
* Seer trial prompt middleware.
4155
*
42-
* If the command fails due to missing authentication and we're in a TTY,
43-
* automatically run the interactive login flow and retry the command.
56+
* Catches trial-eligible SeerErrors and offers to start a free trial.
57+
* On success, retries the original command. On failure/decline, re-throws
58+
* so the outer error handler displays the full error with upgrade URL.
59+
*/
60+
const seerTrialMiddleware: ErrorMiddleware = async (next, args) => {
61+
try {
62+
await next(args);
63+
} catch (err) {
64+
if (isTrialEligible(err)) {
65+
const started = await promptAndStartTrial(
66+
// biome-ignore lint/style/noNonNullAssertion: isTrialEligible guarantees orgSlug is defined
67+
err.orgSlug!,
68+
err.reason
69+
);
70+
71+
if (started) {
72+
process.stderr.write("\nRetrying command...\n\n");
73+
await next(args);
74+
return;
75+
}
76+
}
77+
throw err;
78+
}
79+
};
80+
81+
/**
82+
* Auto-authentication middleware.
4483
*
45-
* @throws Re-throws any non-authentication errors from the command
84+
* Catches auth errors (not_authenticated, expired) in interactive TTYs
85+
* and runs the login flow. On success, retries through the full middleware
86+
* chain so inner middlewares (e.g., trial prompt) also apply to the retry.
4687
*/
47-
async function executeWithAutoAuth(args: string[]): Promise<void> {
88+
const autoAuthMiddleware: ErrorMiddleware = async (next, args) => {
4889
try {
49-
await runCommand(args);
90+
await next(args);
5091
} catch (err) {
51-
// Auto-login for auth errors in interactive TTY environments
5292
// Use isatty(0) for reliable stdin TTY detection (process.stdin.isTTY can be undefined in Bun)
5393
// Errors can opt-out via skipAutoAuth (e.g., auth status command)
5494
if (
@@ -71,21 +111,58 @@ async function executeWithAutoAuth(args: string[]): Promise<void> {
71111

72112
if (loginSuccess) {
73113
process.stderr.write("\nRetrying command...\n\n");
74-
await runCommand(args);
114+
await next(args);
75115
return;
76116
}
77117

78-
// Login failed or was cancelled - set exit code and return
79-
// (don't call process.exit() directly to allow finally blocks to run)
118+
// Login failed or was cancelled
80119
process.exitCode = 1;
81120
return;
82121
}
83122

84-
// Re-throw non-auth errors to be handled by main
85123
throw err;
86124
}
125+
};
126+
127+
/**
128+
* Error-recovery middlewares applied around command execution.
129+
*
130+
* Order matters: applied innermost-first, so the last entry wraps the
131+
* outermost layer. Auth middleware is outermost so it catches errors
132+
* from both the command and any inner middleware retries.
133+
*
134+
* To add a new middleware, append it to this array.
135+
*/
136+
const errorMiddlewares: ErrorMiddleware[] = [
137+
seerTrialMiddleware,
138+
autoAuthMiddleware,
139+
];
140+
141+
/** Run CLI command with telemetry wrapper */
142+
async function runCommand(args: string[]): Promise<void> {
143+
await withTelemetry(async (span) =>
144+
run(app, args, buildContext(process, span))
145+
);
146+
}
147+
148+
/**
149+
* Build the command executor by composing error-recovery middlewares.
150+
*
151+
* Wraps `runCommand` with each middleware in order (innermost-first),
152+
* producing a single function that handles all error recovery.
153+
*/
154+
function buildExecutor(): (args: string[]) => Promise<void> {
155+
let executor = runCommand;
156+
for (const mw of errorMiddlewares) {
157+
const next = executor;
158+
executor = (args) => mw(next, args);
159+
}
160+
return executor;
87161
}
88162

163+
/** Command executor with all error-recovery middlewares applied */
164+
const executeCommand = buildExecutor();
165+
89166
async function main(): Promise<void> {
90167
// Clean up old binary from previous Windows upgrade (no-op if file doesn't exist)
91168
startCleanupOldBinary();
@@ -108,7 +185,7 @@ async function main(): Promise<void> {
108185
}
109186

110187
try {
111-
await executeWithAutoAuth(args);
188+
await executeCommand(args);
112189
} catch (err) {
113190
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
114191
process.exitCode = getExitCode(err);

src/commands/trial/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { buildRouteMap } from "@stricli/core";
2+
import { listCommand } from "./list.js";
3+
import { startCommand } from "./start.js";
4+
5+
export const trialRoute = buildRouteMap({
6+
routes: {
7+
list: listCommand,
8+
start: startCommand,
9+
},
10+
docs: {
11+
brief: "Manage product trials",
12+
fullDescription: "List and start product trials for your organization.",
13+
hideRoute: {},
14+
},
15+
});

0 commit comments

Comments
 (0)