Skip to content

Commit cddef24

Browse files
committed
feat(profile): add profile command to surface CPU profiling data
Add new `sentry profile` command with two subcommands: - `profile list` - Lists transactions with profiling data available - `profile view` - Analyzes CPU profile for a specific transaction, showing hot paths, percentiles, and optimization recommendations Features: - Flamegraph API integration for detailed call stack analysis - Hot path detection with CPU time percentages - P75/P95/P99 percentile statistics per function - User code vs library code filtering (--all-frames to include all) - JSON output support for CI/automation - Web flag to open profiles in Sentry UI - Configurable time periods (1h/24h/7d/14d/30d) Closes #56
1 parent 3335bfa commit cddef24

11 files changed

Lines changed: 1192 additions & 1 deletion

File tree

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { eventRoute } from "./commands/event/index.js";
1313
import { helpCommand } from "./commands/help.js";
1414
import { issueRoute } from "./commands/issue/index.js";
1515
import { orgRoute } from "./commands/org/index.js";
16+
import { profileRoute } from "./commands/profile/index.js";
1617
import { projectRoute } from "./commands/project/index.js";
1718
import { CLI_VERSION } from "./lib/constants.js";
1819
import { CliError, getExitCode } from "./lib/errors.js";
@@ -28,6 +29,7 @@ export const routes = buildRouteMap({
2829
project: projectRoute,
2930
issue: issueRoute,
3031
event: eventRoute,
32+
profile: profileRoute,
3133
api: apiCommand,
3234
},
3335
defaultCommand: "help",

src/commands/profile/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { buildRouteMap } from "@stricli/core";
2+
import { listCommand } from "./list.js";
3+
import { viewCommand } from "./view.js";
4+
5+
export const profileRoute = buildRouteMap({
6+
routes: {
7+
list: listCommand,
8+
view: viewCommand,
9+
},
10+
docs: {
11+
brief: "Analyze CPU profiling data",
12+
fullDescription:
13+
"View and analyze CPU profiling data from your Sentry projects.\n\n" +
14+
"Commands:\n" +
15+
" list List transactions with profiling data\n" +
16+
" view View CPU profiling analysis for a transaction",
17+
hideRoute: {},
18+
},
19+
});

src/commands/profile/list.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* sentry profile list
3+
*
4+
* List transactions with profiling data from Sentry.
5+
* Uses the Explore Events API with the profile_functions dataset.
6+
*/
7+
8+
import { buildCommand, numberParser } from "@stricli/core";
9+
import type { SentryContext } from "../../context.js";
10+
import { getProject, listProfiledTransactions } from "../../lib/api-client.js";
11+
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
12+
import { ContextError } from "../../lib/errors.js";
13+
import {
14+
divider,
15+
formatProfileListFooter,
16+
formatProfileListHeader,
17+
formatProfileListRow,
18+
formatProfileListTableHeader,
19+
writeJson,
20+
} from "../../lib/formatters/index.js";
21+
import { resolveOrgAndProject } from "../../lib/resolve-target.js";
22+
import type { Writer } from "../../types/index.js";
23+
24+
type ListFlags = {
25+
readonly period: string;
26+
readonly limit: number;
27+
readonly json: boolean;
28+
};
29+
30+
/** Valid period values */
31+
const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"];
32+
33+
/** Usage hint for ContextError messages */
34+
const USAGE_HINT = "sentry profile list <org>/<project>";
35+
36+
/**
37+
* Parse and validate the stats period.
38+
*/
39+
function parsePeriod(value: string): string {
40+
if (!VALID_PERIODS.includes(value)) {
41+
throw new Error(
42+
`Invalid period. Must be one of: ${VALID_PERIODS.join(", ")}`
43+
);
44+
}
45+
return value;
46+
}
47+
48+
/**
49+
* Write empty state message when no profiles are found.
50+
*/
51+
function writeEmptyState(stdout: Writer, orgProject: string): void {
52+
stdout.write(`No profiling data found for ${orgProject}.\n`);
53+
stdout.write(
54+
"\nMake sure profiling is enabled for your project and that profile data has been collected.\n"
55+
);
56+
}
57+
58+
export const listCommand = buildCommand({
59+
docs: {
60+
brief: "List transactions with profiling data",
61+
fullDescription:
62+
"List transactions that have CPU profiling data in Sentry.\n\n" +
63+
"Target specification:\n" +
64+
" sentry profile list # auto-detect from DSN or config\n" +
65+
" sentry profile list <org>/<proj> # explicit org and project\n" +
66+
" sentry profile list <project> # find project across all orgs\n\n" +
67+
"The command shows transactions with profile counts and p75 timing data.",
68+
},
69+
parameters: {
70+
positional: {
71+
kind: "tuple",
72+
parameters: [
73+
{
74+
placeholder: "target",
75+
brief: "Target: <org>/<project> or <project>",
76+
parse: String,
77+
optional: true,
78+
},
79+
],
80+
},
81+
flags: {
82+
period: {
83+
kind: "parsed",
84+
parse: parsePeriod,
85+
brief: "Time period: 1h, 24h, 7d, 14d, 30d",
86+
default: "24h",
87+
},
88+
limit: {
89+
kind: "parsed",
90+
parse: numberParser,
91+
brief: "Maximum number of transactions to return",
92+
default: "20",
93+
},
94+
json: {
95+
kind: "boolean",
96+
brief: "Output as JSON",
97+
default: false,
98+
},
99+
},
100+
aliases: { n: "limit" },
101+
},
102+
async func(
103+
this: SentryContext,
104+
flags: ListFlags,
105+
target?: string
106+
): Promise<void> {
107+
const { stdout, cwd, setContext } = this;
108+
109+
// Parse positional argument to determine resolution strategy
110+
const parsed = parseOrgProjectArg(target);
111+
112+
// For profile list, we need both org and project
113+
// We don't support org-wide profile listing (too expensive)
114+
if (parsed.type === "org-all") {
115+
throw new ContextError(
116+
"Project",
117+
"Profile listing requires a specific project.\n\n" +
118+
"Usage: sentry profile list <org>/<project>"
119+
);
120+
}
121+
122+
// Determine project slug based on parsed type
123+
let projectSlug: string | undefined;
124+
if (parsed.type === "explicit") {
125+
projectSlug = parsed.project;
126+
} else if (parsed.type === "project-search") {
127+
projectSlug = parsed.projectSlug;
128+
}
129+
130+
// Resolve org and project
131+
const resolvedTarget = await resolveOrgAndProject({
132+
org: parsed.type === "explicit" ? parsed.org : undefined,
133+
project: projectSlug,
134+
cwd,
135+
usageHint: USAGE_HINT,
136+
});
137+
138+
if (!resolvedTarget) {
139+
throw new ContextError("Organization and project", USAGE_HINT);
140+
}
141+
142+
// Set telemetry context
143+
setContext([resolvedTarget.org], [resolvedTarget.project]);
144+
145+
// Get project to retrieve numeric ID (required for profile API)
146+
const project = await getProject(
147+
resolvedTarget.org,
148+
resolvedTarget.project
149+
);
150+
151+
// Fetch profiled transactions
152+
const response = await listProfiledTransactions(
153+
resolvedTarget.org,
154+
project.id,
155+
{
156+
statsPeriod: flags.period,
157+
limit: flags.limit,
158+
}
159+
);
160+
161+
const orgProject = `${resolvedTarget.org}/${resolvedTarget.project}`;
162+
163+
// JSON output
164+
if (flags.json) {
165+
writeJson(stdout, response.data);
166+
return;
167+
}
168+
169+
// Empty state
170+
if (response.data.length === 0) {
171+
writeEmptyState(stdout, orgProject);
172+
return;
173+
}
174+
175+
// Human-readable output
176+
stdout.write(`${formatProfileListHeader(orgProject, flags.period)}\n\n`);
177+
stdout.write(`${formatProfileListTableHeader()}\n`);
178+
stdout.write(`${divider(76)}\n`);
179+
180+
for (const row of response.data) {
181+
stdout.write(`${formatProfileListRow(row)}\n`);
182+
}
183+
184+
stdout.write(formatProfileListFooter());
185+
186+
if (resolvedTarget.detectedFrom) {
187+
stdout.write(`\n\nDetected from ${resolvedTarget.detectedFrom}\n`);
188+
}
189+
},
190+
});

0 commit comments

Comments
 (0)