Skip to content

Commit e1b6a18

Browse files
committed
feat(release): surface adoption and health metrics in list and view (#463)
Add health/adoption data to the existing release commands: - Pass `health=1` to the list endpoint so each release includes per-project adoption and crash-free metrics - Add ADOPTION and CRASH-FREE columns to `release list` table - Add `health`, `adoptionStages`, and `healthStatsPeriod` query options to `getRelease()` API function - Show per-project health breakdown table in `release view` (crash-free users/sessions, adoption %, 24h user/session counts) - Color-code crash-free rates (green ≥ 99%, yellow ≥ 95%, red < 95%) - Gracefully omit health section when no project has session data
1 parent 2009674 commit e1b6a18

File tree

7 files changed

+306
-22
lines changed

7 files changed

+306
-22
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,8 @@ Manage Sentry dashboards
346346

347347
Work with Sentry releases
348348

349-
- `sentry release list <org/project>` — List releases
350-
- `sentry release view <org/version...>` — View release details
349+
- `sentry release list <org/project>` — List releases with adoption and health metrics
350+
- `sentry release view <org/version...>` — View release details with health metrics
351351
- `sentry release create <org/version...>` — Create a release
352352
- `sentry release finalize <org/version...>` — Finalize a release
353353
- `sentry release delete <org/version...>` — Delete a release

plugins/sentry-cli/skills/sentry-cli/references/release.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Work with Sentry releases
1313

1414
### `sentry release list <org/project>`
1515

16-
List releases
16+
List releases with adoption and health metrics
1717

1818
**Flags:**
1919
- `-n, --limit <value> - Maximum number of releases to list - (default: "25")`
@@ -22,7 +22,7 @@ List releases
2222

2323
### `sentry release view <org/version...>`
2424

25-
View release details
25+
View release details with health metrics
2626

2727
**Flags:**
2828
- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data`

src/commands/release/list.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* sentry release list
33
*
44
* List releases in an organization with pagination support.
5+
* Includes per-project health/adoption metrics when available.
56
*/
67

78
import type { OrgReleaseResponse } from "@sentry/api";
@@ -19,23 +20,48 @@ export const PAGINATION_KEY = "release-list";
1920

2021
type ReleaseWithOrg = OrgReleaseResponse & { orgSlug?: string };
2122

23+
/**
24+
* Extract health data from the first project that has it.
25+
*
26+
* A release spans multiple projects; each gets independent health data.
27+
* For the list table we pick the first project with `hasHealthData: true`.
28+
*/
29+
function getHealthData(release: OrgReleaseResponse) {
30+
return release.projects?.find((p) => p.healthData?.hasHealthData)?.healthData;
31+
}
32+
33+
/** Format a percentage value with one decimal, or "—" when absent. */
34+
function fmtPct(value: number | null | undefined): string {
35+
if (value === null || value === undefined) {
36+
return "—";
37+
}
38+
return `${value.toFixed(1)}%`;
39+
}
40+
2241
const RELEASE_COLUMNS: Column<ReleaseWithOrg>[] = [
2342
{ header: "ORG", value: (r) => r.orgSlug || "" },
2443
{
2544
header: "VERSION",
2645
value: (r) => escapeMarkdownCell(r.shortVersion || r.version),
2746
},
28-
{
29-
header: "STATUS",
30-
value: (r) => (r.dateReleased ? "Finalized" : "Unreleased"),
31-
},
3247
{
3348
header: "CREATED",
3449
value: (r) => (r.dateCreated ? formatRelativeTime(r.dateCreated) : ""),
3550
},
3651
{
37-
header: "RELEASED",
38-
value: (r) => (r.dateReleased ? formatRelativeTime(r.dateReleased) : "—"),
52+
header: "ADOPTION",
53+
value: (r) => fmtPct(getHealthData(r)?.adoption),
54+
align: "right",
55+
},
56+
{
57+
header: "CRASH-FREE",
58+
value: (r) => fmtPct(getHealthData(r)?.crashFreeSessions),
59+
align: "right",
60+
},
61+
{
62+
header: "ISSUES",
63+
value: (r) => String(r.newGroups ?? 0),
64+
align: "right",
3965
},
4066
{ header: "COMMITS", value: (r) => String(r.commitCount ?? 0) },
4167
{ header: "DEPLOYS", value: (r) => String(r.deployCount ?? 0) },
@@ -48,20 +74,27 @@ const releaseListConfig: OrgListConfig<OrgReleaseResponse, ReleaseWithOrg> = {
4874
commandPrefix: "sentry release list",
4975
// listForOrg fetches a buffer page for multi-org fan-out.
5076
// The framework truncates results to --limit after aggregation.
77+
// health=true to populate per-project adoption/crash-free metrics.
5178
listForOrg: async (org) => {
52-
const { data } = await listReleasesPaginated(org, { perPage: 100 });
79+
const { data } = await listReleasesPaginated(org, {
80+
perPage: 100,
81+
health: true,
82+
});
5383
return data;
5484
},
55-
listPaginated: (org, opts) => listReleasesPaginated(org, opts),
85+
listPaginated: (org, opts) =>
86+
listReleasesPaginated(org, { ...opts, health: true }),
5687
withOrg: (release, orgSlug) => ({ ...release, orgSlug }),
5788
displayTable: (releases: ReleaseWithOrg[]) =>
5889
formatTable(releases, RELEASE_COLUMNS),
5990
};
6091

6192
const docs: OrgListCommandDocs = {
62-
brief: "List releases",
93+
brief: "List releases with adoption and health metrics",
6394
fullDescription:
64-
"List releases in an organization.\n\n" +
95+
"List releases in an organization with adoption and crash-free metrics.\n\n" +
96+
"Health data (adoption %, crash-free session rate) is shown per-release\n" +
97+
"from the first project that has session data.\n\n" +
6598
"Target specification:\n" +
6699
" sentry release list # auto-detect from DSN or config\n" +
67100
" sentry release list <org>/ # list all releases in org (paginated)\n" +

src/commands/release/view.ts

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
22
* sentry release view
33
*
4-
* View details of a specific release.
4+
* View details of a specific release, including per-project
5+
* health and adoption metrics when available.
56
*/
67

78
import type { OrgReleaseResponse } from "@sentry/api";
@@ -11,8 +12,10 @@ import { buildCommand } from "../../lib/command.js";
1112
import { ContextError } from "../../lib/errors.js";
1213
import {
1314
colorTag,
15+
escapeMarkdownCell,
1416
escapeMarkdownInline,
1517
mdKvTable,
18+
mdTableHeader,
1619
renderMarkdown,
1720
safeCodeSpan,
1821
} from "../../lib/formatters/markdown.js";
@@ -26,6 +29,81 @@ import {
2629
import { resolveOrg } from "../../lib/resolve-target.js";
2730
import { parseReleaseArg } from "./parse.js";
2831

32+
/** Format a percentage with one decimal, colorized by threshold. */
33+
function fmtPct(value: number | null | undefined): string {
34+
if (value === null || value === undefined) {
35+
return "—";
36+
}
37+
return `${value.toFixed(1)}%`;
38+
}
39+
40+
/** Format a crash-free rate with color coding (green ≥ 99, yellow ≥ 95, red < 95). */
41+
function fmtCrashFree(value: number | null | undefined): string {
42+
if (value === null || value === undefined) {
43+
return "—";
44+
}
45+
const formatted = `${value.toFixed(1)}%`;
46+
if (value >= 99) {
47+
return colorTag("green", formatted);
48+
}
49+
if (value >= 95) {
50+
return colorTag("yellow", formatted);
51+
}
52+
return colorTag("red", formatted);
53+
}
54+
55+
/** Format a count with thousands separators, or "—" when absent. */
56+
function fmtCount(value: number | null | undefined): string {
57+
if (value === null || value === undefined) {
58+
return "—";
59+
}
60+
return value.toLocaleString("en-US");
61+
}
62+
63+
/**
64+
* Build a markdown table of per-project health data.
65+
*
66+
* Only includes projects that have health data. Returns empty string
67+
* if no project has data (so the section is skipped entirely).
68+
*/
69+
function formatProjectHealthTable(release: OrgReleaseResponse): string {
70+
const projects = release.projects?.filter((p) => p.healthData?.hasHealthData);
71+
if (!projects?.length) {
72+
return "";
73+
}
74+
75+
const lines: string[] = [];
76+
lines.push("### Health by Project");
77+
lines.push("");
78+
79+
// Table header: right-align numeric columns with trailing ":"
80+
lines.push(
81+
mdTableHeader([
82+
"PROJECT",
83+
"ADOPTION:",
84+
"CRASH-FREE USERS:",
85+
"CRASH-FREE SESSIONS:",
86+
"USERS (24h):",
87+
"SESSIONS (24h):",
88+
])
89+
);
90+
91+
for (const project of projects) {
92+
const h = project.healthData;
93+
const cells = [
94+
escapeMarkdownCell(project.slug),
95+
fmtPct(h?.adoption),
96+
fmtCrashFree(h?.crashFreeUsers),
97+
fmtCrashFree(h?.crashFreeSessions),
98+
fmtCount(h?.totalUsers24h),
99+
fmtCount(h?.totalSessions24h),
100+
];
101+
lines.push(`| ${cells.join(" | ")} |`);
102+
}
103+
104+
return lines.join("\n");
105+
}
106+
29107
function formatReleaseDetails(release: OrgReleaseResponse): string {
30108
const lines: string[] = [];
31109

@@ -77,14 +155,23 @@ function formatReleaseDetails(release: OrgReleaseResponse): string {
77155
}
78156

79157
lines.push(mdKvTable(kvRows));
158+
159+
// Per-project health breakdown (only if any project has data)
160+
const healthTable = formatProjectHealthTable(release);
161+
if (healthTable) {
162+
lines.push("");
163+
lines.push(healthTable);
164+
}
165+
80166
return renderMarkdown(lines.join("\n"));
81167
}
82168

83169
export const viewCommand = buildCommand({
84170
docs: {
85-
brief: "View release details",
171+
brief: "View release details with health metrics",
86172
fullDescription:
87-
"Show detailed information about a Sentry release.\n\n" +
173+
"Show detailed information about a Sentry release, including\n" +
174+
"per-project adoption and crash-free metrics.\n\n" +
88175
"Examples:\n" +
89176
" sentry release view 1.0.0\n" +
90177
" sentry release view my-org/1.0.0\n" +
@@ -140,7 +227,10 @@ export const viewCommand = buildCommand({
140227
"sentry release view [<org>/]<version>"
141228
);
142229
}
143-
const release = await getRelease(resolved.org, version);
230+
const release = await getRelease(resolved.org, version, {
231+
health: true,
232+
adoptionStages: true,
233+
});
144234
yield new CommandOutput(release);
145235
const hint = resolved.detectedFrom
146236
? `Detected from ${resolved.detectedFrom}`

src/lib/api-client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export {
9696
getRelease,
9797
listReleaseDeploys,
9898
listReleasesPaginated,
99+
type ReleaseSortValue,
99100
setCommitsAuto,
100101
setCommitsLocal,
101102
setCommitsWithRefs,

src/lib/api/releases.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,11 @@ import { listRepositoriesPaginated } from "./repositories.js";
3838
* List releases in an organization with pagination control.
3939
* Returns a single page of results with cursor metadata.
4040
*
41+
* When `health` is true, each release's `projects[].healthData` is populated
42+
* with adoption percentages, crash-free rates, and session/user counts.
43+
*
4144
* @param orgSlug - Organization slug
42-
* @param options - Pagination, query, and sort options
45+
* @param options - Pagination, query, sort, and health options
4346
* @returns Single page of releases with cursor metadata
4447
*/
4548
export async function listReleasesPaginated(
@@ -49,19 +52,22 @@ export async function listReleasesPaginated(
4952
perPage?: number;
5053
query?: string;
5154
sort?: string;
55+
/** Include per-project health/adoption data in the response. */
56+
health?: boolean;
5257
} = {}
5358
): Promise<PaginatedResponse<OrgReleaseResponse[]>> {
5459
const config = await getOrgSdkConfig(orgSlug);
5560

5661
const result = await listAnOrganization_sReleases({
5762
...config,
5863
path: { organization_id_or_slug: orgSlug },
59-
// per_page and sort are supported at runtime but not in the OpenAPI spec
64+
// per_page, sort, and health are supported at runtime but not in the OpenAPI spec
6065
query: {
6166
cursor: options.cursor,
6267
per_page: options.perPage ?? 25,
6368
query: options.query,
6469
sort: options.sort,
70+
health: options.health ? 1 : undefined,
6571
} as { cursor?: string },
6672
});
6773

@@ -73,17 +79,38 @@ export async function listReleasesPaginated(
7379
);
7480
}
7581

82+
/** Sort options for the release list endpoint. */
83+
export type ReleaseSortValue =
84+
| "date"
85+
| "sessions"
86+
| "users"
87+
| "crash_free_sessions"
88+
| "crash_free_users";
89+
7690
/**
7791
* Get a single release by version.
7892
* Version is URL-encoded by the SDK.
7993
*
94+
* When `health` is true, each project in the response includes a
95+
* `healthData` object with adoption percentages, crash-free rates,
96+
* and session/user counts for the requested period.
97+
*
8098
* @param orgSlug - Organization slug
8199
* @param version - Release version string (e.g., "1.0.0", "sentry-cli@0.24.0")
100+
* @param options - Optional health and adoption query parameters
82101
* @returns Full release detail
83102
*/
84103
export async function getRelease(
85104
orgSlug: string,
86-
version: string
105+
version: string,
106+
options?: {
107+
/** Include per-project health/adoption data. */
108+
health?: boolean;
109+
/** Include adoption stage info (e.g., "adopted", "low_adoption"). */
110+
adoptionStages?: boolean;
111+
/** Period for health stats: "24h", "7d", "14d", etc. Defaults to "24h". */
112+
healthStatsPeriod?: string;
113+
}
87114
): Promise<OrgReleaseResponse> {
88115
const config = await getOrgSdkConfig(orgSlug);
89116

@@ -93,6 +120,21 @@ export async function getRelease(
93120
organization_id_or_slug: orgSlug,
94121
version,
95122
},
123+
query: {
124+
health: options?.health,
125+
adoptionStages: options?.adoptionStages,
126+
healthStatsPeriod: options?.healthStatsPeriod as
127+
| "24h"
128+
| "7d"
129+
| "14d"
130+
| "30d"
131+
| "1h"
132+
| "1d"
133+
| "2d"
134+
| "48h"
135+
| "90d"
136+
| undefined,
137+
},
96138
});
97139

98140
const data = unwrapResult(result, `Failed to get release '${version}'`);

0 commit comments

Comments
 (0)