Skip to content

Commit 84d2812

Browse files
authored
fix: show human-friendly names in trial list and surface plan trials (#412)
## Summary Improves the `sentry trial list` and `sentry trial start` commands with human-friendly names, plan trial visibility, deduplication, and better error messages. ## Changes ### Human-friendly trial names - Added mappings for `monitorSeats` → "Cron Monitors", `uptime` → "Uptime Monitoring" - Added `profileDurationUI` to existing `profiling` mapping - Added `humanizeCategory()` fallback that converts unknown camelCase categories to "Title Case" - Added `kebabize()` fallback for CLI-friendly names ### Plan trial surfacing - New `getCustomerTrialInfo()` API function returns plan trial data (`canTrial`, `isTrial`, `trialEnd`, `planDetails`) - Plan-level trials (e.g., "Developer -> Business") now appear in `trial list` with status "Available" or "Active" - Shows hint: `Tip: Use 'sentry trial start plan' to start a Business plan trial` ### `sentry trial start plan` - New "plan" pseudo-trial name that opens the org's billing page - Shows billing URL, QR code, and optional browser prompt (TTY-aware) - Checks eligibility via `canTrial`/`isTrial` before prompting - Respects `--json` mode ### Simplified table format - Merged NAME + PRODUCT into single "TRIAL" column with CLI name in parentheses: `Profiling (profiling)` - Dropped CATEGORY column from human output (kept in `--json`) - Plan trial row: `Developer -> Business (plan) | ○ Available | —` ### Deduplication - API returns separate entries for `profileDuration` and `profileDurationUI`, both mapping to CLI name "profiling" - `deduplicateTrials()` groups by CLI name, keeps best entry (active > available > expired, latest endDate wins) ### Better error messages - `ContextError` now shows usage with placeholder: `sentry trial list <org>` instead of bare `sentry trial list` - Default alternatives (DSN detection, `SENTRY_ORG` env var) shown instead of being overridden ## Test coverage 89 tests passing across 3 files (154 assertions): - `test/lib/trials.test.ts` — unit tests for name mappings, humanization, kebabization - `test/commands/trial/list.test.ts` — plan trial entries, deduplication, table formatting - `test/commands/trial/start.test.ts` — plan trial flow, eligibility checks, JSON mode ## Related - Discovered a pre-existing systemic bug where commands produce no output when DSN auto-detection is slow on first run (uncached). Filed as #411.
1 parent fb38a16 commit 84d2812

File tree

10 files changed

+1047
-116
lines changed

10 files changed

+1047
-116
lines changed

AGENTS.md

Lines changed: 46 additions & 20 deletions
Large diffs are not rendered by default.

src/commands/trial/list.ts

Lines changed: 140 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,26 @@
22
* sentry trial list
33
*
44
* List product trials available to an organization, including
5-
* active trials with time remaining and expired trials.
5+
* active trials with time remaining, expired trials, and
6+
* plan-level upgrade trials.
67
*/
78

89
import type { SentryContext } from "../../context.js";
9-
import { getProductTrials } from "../../lib/api-client.js";
10+
import { getCustomerTrialInfo } from "../../lib/api-client.js";
1011
import { buildCommand } from "../../lib/command.js";
1112
import { ContextError } from "../../lib/errors.js";
1213
import { colorTag } from "../../lib/formatters/markdown.js";
1314
import { type Column, writeTable } from "../../lib/formatters/table.js";
1415
import { resolveOrg } from "../../lib/resolve-target.js";
1516
import {
17+
daysRemainingFromDate,
1618
getDaysRemaining,
1719
getTrialDisplayName,
1820
getTrialFriendlyName,
1921
getTrialStatus,
2022
type TrialStatus,
2123
} from "../../lib/trials.js";
22-
import type { Writer } from "../../types/index.js";
24+
import type { CustomerTrialInfo, Writer } from "../../types/index.js";
2325

2426
type ListFlags = {
2527
readonly json: boolean;
@@ -31,11 +33,11 @@ type ListFlags = {
3133
* Adds derived fields (name, displayName, status, daysRemaining) to the raw API data.
3234
*/
3335
type TrialListEntry = {
34-
/** CLI-friendly name (e.g., "seer") */
36+
/** CLI-friendly name (e.g., "seer") or "plan" for plan-level trials */
3537
name: string;
36-
/** Human-readable product name (e.g., "Seer") */
38+
/** Human-readable product name (e.g., "Seer") or plan upgrade name */
3739
displayName: string;
38-
/** API category name (e.g., "seerUsers") */
40+
/** API category name (e.g., "seerUsers"), or "plan" for plan-level trials */
3941
category: string;
4042
/** Derived status */
4143
status: TrialStatus;
@@ -58,17 +60,115 @@ const STATUS_LABELS: Record<TrialStatus, string> = {
5860
expired: `${colorTag("muted", "−")} Expired`,
5961
};
6062

63+
/** Status priority for deduplication: active > available > expired */
64+
const STATUS_PRIORITY: Record<TrialStatus, number> = {
65+
active: 2,
66+
available: 1,
67+
expired: 0,
68+
};
69+
70+
/**
71+
* Deduplicate trial entries that map to the same CLI name.
72+
*
73+
* Multiple API categories can map to a single trial name (e.g., both
74+
* `profileDuration` and `profileDurationUI` map to "profiling"). When
75+
* that happens, keep the entry with the best status (active > available >
76+
* expired), breaking ties by latest end date.
77+
*/
78+
function deduplicateTrials(entries: TrialListEntry[]): TrialListEntry[] {
79+
const byName = new Map<string, TrialListEntry>();
80+
for (const entry of entries) {
81+
const existing = byName.get(entry.name);
82+
if (!existing || isBetterTrial(entry, existing)) {
83+
byName.set(entry.name, entry);
84+
}
85+
}
86+
return [...byName.values()];
87+
}
88+
89+
/**
90+
* Compare two trial entries — returns true if `a` should replace `b`.
91+
* Prefers active over available over expired, then latest end date.
92+
*/
93+
function isBetterTrial(a: TrialListEntry, b: TrialListEntry): boolean {
94+
const aPriority = STATUS_PRIORITY[a.status];
95+
const bPriority = STATUS_PRIORITY[b.status];
96+
if (aPriority !== bPriority) {
97+
return aPriority > bPriority;
98+
}
99+
// Same status — prefer the one with a later end date
100+
if (a.endDate && b.endDate) {
101+
return a.endDate > b.endDate;
102+
}
103+
return a.endDate !== null;
104+
}
105+
106+
/**
107+
* Build a synthetic trial entry for a plan-level trial.
108+
*
109+
* The Sentry billing API exposes plan-level trials (e.g., "Try Business")
110+
* separately from product trials. This creates a unified entry so both
111+
* appear in the same list.
112+
*
113+
* @param info - Customer trial info from the API
114+
* @returns A TrialListEntry for the plan trial, or null if not applicable
115+
*/
116+
function buildPlanTrialEntry(info: CustomerTrialInfo): TrialListEntry | null {
117+
if (info.isTrial) {
118+
// Currently on a plan trial
119+
const planName = info.planDetails?.name ?? "Business";
120+
const endDate = info.trialEnd ?? null;
121+
const daysRemaining = endDate ? daysRemainingFromDate(endDate) : null;
122+
return {
123+
name: "plan",
124+
displayName: `${planName} Plan`,
125+
category: "plan",
126+
status: "active",
127+
daysRemaining,
128+
isStarted: true,
129+
lengthDays: null,
130+
startDate: null,
131+
endDate,
132+
};
133+
}
134+
135+
if (info.canTrial) {
136+
// Plan trial available but not started
137+
const currentPlan = info.planDetails?.name ?? "current plan";
138+
return {
139+
name: "plan",
140+
displayName: `${currentPlan} -> Business`,
141+
category: "plan",
142+
status: "available",
143+
daysRemaining: null,
144+
isStarted: false,
145+
lengthDays: null,
146+
startDate: null,
147+
endDate: null,
148+
};
149+
}
150+
151+
return null;
152+
}
153+
61154
/**
62155
* Format trial list as a human-readable table.
63156
*/
64157
function formatTrialListHuman(entries: TrialListEntry[]): string {
65158
if (entries.length === 0) {
66-
return "No product trials found for this organization.";
159+
return "No trials found for this organization.";
67160
}
68161

69162
const columns: Column<TrialListEntry>[] = [
70-
{ header: "NAME", value: (t) => t.name },
71-
{ header: "PRODUCT", value: (t) => t.displayName },
163+
{
164+
header: "TRIAL",
165+
value: (t) =>
166+
// Show CLI name in parentheses so users know the argument
167+
// for `sentry trial start <name>`
168+
t.name !== t.displayName
169+
? `${t.displayName} ${colorTag("muted", `(${t.name})`)}`
170+
: t.displayName,
171+
},
72172
{ header: "STATUS", value: (t) => STATUS_LABELS[t.status] ?? t.status },
73173
{
74174
header: "DAYS LEFT",
@@ -82,10 +182,6 @@ function formatTrialListHuman(entries: TrialListEntry[]): string {
82182
},
83183
align: "right",
84184
},
85-
{
86-
header: "CATEGORY",
87-
value: (t) => colorTag("muted", t.category),
88-
},
89185
];
90186

91187
const parts: string[] = [];
@@ -130,30 +226,44 @@ export const listCommand = buildCommand({
130226
});
131227

132228
if (!resolved) {
133-
throw new ContextError("Organization", "sentry trial list", [
134-
"sentry trial list <org>",
135-
]);
229+
throw new ContextError("Organization", "sentry trial list <org>");
136230
}
137231

138-
const trials = await getProductTrials(resolved.org);
232+
const info = await getCustomerTrialInfo(resolved.org);
233+
const productTrials = info.productTrials ?? [];
234+
235+
const entries: TrialListEntry[] = deduplicateTrials(
236+
productTrials.map((t) => ({
237+
name: getTrialFriendlyName(t.category),
238+
displayName: getTrialDisplayName(t.category),
239+
category: t.category,
240+
status: getTrialStatus(t),
241+
daysRemaining: getDaysRemaining(t),
242+
isStarted: t.isStarted,
243+
lengthDays: t.lengthDays,
244+
startDate: t.startDate,
245+
endDate: t.endDate,
246+
}))
247+
);
139248

140-
const entries: TrialListEntry[] = trials.map((t) => ({
141-
name: getTrialFriendlyName(t.category),
142-
displayName: getTrialDisplayName(t.category),
143-
category: t.category,
144-
status: getTrialStatus(t),
145-
daysRemaining: getDaysRemaining(t),
146-
isStarted: t.isStarted,
147-
lengthDays: t.lengthDays,
148-
startDate: t.startDate,
149-
endDate: t.endDate,
150-
}));
249+
// Add plan-level trial entry (available or active) at the top
250+
const planEntry = buildPlanTrialEntry(info);
251+
if (planEntry) {
252+
entries.unshift(planEntry);
253+
}
151254

152255
const hints: string[] = [];
153-
const hasAvailable = entries.some((e) => e.status === "available");
154-
if (hasAvailable) {
256+
const hasAvailableProduct = entries.some(
257+
(e) => e.status === "available" && e.category !== "plan"
258+
);
259+
if (hasAvailableProduct) {
155260
hints.push("Tip: Use 'sentry trial start <name>' to start a trial");
156261
}
262+
if (planEntry?.status === "available") {
263+
hints.push(
264+
"Tip: Use 'sentry trial start plan' to start a Business plan trial"
265+
);
266+
}
157267

158268
return { data: entries, hint: hints.join("\n") || undefined };
159269
},

0 commit comments

Comments
 (0)