From cddef24f74d94844e842306c5f8132439de28d00 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 10:05:03 +0000 Subject: [PATCH 01/32] 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 --- src/app.ts | 2 + src/commands/profile/index.ts | 19 +++ src/commands/profile/list.ts | 190 +++++++++++++++++++++++++++++ src/commands/profile/view.ts | 204 +++++++++++++++++++++++++++++++ src/lib/api-client.ts | 78 ++++++++++++ src/lib/formatters/index.ts | 1 + src/lib/formatters/profile.ts | 209 ++++++++++++++++++++++++++++++++ src/lib/profile/analyzer.ts | 214 ++++++++++++++++++++++++++++++++ src/lib/sentry-urls.ts | 33 +++++ src/types/index.ts | 22 +++- src/types/profile.ts | 221 ++++++++++++++++++++++++++++++++++ 11 files changed, 1192 insertions(+), 1 deletion(-) create mode 100644 src/commands/profile/index.ts create mode 100644 src/commands/profile/list.ts create mode 100644 src/commands/profile/view.ts create mode 100644 src/lib/formatters/profile.ts create mode 100644 src/lib/profile/analyzer.ts create mode 100644 src/types/profile.ts diff --git a/src/app.ts b/src/app.ts index 0200ccc8..cb282d82 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,6 +13,7 @@ import { eventRoute } from "./commands/event/index.js"; import { helpCommand } from "./commands/help.js"; import { issueRoute } from "./commands/issue/index.js"; import { orgRoute } from "./commands/org/index.js"; +import { profileRoute } from "./commands/profile/index.js"; import { projectRoute } from "./commands/project/index.js"; import { CLI_VERSION } from "./lib/constants.js"; import { CliError, getExitCode } from "./lib/errors.js"; @@ -28,6 +29,7 @@ export const routes = buildRouteMap({ project: projectRoute, issue: issueRoute, event: eventRoute, + profile: profileRoute, api: apiCommand, }, defaultCommand: "help", diff --git a/src/commands/profile/index.ts b/src/commands/profile/index.ts new file mode 100644 index 00000000..682fd7b2 --- /dev/null +++ b/src/commands/profile/index.ts @@ -0,0 +1,19 @@ +import { buildRouteMap } from "@stricli/core"; +import { listCommand } from "./list.js"; +import { viewCommand } from "./view.js"; + +export const profileRoute = buildRouteMap({ + routes: { + list: listCommand, + view: viewCommand, + }, + docs: { + brief: "Analyze CPU profiling data", + fullDescription: + "View and analyze CPU profiling data from your Sentry projects.\n\n" + + "Commands:\n" + + " list List transactions with profiling data\n" + + " view View CPU profiling analysis for a transaction", + hideRoute: {}, + }, +}); diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts new file mode 100644 index 00000000..189e3a55 --- /dev/null +++ b/src/commands/profile/list.ts @@ -0,0 +1,190 @@ +/** + * sentry profile list + * + * List transactions with profiling data from Sentry. + * Uses the Explore Events API with the profile_functions dataset. + */ + +import { buildCommand, numberParser } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { getProject, listProfiledTransactions } from "../../lib/api-client.js"; +import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { ContextError } from "../../lib/errors.js"; +import { + divider, + formatProfileListFooter, + formatProfileListHeader, + formatProfileListRow, + formatProfileListTableHeader, + writeJson, +} from "../../lib/formatters/index.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import type { Writer } from "../../types/index.js"; + +type ListFlags = { + readonly period: string; + readonly limit: number; + readonly json: boolean; +}; + +/** Valid period values */ +const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"]; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry profile list /"; + +/** + * Parse and validate the stats period. + */ +function parsePeriod(value: string): string { + if (!VALID_PERIODS.includes(value)) { + throw new Error( + `Invalid period. Must be one of: ${VALID_PERIODS.join(", ")}` + ); + } + return value; +} + +/** + * Write empty state message when no profiles are found. + */ +function writeEmptyState(stdout: Writer, orgProject: string): void { + stdout.write(`No profiling data found for ${orgProject}.\n`); + stdout.write( + "\nMake sure profiling is enabled for your project and that profile data has been collected.\n" + ); +} + +export const listCommand = buildCommand({ + docs: { + brief: "List transactions with profiling data", + fullDescription: + "List transactions that have CPU profiling data in Sentry.\n\n" + + "Target specification:\n" + + " sentry profile list # auto-detect from DSN or config\n" + + " sentry profile list / # explicit org and project\n" + + " sentry profile list # find project across all orgs\n\n" + + "The command shows transactions with profile counts and p75 timing data.", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "target", + brief: "Target: / or ", + parse: String, + optional: true, + }, + ], + }, + flags: { + period: { + kind: "parsed", + parse: parsePeriod, + brief: "Time period: 1h, 24h, 7d, 14d, 30d", + default: "24h", + }, + limit: { + kind: "parsed", + parse: numberParser, + brief: "Maximum number of transactions to return", + default: "20", + }, + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + }, + aliases: { n: "limit" }, + }, + async func( + this: SentryContext, + flags: ListFlags, + target?: string + ): Promise { + const { stdout, cwd, setContext } = this; + + // Parse positional argument to determine resolution strategy + const parsed = parseOrgProjectArg(target); + + // For profile list, we need both org and project + // We don't support org-wide profile listing (too expensive) + if (parsed.type === "org-all") { + throw new ContextError( + "Project", + "Profile listing requires a specific project.\n\n" + + "Usage: sentry profile list /" + ); + } + + // Determine project slug based on parsed type + let projectSlug: string | undefined; + if (parsed.type === "explicit") { + projectSlug = parsed.project; + } else if (parsed.type === "project-search") { + projectSlug = parsed.projectSlug; + } + + // Resolve org and project + const resolvedTarget = await resolveOrgAndProject({ + org: parsed.type === "explicit" ? parsed.org : undefined, + project: projectSlug, + cwd, + usageHint: USAGE_HINT, + }); + + if (!resolvedTarget) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + // Set telemetry context + setContext([resolvedTarget.org], [resolvedTarget.project]); + + // Get project to retrieve numeric ID (required for profile API) + const project = await getProject( + resolvedTarget.org, + resolvedTarget.project + ); + + // Fetch profiled transactions + const response = await listProfiledTransactions( + resolvedTarget.org, + project.id, + { + statsPeriod: flags.period, + limit: flags.limit, + } + ); + + const orgProject = `${resolvedTarget.org}/${resolvedTarget.project}`; + + // JSON output + if (flags.json) { + writeJson(stdout, response.data); + return; + } + + // Empty state + if (response.data.length === 0) { + writeEmptyState(stdout, orgProject); + return; + } + + // Human-readable output + stdout.write(`${formatProfileListHeader(orgProject, flags.period)}\n\n`); + stdout.write(`${formatProfileListTableHeader()}\n`); + stdout.write(`${divider(76)}\n`); + + for (const row of response.data) { + stdout.write(`${formatProfileListRow(row)}\n`); + } + + stdout.write(formatProfileListFooter()); + + if (resolvedTarget.detectedFrom) { + stdout.write(`\n\nDetected from ${resolvedTarget.detectedFrom}\n`); + } + }, +}); diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts new file mode 100644 index 00000000..346f136d --- /dev/null +++ b/src/commands/profile/view.ts @@ -0,0 +1,204 @@ +/** + * sentry profile view + * + * View CPU profiling analysis for a specific transaction. + * Displays hot paths, performance percentiles, and recommendations. + */ + +import { buildCommand, numberParser } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { getFlamegraph, getProject } from "../../lib/api-client.js"; +import { openInBrowser } from "../../lib/browser.js"; +import { ContextError } from "../../lib/errors.js"; +import { + formatProfileAnalysis, + muted, + writeJson, +} from "../../lib/formatters/index.js"; +import { + analyzeFlamegraph, + hasProfileData, +} from "../../lib/profile/analyzer.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { buildProfileUrl } from "../../lib/sentry-urls.js"; + +type ViewFlags = { + readonly org?: string; + readonly project?: string; + readonly period: string; + readonly limit: number; + readonly allFrames: boolean; + readonly json: boolean; + readonly web: boolean; +}; + +/** Valid period values */ +const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"]; + +/** + * Parse and validate the stats period. + */ +function parsePeriod(value: string): string { + if (!VALID_PERIODS.includes(value)) { + throw new Error( + `Invalid period. Must be one of: ${VALID_PERIODS.join(", ")}` + ); + } + return value; +} + +export const viewCommand = buildCommand({ + docs: { + brief: "View CPU profiling analysis for a transaction", + fullDescription: + "Analyze CPU profiling data for a specific transaction.\n\n" + + "Displays:\n" + + " - Performance percentiles (p75, p95, p99)\n" + + " - Hot paths (functions consuming the most CPU time)\n" + + " - Recommendations for optimization\n\n" + + "By default, only user application code is shown. Use --all-frames to include library code.\n\n" + + "The organization and project are resolved from:\n" + + " 1. --org and --project flags\n" + + " 2. Config defaults\n" + + " 3. SENTRY_DSN environment variable or source code detection", + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "transaction", + brief: 'Transaction name (e.g., "/api/users", "POST /api/orders")', + parse: String, + }, + ], + }, + flags: { + org: { + kind: "parsed", + parse: String, + brief: "Organization slug", + optional: true, + }, + project: { + kind: "parsed", + parse: String, + brief: "Project slug", + optional: true, + }, + period: { + kind: "parsed", + parse: parsePeriod, + brief: "Stats period: 1h, 24h, 7d, 14d, 30d", + default: "7d", + }, + limit: { + kind: "parsed", + parse: numberParser, + brief: "Number of hot paths to show (max 20)", + default: "10", + }, + allFrames: { + kind: "boolean", + brief: "Include library/system frames (default: user code only)", + default: false, + }, + json: { + kind: "boolean", + brief: "Output as JSON", + default: false, + }, + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { w: "web", n: "limit" }, + }, + async func( + this: SentryContext, + flags: ViewFlags, + transactionName: string + ): Promise { + const { stdout, cwd, setContext } = this; + + // Resolve org and project + const target = await resolveOrgAndProject({ + org: flags.org, + project: flags.project, + cwd, + usageHint: `sentry profile view "${transactionName}" --org --project `, + }); + + if (!target) { + throw new ContextError( + "Organization and project", + `sentry profile view "${transactionName}" --org --project ` + ); + } + + // Set telemetry context + setContext([target.org], [target.project]); + + // Open in browser if requested + if (flags.web) { + await openInBrowser( + stdout, + buildProfileUrl(target.org, target.project, transactionName), + "profile" + ); + return; + } + + // Get project to retrieve numeric ID + const project = await getProject(target.org, target.project); + + // Fetch flamegraph data + const flamegraph = await getFlamegraph( + target.org, + project.id, + transactionName, + flags.period + ); + + // Check if we have profile data + if (!hasProfileData(flamegraph)) { + stdout.write( + `No profiling data found for transaction "${transactionName}".\n\n` + ); + stdout.write( + "Make sure:\n" + + " 1. Profiling is enabled for your project\n" + + " 2. The transaction name is correct\n" + + " 3. Profile data has been collected in the specified period\n" + ); + return; + } + + // Clamp limit to valid range + const limit = Math.min(Math.max(flags.limit, 1), 20); + + // Analyze the flamegraph + const analysis = analyzeFlamegraph(flamegraph, { + transactionName, + period: flags.period, + limit, + userCodeOnly: !flags.allFrames, + }); + + // JSON output + if (flags.json) { + writeJson(stdout, analysis); + return; + } + + // Human-readable output + const lines = formatProfileAnalysis(analysis); + stdout.write(`${lines.join("\n")}\n`); + + if (target.detectedFrom) { + stdout.write(`\n${muted(`Detected from ${target.detectedFrom}`)}\n`); + } + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index fb2480f1..75822597 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -8,6 +8,10 @@ import kyHttpClient, { type KyInstance } from "ky"; import { z } from "zod"; import { + type Flamegraph, + FlamegraphSchema, + type ProfileFunctionsResponse, + ProfileFunctionsResponseSchema, type ProjectKey, ProjectKeySchema, type Region, @@ -1012,3 +1016,77 @@ export function getCurrentUser(): Promise { schema: SentryUserSchema, }); } + +// Profiling API + +/** + * Get flamegraph data for a transaction. + * Returns aggregated profiling data across all samples for the given transaction. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @param projectId - Project ID (numeric) + * @param transactionName - Transaction name to analyze + * @param statsPeriod - Time period to aggregate (e.g., "7d", "24h") + * @returns Flamegraph data with frames, samples, and statistics + */ +export function getFlamegraph( + orgSlug: string, + projectId: string | number, + transactionName: string, + statsPeriod = "7d" +): Promise { + // Escape special characters in transaction name for query + const escapedTransaction = transactionName + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + + return orgScopedRequest( + `/organizations/${orgSlug}/profiling/flamegraph/`, + { + params: { + project: projectId, + query: `event.type:transaction transaction:"${escapedTransaction}"`, + statsPeriod, + }, + schema: FlamegraphSchema, + } + ); +} + +/** + * List transactions with profiling data using the Explore Events API. + * Queries the profile_functions dataset to find transactions with profiles. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @param projectId - Project ID (numeric) + * @param options - Query options + * @returns List of transactions with profile counts and timing data + */ +export function listProfiledTransactions( + orgSlug: string, + projectId: string | number, + options: { + statsPeriod?: string; + limit?: number; + } = {} +): Promise { + const { statsPeriod = "24h", limit = 20 } = options; + + return orgScopedRequest( + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "profile_functions", + field: ["transaction", "count()", "p75(function.duration)"], + statsPeriod, + per_page: limit, + project: projectId, + // Sort by count descending to show most active transactions first + sort: "-count()", + }, + schema: ProfileFunctionsResponseSchema, + } + ); +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index 1109687e..40e9a50d 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -9,4 +9,5 @@ export * from "./colors.js"; export * from "./human.js"; export * from "./json.js"; export * from "./output.js"; +export * from "./profile.js"; export * from "./seer.js"; diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts new file mode 100644 index 00000000..475ee323 --- /dev/null +++ b/src/lib/formatters/profile.ts @@ -0,0 +1,209 @@ +/** + * Profile Formatters + * + * Human-readable output formatters for profiling data. + * Formats flamegraph analysis, hot paths, and transaction lists. + */ + +import type { ProfileAnalysis, ProfileFunctionRow } from "../../types/index.js"; +import { formatDuration } from "../profile/analyzer.js"; +import { bold, muted, yellow } from "./colors.js"; + +/** Minimum width for header separator line */ +const MIN_HEADER_WIDTH = 60; + +/** + * Format a section header with separator line. + */ +function formatSectionHeader(title: string): string[] { + const width = Math.max(MIN_HEADER_WIDTH, title.length); + return [title, muted("─".repeat(width))]; +} + +/** + * Format the profile analysis header with transaction name and period. + */ +function formatProfileHeader(analysis: ProfileAnalysis): string[] { + const header = `${analysis.transactionName}: CPU Profile Analysis (last ${analysis.period})`; + const separatorWidth = Math.max( + MIN_HEADER_WIDTH, + Math.min(80, header.length) + ); + return [header, muted("═".repeat(separatorWidth))]; +} + +/** + * Format performance percentiles section. + */ +function formatPercentiles(analysis: ProfileAnalysis): string[] { + const { percentiles } = analysis; + const lines: string[] = []; + + lines.push(""); + lines.push(bold("Performance Percentiles")); + lines.push( + ` p75: ${formatDuration(percentiles.p75)} ` + + `p95: ${formatDuration(percentiles.p95)} ` + + `p99: ${formatDuration(percentiles.p99)}` + ); + + return lines; +} + +/** + * Format a single hot path row for the table. + */ +function formatHotPathRow( + index: number, + frame: { name: string; file: string; line: number }, + percentage: number +): string { + const num = `${index + 1}`.padStart(3); + const funcName = frame.name.slice(0, 40).padEnd(40); + const location = `${frame.file}:${frame.line}`.slice(0, 20).padEnd(20); + const pct = `${percentage.toFixed(1)}%`.padStart(7); + + return ` ${num} ${funcName} ${location} ${pct}`; +} + +/** + * Format the hot paths table. + */ +function formatHotPaths(analysis: ProfileAnalysis): string[] { + const { hotPaths, userCodeOnly } = analysis; + const lines: string[] = []; + + lines.push(""); + const title = userCodeOnly + ? `Hot Paths (Top ${hotPaths.length} by CPU time, user code only)` + : `Hot Paths (Top ${hotPaths.length} by CPU time)`; + lines.push(...formatSectionHeader(title)); + + // Table header + lines.push( + muted( + " # Function File:Line % Time" + ) + ); + + if (hotPaths.length === 0) { + lines.push(muted(" No profile data available.")); + return lines; + } + + // Table rows + for (let i = 0; i < hotPaths.length; i++) { + const hotPath = hotPaths[i]; + if (!hotPath) { + continue; + } + const frame = hotPath.frames[0]; + if (!frame) { + continue; + } + lines.push( + formatHotPathRow( + i, + { name: frame.name, file: frame.file, line: frame.line }, + hotPath.percentage + ) + ); + } + + return lines; +} + +/** + * Format recommendations based on hot paths. + */ +function formatRecommendations(analysis: ProfileAnalysis): string[] { + const { hotPaths } = analysis; + const lines: string[] = []; + + if (hotPaths.length === 0) { + return lines; + } + + const topHotPath = hotPaths[0]; + if (!topHotPath || topHotPath.percentage < 10) { + return lines; + } + + const topFrame = topHotPath.frames[0]; + if (!topFrame) { + return lines; + } + + lines.push(""); + lines.push(...formatSectionHeader("Recommendations")); + lines.push( + ` ${yellow("⚠")} ${topFrame.name} is consuming ${topHotPath.percentage.toFixed(1)}% of CPU time` + ); + lines.push(" Consider optimizing this function or caching its results."); + + return lines; +} + +/** + * Format a complete profile analysis for human-readable output. + * + * @param analysis - The analyzed profile data + * @returns Array of formatted lines + */ +export function formatProfileAnalysis(analysis: ProfileAnalysis): string[] { + const lines: string[] = []; + + lines.push(...formatProfileHeader(analysis)); + lines.push(...formatPercentiles(analysis)); + lines.push(...formatHotPaths(analysis)); + lines.push(...formatRecommendations(analysis)); + + return lines; +} + +/** + * Format the transaction list header for profile list command. + * + * @param orgProject - Organization/project display string + * @param period - Time period being displayed + * @returns Formatted header string + */ +export function formatProfileListHeader( + orgProject: string, + period: string +): string { + return `Transactions with Profiles in ${orgProject} (last ${period}):`; +} + +/** + * Format the column headers for the transaction list table. + */ +export function formatProfileListTableHeader(): string { + return muted( + " TRANSACTION PROFILES p75" + ); +} + +/** + * Format a single transaction row for the list. + * + * @param row - Profile function row data + * @returns Formatted row string + */ +export function formatProfileListRow(row: ProfileFunctionRow): string { + const transaction = (row.transaction ?? "unknown").slice(0, 48).padEnd(48); + const count = `${row["count()"] ?? 0}`.padStart(10); + const p75Ms = row["p75(function.duration)"] + ? formatDuration(row["p75(function.duration)"] / 1_000_000) // ns to ms + : "-"; + const p75 = p75Ms.padStart(10); + + return ` ${transaction} ${count} ${p75}`; +} + +/** + * Format the footer tip for profile list command. + */ +export function formatProfileListFooter(): string { + return "\nTip: Use 'sentry profile view \"\"' to analyze."; +} diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts new file mode 100644 index 00000000..ca1fe015 --- /dev/null +++ b/src/lib/profile/analyzer.ts @@ -0,0 +1,214 @@ +/** + * Profile Analyzer + * + * Utilities for analyzing flamegraph data to extract hot paths, + * identify performance hotspots, and generate insights. + */ + +import type { + Flamegraph, + FlamegraphFrame, + FlamegraphFrameInfo, + HotPath, + ProfileAnalysis, +} from "../../types/index.js"; + +/** Nanoseconds per millisecond */ +const NS_PER_MS = 1_000_000; + +/** + * Convert nanoseconds to milliseconds. + */ +export function nsToMs(ns: number): number { + return ns / NS_PER_MS; +} + +/** + * Format duration in milliseconds to a human-readable string. + * Shows appropriate precision based on magnitude. + */ +export function formatDuration(ms: number): string { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(1)}s`; + } + if (ms >= 100) { + return `${Math.round(ms)}ms`; + } + if (ms >= 10) { + return `${ms.toFixed(1)}ms`; + } + if (ms >= 1) { + return `${ms.toFixed(2)}ms`; + } + // Sub-millisecond + const us = ms * 1000; + if (us >= 1) { + return `${us.toFixed(0)}µs`; + } + return `${(us * 1000).toFixed(0)}ns`; +} + +/** + * Check if a flamegraph has valid profile data. + */ +export function hasProfileData(flamegraph: Flamegraph): boolean { + return ( + flamegraph.profiles.length > 0 && + flamegraph.shared.frames.length > 0 && + flamegraph.shared.frame_infos.length > 0 + ); +} + +/** + * Get the total weight (time) across all samples. + */ +function getTotalWeight(flamegraph: Flamegraph): number { + let total = 0; + for (const profile of flamegraph.profiles) { + for (const weight of profile.weights) { + total += weight; + } + } + return total; +} + +/** + * Extract hot paths from flamegraph data. + * Returns the top N call stacks by CPU time. + * + * @param flamegraph - The flamegraph data + * @param limit - Maximum number of hot paths to return + * @param userCodeOnly - Filter to only user application code + * @returns Array of hot paths sorted by CPU time (descending) + */ +export function analyzeHotPaths( + flamegraph: Flamegraph, + limit: number, + userCodeOnly: boolean +): HotPath[] { + const { frames, frame_infos } = flamegraph.shared; + const totalWeight = getTotalWeight(flamegraph); + + if (totalWeight === 0 || frames.length === 0) { + return []; + } + + // Build frame index to info mapping + const frameInfoMap: Array<{ + frame: FlamegraphFrame; + info: FlamegraphFrameInfo; + index: number; + }> = []; + + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + const info = frame_infos[i]; + if (!(frame && info)) { + continue; + } + + // Filter by user code if requested + if (userCodeOnly && !frame.is_application) { + continue; + } + + frameInfoMap.push({ frame, info, index: i }); + } + + // Sort by self time (most CPU-intensive frames first) + frameInfoMap.sort((a, b) => b.info.sumSelfTime - a.info.sumSelfTime); + + // Take top N + const topFrames = frameInfoMap.slice(0, limit); + + // Convert to HotPath format + return topFrames.map(({ frame, info }) => ({ + frames: [frame], // Single frame for now (could expand to full call stack) + frameInfo: info, + percentage: (info.sumSelfTime / totalWeight) * 100, + })); +} + +/** + * Calculate aggregate percentiles from flamegraph data. + * Returns p75, p95, p99 in milliseconds. + */ +export function calculatePercentiles(flamegraph: Flamegraph): { + p75: number; + p95: number; + p99: number; +} { + const { frame_infos } = flamegraph.shared; + + if (frame_infos.length === 0) { + return { p75: 0, p95: 0, p99: 0 }; + } + + // Aggregate percentiles across all frames (weighted average would be better, + // but for simplicity we use max which represents worst-case) + let maxP75 = 0; + let maxP95 = 0; + let maxP99 = 0; + + for (const info of frame_infos) { + maxP75 = Math.max(maxP75, info.p75Duration); + maxP95 = Math.max(maxP95, info.p95Duration); + maxP99 = Math.max(maxP99, info.p99Duration); + } + + return { + p75: nsToMs(maxP75), + p95: nsToMs(maxP95), + p99: nsToMs(maxP99), + }; +} + +/** + * Get total sample count from flamegraph. + */ +function getTotalSamples(flamegraph: Flamegraph): number { + let total = 0; + for (const profile of flamegraph.profiles) { + total += profile.samples.length; + } + return total; +} + +/** Options for flamegraph analysis */ +type AnalyzeOptions = { + /** The transaction name being analyzed */ + transactionName: string; + /** The time period of the analysis (e.g., "7d") */ + period: string; + /** Maximum hot paths to include */ + limit: number; + /** Filter to user application code only */ + userCodeOnly: boolean; +}; + +/** + * Analyze a flamegraph and return structured analysis data. + * + * @param flamegraph - The flamegraph data from the API + * @param options - Analysis options + * @returns Structured profile analysis + */ +export function analyzeFlamegraph( + flamegraph: Flamegraph, + options: AnalyzeOptions +): ProfileAnalysis { + const { transactionName, period, limit, userCodeOnly } = options; + const hotPaths = analyzeHotPaths(flamegraph, limit, userCodeOnly); + const percentiles = calculatePercentiles(flamegraph); + const totalSamples = getTotalSamples(flamegraph); + + return { + transactionName, + platform: flamegraph.platform, + period, + percentiles, + hotPaths, + totalSamples, + userCodeOnly, + }; +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 7b4f8057..b9fbf73d 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -104,3 +104,36 @@ export function buildBillingUrl(orgSlug: string, product?: string): string { const base = `${getSentryBaseUrl()}/settings/${orgSlug}/billing/overview/`; return product ? `${base}?product=${product}` : base; } + +// Profiling URLs + +/** + * Build URL to the profiling flamegraph view for a transaction. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @param transactionName - Transaction name to view profiles for + * @returns Full URL to the profiling flamegraph view + */ +export function buildProfileUrl( + orgSlug: string, + projectSlug: string, + transactionName: string +): string { + const encodedTransaction = encodeURIComponent(transactionName); + return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/profile/${projectSlug}/flamegraph/?query=transaction%3A${encodedTransaction}`; +} + +/** + * Build URL to the profiling summary page for a project. + * + * @param orgSlug - Organization slug + * @param projectSlug - Project slug + * @returns Full URL to the profiling summary page + */ +export function buildProfilingSummaryUrl( + orgSlug: string, + projectSlug: string +): string { + return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/?project=${projectSlug}`; +} diff --git a/src/types/index.ts b/src/types/index.ts index 5ff197f0..2ef7f9e6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,6 +31,27 @@ export { TokenErrorResponseSchema, TokenResponseSchema, } from "./oauth.js"; +// Profile types +export type { + Flamegraph, + FlamegraphFrame, + FlamegraphFrameInfo, + FlamegraphProfile, + FlamegraphProfileMetadata, + HotPath, + ProfileAnalysis, + ProfileFunctionRow, + ProfileFunctionsResponse, +} from "./profile.js"; +export { + FlamegraphFrameInfoSchema, + FlamegraphFrameSchema, + FlamegraphProfileMetadataSchema, + FlamegraphProfileSchema, + FlamegraphSchema, + ProfileFunctionRowSchema, + ProfileFunctionsResponseSchema, +} from "./profile.js"; export type { AutofixResponse, AutofixState, @@ -78,7 +99,6 @@ export type { UserGeo, UserRegionsResponse, } from "./sentry.js"; - export { BreadcrumbSchema, BreadcrumbsEntrySchema, diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 00000000..6778ca14 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,221 @@ +/** + * Profiling API Types + * + * Types for Sentry's profiling API responses including flamegraph data + * and profile functions. Zod schemas provide runtime validation. + */ + +import { z } from "zod"; + +// Flamegraph Types + +/** + * A single frame in a flamegraph call stack. + * Contains source location and whether it's application code. + */ +export const FlamegraphFrameSchema = z + .object({ + /** Source file path */ + file: z.string(), + /** Image/module name (for native code) */ + image: z.string().optional(), + /** Whether this is user application code (vs library/system) */ + is_application: z.boolean(), + /** Line number in source file */ + line: z.number(), + /** Function name */ + name: z.string(), + /** Full file path */ + path: z.string().optional(), + /** Unique identifier for deduplication */ + fingerprint: z.number(), + }) + .passthrough(); + +export type FlamegraphFrame = z.infer; + +/** + * Statistics for a single frame across all samples. + * Contains timing percentiles and aggregate counts. + */ +export const FlamegraphFrameInfoSchema = z + .object({ + /** Number of times this frame appears */ + count: z.number(), + /** Total weight/time in this frame */ + weight: z.number(), + /** Sum of all durations (nanoseconds) */ + sumDuration: z.number(), + /** Sum of self time only (excluding children) */ + sumSelfTime: z.number(), + /** 75th percentile duration (nanoseconds) */ + p75Duration: z.number(), + /** 95th percentile duration (nanoseconds) */ + p95Duration: z.number(), + /** 99th percentile duration (nanoseconds) */ + p99Duration: z.number(), + }) + .passthrough(); + +export type FlamegraphFrameInfo = z.infer; + +/** + * Metadata for a single profile within a flamegraph. + */ +export const FlamegraphProfileMetadataSchema = z + .object({ + project_id: z.number(), + profile_id: z.string(), + /** Start timestamp (Unix epoch) */ + start: z.number(), + /** End timestamp (Unix epoch) */ + end: z.number(), + }) + .passthrough(); + +export type FlamegraphProfileMetadata = z.infer< + typeof FlamegraphProfileMetadataSchema +>; + +/** + * A single profile with sample data. + * Contains the actual call stack samples and timing weights. + */ +export const FlamegraphProfileSchema = z + .object({ + /** End value for the profile timeline */ + endValue: z.number(), + /** Whether this is the main thread */ + isMainThread: z.boolean(), + /** Thread/profile name */ + name: z.string(), + /** Sample data: arrays of frame indices representing call stacks */ + samples: z.array(z.array(z.number())), + /** Start value for the profile timeline */ + startValue: z.number(), + /** Thread ID */ + threadID: z.number(), + /** Profile type (e.g., "sampled") */ + type: z.string(), + /** Time unit (e.g., "nanoseconds") */ + unit: z.string(), + /** Time weights for each sample */ + weights: z.array(z.number()), + /** Sample durations in nanoseconds */ + sample_durations_ns: z.array(z.number()).optional(), + /** Sample counts */ + sample_counts: z.array(z.number()).optional(), + }) + .passthrough(); + +export type FlamegraphProfile = z.infer; + +/** + * Complete flamegraph response from the profiling API. + * Contains all frames, profiles, and aggregate statistics. + */ +export const FlamegraphSchema = z + .object({ + /** Index of the active/main profile */ + activeProfileIndex: z.number(), + /** Additional metadata */ + metadata: z.record(z.unknown()).optional(), + /** Platform/language (e.g., "python", "node") */ + platform: z.string(), + /** Array of profile data with samples */ + profiles: z.array(FlamegraphProfileSchema), + /** Project ID */ + projectID: z.number(), + /** Shared data across all profiles */ + shared: z.object({ + /** All unique frames in the flamegraph */ + frames: z.array(FlamegraphFrameSchema), + /** Statistics for each frame (parallel array to frames) */ + frame_infos: z.array(FlamegraphFrameInfoSchema), + /** Profile metadata */ + profiles: z.array(FlamegraphProfileMetadataSchema), + }), + /** Transaction name this flamegraph represents */ + transactionName: z.string().optional(), + /** Additional metrics */ + metrics: z.unknown().optional(), + }) + .passthrough(); + +export type Flamegraph = z.infer; + +// Explore Events API Types (for profile_functions dataset) + +/** + * A row from the profile_functions dataset query. + * Used for listing transactions with profile data. + */ +export const ProfileFunctionRowSchema = z + .object({ + /** Transaction name */ + transaction: z.string().optional(), + /** Number of profiles/samples */ + "count()": z.number().optional(), + /** 75th percentile duration */ + "p75(function.duration)": z.number().optional(), + /** 95th percentile duration */ + "p95(function.duration)": z.number().optional(), + }) + .passthrough(); + +export type ProfileFunctionRow = z.infer; + +/** + * Response from the Explore Events API for profile_functions dataset. + */ +export const ProfileFunctionsResponseSchema = z.object({ + data: z.array(ProfileFunctionRowSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), +}); + +export type ProfileFunctionsResponse = z.infer< + typeof ProfileFunctionsResponseSchema +>; + +// Analyzed Profile Types (for CLI output) + +/** + * A hot path (call stack) identified from profile analysis. + */ +export type HotPath = { + /** Frames in the call stack (leaf to root) */ + frames: FlamegraphFrame[]; + /** Frame info for the leaf frame */ + frameInfo: FlamegraphFrameInfo; + /** Percentage of total CPU time */ + percentage: number; +}; + +/** + * Analyzed profile data ready for display. + */ +export type ProfileAnalysis = { + /** Transaction name */ + transactionName: string; + /** Platform (e.g., "python", "node") */ + platform: string; + /** Time period analyzed */ + period: string; + /** Performance percentiles (in milliseconds) */ + percentiles: { + p75: number; + p95: number; + p99: number; + }; + /** Top hot paths by CPU time */ + hotPaths: HotPath[]; + /** Total number of samples analyzed */ + totalSamples: number; + /** Whether analysis focused on user code only */ + userCodeOnly: boolean; +}; From 4ac04d6325f16c335a88271f712da4139abe7f68 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 11:29:32 +0000 Subject: [PATCH 02/32] fix(profile): handle empty profiles and fix percentage calculation - Make shared.profiles optional in schema (may be absent when no data) - Handle null values for sample_durations_ns and sample_counts - Fix hot path percentage calculation to use total self time --- src/lib/profile/analyzer.ts | 17 ++++++++--------- src/types/profile.ts | 8 ++++---- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts index ca1fe015..381619b5 100644 --- a/src/lib/profile/analyzer.ts +++ b/src/lib/profile/analyzer.ts @@ -60,14 +60,13 @@ export function hasProfileData(flamegraph: Flamegraph): boolean { } /** - * Get the total weight (time) across all samples. + * Get the total self time across all frames. + * This gives the total CPU time spent in all functions. */ -function getTotalWeight(flamegraph: Flamegraph): number { +function getTotalSelfTime(flamegraph: Flamegraph): number { let total = 0; - for (const profile of flamegraph.profiles) { - for (const weight of profile.weights) { - total += weight; - } + for (const info of flamegraph.shared.frame_infos) { + total += info.sumSelfTime; } return total; } @@ -87,9 +86,9 @@ export function analyzeHotPaths( userCodeOnly: boolean ): HotPath[] { const { frames, frame_infos } = flamegraph.shared; - const totalWeight = getTotalWeight(flamegraph); + const totalSelfTime = getTotalSelfTime(flamegraph); - if (totalWeight === 0 || frames.length === 0) { + if (totalSelfTime === 0 || frames.length === 0) { return []; } @@ -125,7 +124,7 @@ export function analyzeHotPaths( return topFrames.map(({ frame, info }) => ({ frames: [frame], // Single frame for now (could expand to full call stack) frameInfo: info, - percentage: (info.sumSelfTime / totalWeight) * 100, + percentage: (info.sumSelfTime / totalSelfTime) * 100, })); } diff --git a/src/types/profile.ts b/src/types/profile.ts index 6778ca14..dd41c8b3 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -102,9 +102,9 @@ export const FlamegraphProfileSchema = z /** Time weights for each sample */ weights: z.array(z.number()), /** Sample durations in nanoseconds */ - sample_durations_ns: z.array(z.number()).optional(), + sample_durations_ns: z.array(z.number()).nullish(), /** Sample counts */ - sample_counts: z.array(z.number()).optional(), + sample_counts: z.array(z.number()).nullish(), }) .passthrough(); @@ -132,8 +132,8 @@ export const FlamegraphSchema = z frames: z.array(FlamegraphFrameSchema), /** Statistics for each frame (parallel array to frames) */ frame_infos: z.array(FlamegraphFrameInfoSchema), - /** Profile metadata */ - profiles: z.array(FlamegraphProfileMetadataSchema), + /** Profile metadata (may be absent when no profiles exist) */ + profiles: z.array(FlamegraphProfileMetadataSchema).optional(), }), /** Transaction name this flamegraph represents */ transactionName: z.string().optional(), From e7571838e57251ff9e062636d9a09ec392d8b242 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 13:11:07 +0000 Subject: [PATCH 03/32] feat(profile): add transaction aliases for quick reference Enable quick access to transactions via numeric indices or short aliases: - `sentry profile list` now shows # and ALIAS columns - `sentry profile view 1` - access by numeric index - `sentry profile view i` - access by alias (last unique segment) Implementation: - Add transaction_aliases SQLite table (schema v5) - Add TransactionAliasEntry type for cached aliases - Add buildTransactionAliases() using existing alias algorithm - Add resolveTransaction() with stale cache detection - Update formatters to show alias columns Aliases are fingerprinted by org:project:period and error with helpful messages when stale (e.g., different period) or unknown. --- src/commands/profile/list.ts | 44 ++++++- src/commands/profile/view.ts | 23 +++- src/lib/db/schema.ts | 29 ++++- src/lib/db/transaction-aliases.ts | 192 ++++++++++++++++++++++++++++++ src/lib/formatters/profile.ts | 37 +++++- src/lib/resolve-transaction.ts | 187 +++++++++++++++++++++++++++++ src/lib/transaction-alias.ts | 123 +++++++++++++++++++ src/types/index.ts | 1 + src/types/profile.ts | 17 +++ 9 files changed, 636 insertions(+), 17 deletions(-) create mode 100644 src/lib/db/transaction-aliases.ts create mode 100644 src/lib/resolve-transaction.ts create mode 100644 src/lib/transaction-alias.ts diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index 189e3a55..fc481e74 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -9,6 +9,10 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getProject, listProfiledTransactions } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { + buildTransactionFingerprint, + setTransactionAliases, +} from "../../lib/db/transaction-aliases.js"; import { ContextError } from "../../lib/errors.js"; import { divider, @@ -19,7 +23,8 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; -import type { Writer } from "../../types/index.js"; +import { buildTransactionAliases } from "../../lib/transaction-alias.js"; +import type { TransactionAliasEntry, Writer } from "../../types/index.js"; type ListFlags = { readonly period: string; @@ -160,6 +165,31 @@ export const listCommand = buildCommand({ const orgProject = `${resolvedTarget.org}/${resolvedTarget.project}`; + // Build and store transaction aliases for later use with profile view + const transactionInputs = response.data + .filter((row) => row.transaction) + .map((row) => ({ + transaction: row.transaction as string, + orgSlug: resolvedTarget.org, + projectSlug: resolvedTarget.project, + })); + + const aliases = buildTransactionAliases(transactionInputs); + + // Store aliases with fingerprint for cache validation + const fingerprint = buildTransactionFingerprint( + resolvedTarget.org, + resolvedTarget.project, + flags.period + ); + setTransactionAliases(aliases, fingerprint); + + // Build alias lookup map for formatting + const aliasMap = new Map(); + for (const alias of aliases) { + aliasMap.set(alias.transaction, alias); + } + // JSON output if (flags.json) { writeJson(stdout, response.data); @@ -172,16 +202,18 @@ export const listCommand = buildCommand({ return; } - // Human-readable output + // Human-readable output with aliases + const hasAliases = aliases.length > 0; stdout.write(`${formatProfileListHeader(orgProject, flags.period)}\n\n`); - stdout.write(`${formatProfileListTableHeader()}\n`); - stdout.write(`${divider(76)}\n`); + stdout.write(`${formatProfileListTableHeader(hasAliases)}\n`); + stdout.write(`${divider(82)}\n`); for (const row of response.data) { - stdout.write(`${formatProfileListRow(row)}\n`); + const alias = row.transaction ? aliasMap.get(row.transaction) : undefined; + stdout.write(`${formatProfileListRow(row, alias)}\n`); } - stdout.write(formatProfileListFooter()); + stdout.write(formatProfileListFooter(hasAliases)); if (resolvedTarget.detectedFrom) { stdout.write(`\n\nDetected from ${resolvedTarget.detectedFrom}\n`); diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 346f136d..6d9676db 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -20,6 +20,7 @@ import { hasProfileData, } from "../../lib/profile/analyzer.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { resolveTransaction } from "../../lib/resolve-transaction.js"; import { buildProfileUrl } from "../../lib/sentry-urls.js"; type ViewFlags = { @@ -68,7 +69,8 @@ export const viewCommand = buildCommand({ parameters: [ { placeholder: "transaction", - brief: 'Transaction name (e.g., "/api/users", "POST /api/orders")', + brief: + 'Transaction: index (1), alias (i), or full name ("/api/users")', parse: String, }, ], @@ -119,25 +121,36 @@ export const viewCommand = buildCommand({ async func( this: SentryContext, flags: ViewFlags, - transactionName: string + transactionRef: string ): Promise { const { stdout, cwd, setContext } = this; - // Resolve org and project + // Resolve org and project from flags or detection const target = await resolveOrgAndProject({ org: flags.org, project: flags.project, cwd, - usageHint: `sentry profile view "${transactionName}" --org --project `, + usageHint: `sentry profile view "${transactionRef}" --org --project `, }); if (!target) { throw new ContextError( "Organization and project", - `sentry profile view "${transactionName}" --org --project ` + `sentry profile view "${transactionRef}" --org --project ` ); } + // Resolve transaction reference (alias, index, or full name) + // This may throw ContextError if alias is stale or not found + const resolved = resolveTransaction(transactionRef, { + org: target.org, + project: target.project, + period: flags.period, + }); + + // Use resolved transaction name for the rest of the command + const transactionName = resolved.transaction; + // Set telemetry context setContext([target.org], [target.project]); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index d7b33aab..2ffcdec4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -4,7 +4,7 @@ import type { Database } from "bun:sqlite"; -const CURRENT_SCHEMA_VERSION = 4; +const CURRENT_SCHEMA_VERSION = 5; /** User identity for telemetry (single row, id=1) */ const USER_INFO_TABLE = ` @@ -48,6 +48,25 @@ const PROJECT_ROOT_CACHE_TABLE = ` ) `; +/** Transaction aliases for profile commands (1, i → full transaction name) */ +const TRANSACTION_ALIASES_TABLE = ` + CREATE TABLE IF NOT EXISTS transaction_aliases ( + idx INTEGER NOT NULL, + alias TEXT NOT NULL, + transaction_name TEXT NOT NULL, + org_slug TEXT NOT NULL, + project_slug TEXT NOT NULL, + fingerprint TEXT NOT NULL, + cached_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + PRIMARY KEY (fingerprint, idx) + ) +`; + +const TRANSACTION_ALIASES_INDEX = ` + CREATE INDEX IF NOT EXISTS idx_txn_alias_lookup + ON transaction_aliases(alias, fingerprint) +`; + export function initSchema(db: Database): void { db.exec(` -- Schema version for future migrations @@ -128,6 +147,8 @@ export function initSchema(db: Database): void { ${USER_INFO_TABLE}; ${INSTANCE_INFO_TABLE}; ${PROJECT_ROOT_CACHE_TABLE}; + ${TRANSACTION_ALIASES_TABLE}; + ${TRANSACTION_ALIASES_INDEX}; `); const versionRow = db @@ -205,6 +226,12 @@ export function runMigrations(db: Database): void { db.exec(PROJECT_ROOT_CACHE_TABLE); } + // Migration 4 -> 5: Add transaction_aliases table for profile commands + if (currentVersion < 5) { + db.exec(TRANSACTION_ALIASES_TABLE); + db.exec(TRANSACTION_ALIASES_INDEX); + } + // Update schema version if needed if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( diff --git a/src/lib/db/transaction-aliases.ts b/src/lib/db/transaction-aliases.ts new file mode 100644 index 00000000..c5a35b85 --- /dev/null +++ b/src/lib/db/transaction-aliases.ts @@ -0,0 +1,192 @@ +/** + * Transaction aliases storage for profile commands. + * Enables short references like "1" or "i" for transactions from `profile list`. + */ + +import type { TransactionAliasEntry } from "../../types/index.js"; +import { getDatabase } from "./index.js"; + +type TransactionAliasRow = { + idx: number; + alias: string; + transaction_name: string; + org_slug: string; + project_slug: string; + fingerprint: string; + cached_at: number; +}; + +/** + * Build a fingerprint for cache validation. + * Format: "orgSlug:projectSlug:period" or "orgSlug:*:period" for multi-project. + */ +export function buildTransactionFingerprint( + orgSlug: string, + projectSlug: string | null, + period: string +): string { + return `${orgSlug}:${projectSlug ?? "*"}:${period}`; +} + +/** + * Store transaction aliases from a profile list command. + * Replaces any existing aliases for the same fingerprint. + */ +export function setTransactionAliases( + aliases: TransactionAliasEntry[], + fingerprint: string +): void { + const db = getDatabase(); + const now = Date.now(); + + db.exec("BEGIN TRANSACTION"); + + try { + // Delete only aliases with the same fingerprint + db.query("DELETE FROM transaction_aliases WHERE fingerprint = ?").run( + fingerprint + ); + + const insertStmt = db.query(` + INSERT INTO transaction_aliases + (idx, alias, transaction_name, org_slug, project_slug, fingerprint, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + for (const entry of aliases) { + insertStmt.run( + entry.idx, + entry.alias.toLowerCase(), + entry.transaction, + entry.orgSlug, + entry.projectSlug, + fingerprint, + now + ); + } + + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +/** + * Look up transaction by numeric index. + * Returns null if not found or fingerprint doesn't match. + */ +export function getTransactionByIndex( + idx: number, + fingerprint: string +): TransactionAliasEntry | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT * FROM transaction_aliases WHERE idx = ? AND fingerprint = ?" + ) + .get(idx, fingerprint) as TransactionAliasRow | undefined; + + if (!row) { + return null; + } + + return { + idx: row.idx, + alias: row.alias, + transaction: row.transaction_name, + orgSlug: row.org_slug, + projectSlug: row.project_slug, + }; +} + +/** + * Look up transaction by alias. + * Returns null if not found or fingerprint doesn't match. + */ +export function getTransactionByAlias( + alias: string, + fingerprint: string +): TransactionAliasEntry | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT * FROM transaction_aliases WHERE alias = ? AND fingerprint = ?" + ) + .get(alias.toLowerCase(), fingerprint) as TransactionAliasRow | undefined; + + if (!row) { + return null; + } + + return { + idx: row.idx, + alias: row.alias, + transaction: row.transaction_name, + orgSlug: row.org_slug, + projectSlug: row.project_slug, + }; +} + +/** + * Get all cached aliases for a fingerprint. + */ +export function getTransactionAliases( + fingerprint: string +): TransactionAliasEntry[] { + const db = getDatabase(); + + const rows = db + .query( + "SELECT * FROM transaction_aliases WHERE fingerprint = ? ORDER BY idx" + ) + .all(fingerprint) as TransactionAliasRow[]; + + return rows.map((row) => ({ + idx: row.idx, + alias: row.alias, + transaction: row.transaction_name, + orgSlug: row.org_slug, + projectSlug: row.project_slug, + })); +} + +/** + * Check if an alias exists for a different fingerprint (stale check). + * Returns the stale fingerprint if found, null otherwise. + */ +export function getStaleFingerprint(alias: string): string | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT fingerprint FROM transaction_aliases WHERE alias = ? LIMIT 1" + ) + .get(alias.toLowerCase()) as { fingerprint: string } | undefined; + + return row?.fingerprint ?? null; +} + +/** + * Check if an index exists for a different fingerprint (stale check). + * Returns the stale fingerprint if found, null otherwise. + */ +export function getStaleIndexFingerprint(idx: number): string | null { + const db = getDatabase(); + + const row = db + .query("SELECT fingerprint FROM transaction_aliases WHERE idx = ? LIMIT 1") + .get(idx) as { fingerprint: string } | undefined; + + return row?.fingerprint ?? null; +} + +/** + * Clear all transaction aliases. + */ +export function clearTransactionAliases(): void { + const db = getDatabase(); + db.query("DELETE FROM transaction_aliases").run(); +} diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index 475ee323..3a25022a 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -5,7 +5,11 @@ * Formats flamegraph analysis, hot paths, and transaction lists. */ -import type { ProfileAnalysis, ProfileFunctionRow } from "../../types/index.js"; +import type { + ProfileAnalysis, + ProfileFunctionRow, + TransactionAliasEntry, +} from "../../types/index.js"; import { formatDuration } from "../profile/analyzer.js"; import { bold, muted, yellow } from "./colors.js"; @@ -177,8 +181,15 @@ export function formatProfileListHeader( /** * Format the column headers for the transaction list table. + * + * @param hasAliases - Whether to include # and ALIAS columns */ -export function formatProfileListTableHeader(): string { +export function formatProfileListTableHeader(hasAliases = false): string { + if (hasAliases) { + return muted( + " # ALIAS TRANSACTION PROFILES p75" + ); + } return muted( " TRANSACTION PROFILES p75" ); @@ -188,22 +199,38 @@ export function formatProfileListTableHeader(): string { * Format a single transaction row for the list. * * @param row - Profile function row data + * @param alias - Optional alias entry for this transaction * @returns Formatted row string */ -export function formatProfileListRow(row: ProfileFunctionRow): string { - const transaction = (row.transaction ?? "unknown").slice(0, 48).padEnd(48); +export function formatProfileListRow( + row: ProfileFunctionRow, + alias?: TransactionAliasEntry +): string { const count = `${row["count()"] ?? 0}`.padStart(10); const p75Ms = row["p75(function.duration)"] ? formatDuration(row["p75(function.duration)"] / 1_000_000) // ns to ms : "-"; const p75 = p75Ms.padStart(10); + if (alias) { + const idx = `${alias.idx}`.padStart(3); + const aliasStr = alias.alias.padEnd(6); + const transaction = (row.transaction ?? "unknown").slice(0, 42).padEnd(42); + return ` ${idx} ${aliasStr} ${transaction} ${count} ${p75}`; + } + + const transaction = (row.transaction ?? "unknown").slice(0, 48).padEnd(48); return ` ${transaction} ${count} ${p75}`; } /** * Format the footer tip for profile list command. + * + * @param hasAliases - Whether aliases are available for quick access */ -export function formatProfileListFooter(): string { +export function formatProfileListFooter(hasAliases = false): string { + if (hasAliases) { + return "\nTip: Use 'sentry profile view 1' or 'sentry profile view ' to analyze."; + } return "\nTip: Use 'sentry profile view \"\"' to analyze."; } diff --git a/src/lib/resolve-transaction.ts b/src/lib/resolve-transaction.ts new file mode 100644 index 00000000..bd49ff1a --- /dev/null +++ b/src/lib/resolve-transaction.ts @@ -0,0 +1,187 @@ +/** + * Transaction resolver for profile commands. + * + * Resolves transaction references (numbers, aliases, or full names) to full transaction names. + * Works with the cached transaction aliases from `profile list`. + */ + +import { + buildTransactionFingerprint, + getStaleFingerprint, + getStaleIndexFingerprint, + getTransactionByAlias, + getTransactionByIndex, +} from "./db/transaction-aliases.js"; +import { ConfigError } from "./errors.js"; + +/** Resolved transaction with full name and context */ +export type ResolvedTransaction = { + /** Full transaction name */ + transaction: string; + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; +}; + +/** Options for transaction resolution */ +export type ResolveTransactionOptions = { + /** Organization slug (required for fingerprint) */ + org: string; + /** Project slug (null for multi-project lists) */ + project: string | null; + /** Time period (required for fingerprint validation) */ + period: string; +}; + +/** Pattern to detect numeric-only input */ +const NUMERIC_PATTERN = /^\d+$/; + +/** + * Check if input is a full transaction name (contains / or .). + * Full names are passed through without alias lookup. + */ +function isFullTransactionName(input: string): boolean { + return input.includes("/") || input.includes("."); +} + +/** + * Parse the stale fingerprint to extract period for error messages. + * Fingerprint format: "orgSlug:projectSlug:period" + */ +function parseFingerprint(fingerprint: string): { + org: string; + project: string | null; + period: string; +} { + const parts = fingerprint.split(":"); + return { + org: parts[0] ?? "", + project: parts[1] === "*" ? null : (parts[1] ?? null), + period: parts[2] ?? "", + }; +} + +/** + * Build a helpful error message for stale alias references. + */ +function buildStaleAliasError( + ref: string, + staleFingerprint: string, + currentFingerprint: string +): ConfigError { + const stale = parseFingerprint(staleFingerprint); + const current = parseFingerprint(currentFingerprint); + + let reason = ""; + if (stale.period !== current.period) { + reason = `different time period (cached: ${stale.period}, requested: ${current.period})`; + } else if (stale.project !== current.project) { + reason = `different project (cached: ${stale.project ?? "all"}, requested: ${current.project ?? "all"})`; + } else if (stale.org !== current.org) { + reason = `different organization (cached: ${stale.org}, requested: ${current.org})`; + } else { + reason = "different context"; + } + + const isNumeric = NUMERIC_PATTERN.test(ref); + const refType = isNumeric ? "index" : "alias"; + const listCmd = current.project + ? `sentry profile list ${current.org}/${current.project} --period ${current.period}` + : `sentry profile list --org ${current.org} --period ${current.period}`; + + return new ConfigError( + `Transaction ${refType} '${ref}' is from a ${reason}.`, + `Run '${listCmd}' to refresh aliases.` + ); +} + +/** + * Build error for unknown alias/index. + */ +function buildUnknownRefError( + ref: string, + options: ResolveTransactionOptions +): ConfigError { + const isNumeric = NUMERIC_PATTERN.test(ref); + const refType = isNumeric ? "index" : "alias"; + const listCmd = options.project + ? `sentry profile list ${options.org}/${options.project} --period ${options.period}` + : `sentry profile list --org ${options.org} --period ${options.period}`; + + return new ConfigError( + `Unknown transaction ${refType} '${ref}'.`, + `Run '${listCmd}' to see available transactions.` + ); +} + +/** + * Resolve a transaction reference to its full name. + * + * Accepts: + * - Numeric index: "1", "2", "10" → looks up by cached index + * - Alias: "i", "e", "iu" → looks up by cached alias + * - Full transaction name: "/api/0/..." or "tasks.process" → passed through + * + * @throws ConfigError if alias/index not found or stale + */ +export function resolveTransaction( + input: string, + options: ResolveTransactionOptions +): ResolvedTransaction { + // Full transaction names pass through directly + if (isFullTransactionName(input)) { + return { + transaction: input, + orgSlug: options.org, + projectSlug: options.project ?? "", + }; + } + + const currentFingerprint = buildTransactionFingerprint( + options.org, + options.project, + options.period + ); + + // Numeric input → look up by index + if (NUMERIC_PATTERN.test(input)) { + const idx = Number.parseInt(input, 10); + const entry = getTransactionByIndex(idx, currentFingerprint); + + if (entry) { + return { + transaction: entry.transaction, + orgSlug: entry.orgSlug, + projectSlug: entry.projectSlug, + }; + } + + // Check if there's a stale entry for this index + const staleFingerprint = getStaleIndexFingerprint(idx); + if (staleFingerprint) { + throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + } + + throw buildUnknownRefError(input, options); + } + + // Non-numeric input → look up by alias + const entry = getTransactionByAlias(input, currentFingerprint); + + if (entry) { + return { + transaction: entry.transaction, + orgSlug: entry.orgSlug, + projectSlug: entry.projectSlug, + }; + } + + // Check if there's a stale entry for this alias + const staleFingerprint = getStaleFingerprint(input); + if (staleFingerprint) { + throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + } + + throw buildUnknownRefError(input, options); +} diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts new file mode 100644 index 00000000..82375db9 --- /dev/null +++ b/src/lib/transaction-alias.ts @@ -0,0 +1,123 @@ +/** + * Transaction alias generation utilities. + * + * Generates short, unique aliases from transaction names for use in profile commands. + * Similar to project aliases but for transaction names like "/api/0/organizations/{org}/issues/". + */ + +import type { TransactionAliasEntry } from "../types/index.js"; +import { findShortestUniquePrefixes } from "./alias.js"; + +/** Characters that separate segments in transaction names */ +const SEGMENT_SEPARATORS = /[/.]/; + +/** Pattern for URL parameter placeholders like {org}, {project_id}, etc. */ +const PLACEHOLDER_PATTERN = /^\{[^}]+\}$/; + +/** Numeric-only segments to filter out (like "0" in "/api/0/...") */ +const NUMERIC_PATTERN = /^\d+$/; + +/** + * Extract the last meaningful segment from a transaction name. + * Filters out parameter placeholders like {org}, {project_id}, and numeric segments. + * + * @example + * extractTransactionSegment("/api/0/organizations/{org}/issues/") + * // => "issues" + * + * @example + * extractTransactionSegment("/extensions/jira/issue-updated/") + * // => "issueupdated" + * + * @example + * extractTransactionSegment("tasks.sentry.process_event") + * // => "processevent" + */ +export function extractTransactionSegment(transaction: string): string { + // Split on / and . to handle both URL paths and dotted task names + const segments = transaction + .split(SEGMENT_SEPARATORS) + .filter((s) => s.length > 0); + + // Find the last meaningful segment (not a placeholder, not numeric) + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; + if (!segment) { + continue; + } + + // Skip placeholders like {org}, {project_id} + if (PLACEHOLDER_PATTERN.test(segment)) { + continue; + } + + // Skip pure numeric segments like "0" in "/api/0/..." + if (NUMERIC_PATTERN.test(segment)) { + continue; + } + + // Normalize: remove hyphens/underscores, lowercase + return segment.replace(/[-_]/g, "").toLowerCase(); + } + + // Fallback: use first non-empty segment if no meaningful one found + const firstSegment = segments.find((s) => s.length > 0); + return firstSegment?.replace(/[-_]/g, "").toLowerCase() ?? "txn"; +} + +/** Input for alias generation */ +type TransactionInput = { + /** Full transaction name */ + transaction: string; + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; +}; + +/** + * Build aliases for a list of transactions. + * Uses shortest unique prefix algorithm on extracted segments. + * + * @param transactions - Array of transaction inputs with org/project context + * @returns Array of TransactionAliasEntry with idx, alias, and transaction + * + * @example + * buildTransactionAliases([ + * { transaction: "/api/0/organizations/{org}/issues/", orgSlug: "sentry", projectSlug: "sentry" }, + * { transaction: "/api/0/projects/{org}/{proj}/events/", orgSlug: "sentry", projectSlug: "sentry" }, + * ]) + * // => [ + * // { idx: 1, alias: "i", transaction: "/api/0/organizations/{org}/issues/", ... }, + * // { idx: 2, alias: "e", transaction: "/api/0/projects/{org}/{proj}/events/", ... }, + * // ] + */ +export function buildTransactionAliases( + transactions: TransactionInput[] +): TransactionAliasEntry[] { + if (transactions.length === 0) { + return []; + } + + // Extract segments from each transaction + const segments = transactions.map((t) => + extractTransactionSegment(t.transaction) + ); + + // Find shortest unique prefixes for the segments + const prefixMap = findShortestUniquePrefixes(segments); + + // Build result with 1-based indices + return transactions.map((t, index) => { + const segment = segments[index] ?? "txn"; + const alias = prefixMap.get(segment) ?? segment.charAt(0); + + return { + idx: index + 1, + alias, + transaction: t.transaction, + orgSlug: t.orgSlug, + projectSlug: t.projectSlug, + }; + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index 2ef7f9e6..847fd8e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -42,6 +42,7 @@ export type { ProfileAnalysis, ProfileFunctionRow, ProfileFunctionsResponse, + TransactionAliasEntry, } from "./profile.js"; export { FlamegraphFrameInfoSchema, diff --git a/src/types/profile.ts b/src/types/profile.ts index dd41c8b3..8a7424d8 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -196,6 +196,23 @@ export type HotPath = { percentage: number; }; +/** + * A cached transaction alias entry for quick reference in profile commands. + * Stored in SQLite and used to resolve short aliases like "i" or "1" to full transaction names. + */ +export type TransactionAliasEntry = { + /** 1-based numeric index from the list command */ + idx: number; + /** Short alias derived from last meaningful segment (e.g., "i" for issues) */ + alias: string; + /** Full transaction name (e.g., "/api/0/organizations/{org}/issues/") */ + transaction: string; + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; +}; + /** * Analyzed profile data ready for display. */ From ce939e1e6a531f783e11ec00352536b1c2dae03b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 15:17:53 +0000 Subject: [PATCH 04/32] chore: regenerate SKILL.md for profile commands --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f527fdda..b09fb373 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -397,6 +397,32 @@ Update the Sentry CLI to the latest version - `--check - Check for updates without installing` - `--method - Installation method to use (curl, npm, pnpm, bun, yarn)` +### Profile + +Analyze CPU profiling data + +#### `sentry profile list ` + +List transactions with profiling data + +**Flags:** +- `--period - Time period: 1h, 24h, 7d, 14d, 30d - (default: "24h")` +- `-n, --limit - Maximum number of transactions to return - (default: "20")` +- `--json - Output as JSON` + +#### `sentry profile view ` + +View CPU profiling analysis for a transaction + +**Flags:** +- `--org - Organization slug` +- `--project - Project slug` +- `--period - Stats period: 1h, 24h, 7d, 14d, 30d - (default: "7d")` +- `-n, --limit - Number of hot paths to show (max 20) - (default: "10")` +- `--allFrames - Include library/system frames (default: user code only)` +- `--json - Output as JSON` +- `-w, --web - Open in browser` + ## Output Formats ### JSON Output From 86c2ab14ada773bb97ab7970c17cfcb657a23b33 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Thu, 5 Feb 2026 15:43:18 +0000 Subject: [PATCH 05/32] test: add unit and property tests for transaction aliases Add comprehensive test coverage for the transaction alias system: - test/lib/transaction-alias.property.test.ts: Property-based tests using fast-check to verify extractTransactionSegment and buildTransactionAliases invariants - test/lib/db/transaction-aliases.test.ts: Unit tests for SQLite storage layer including fingerprint building, CRUD operations, and stale detection - test/lib/resolve-transaction.test.ts: Unit tests for transaction resolution including index/alias lookup, full name pass-through, and stale alias error handling --- test/lib/db/transaction-aliases.test.ts | 338 +++++++++++++++++++ test/lib/resolve-transaction.test.ts | 355 ++++++++++++++++++++ test/lib/transaction-alias.property.test.ts | 334 ++++++++++++++++++ 3 files changed, 1027 insertions(+) create mode 100644 test/lib/db/transaction-aliases.test.ts create mode 100644 test/lib/resolve-transaction.test.ts create mode 100644 test/lib/transaction-alias.property.test.ts diff --git a/test/lib/db/transaction-aliases.test.ts b/test/lib/db/transaction-aliases.test.ts new file mode 100644 index 00000000..58e1b2c2 --- /dev/null +++ b/test/lib/db/transaction-aliases.test.ts @@ -0,0 +1,338 @@ +/** + * Transaction Aliases Database Layer Tests + * + * Tests for SQLite storage of transaction aliases from profile list commands. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + buildTransactionFingerprint, + clearTransactionAliases, + getStaleFingerprint, + getStaleIndexFingerprint, + getTransactionAliases, + getTransactionByAlias, + getTransactionByIndex, + setTransactionAliases, +} from "../../../src/lib/db/transaction-aliases.js"; +import type { TransactionAliasEntry } from "../../../src/types/index.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; + +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-transaction-aliases-"); + process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; +}); + +afterEach(async () => { + delete process.env.SENTRY_CLI_CONFIG_DIR; + await cleanupTestDir(testConfigDir); +}); + +// ============================================================================= +// buildTransactionFingerprint +// ============================================================================= + +describe("buildTransactionFingerprint", () => { + test("builds fingerprint with org, project, and period", () => { + const fp = buildTransactionFingerprint("my-org", "my-project", "7d"); + expect(fp).toBe("my-org:my-project:7d"); + }); + + test("uses * for null project (multi-project)", () => { + const fp = buildTransactionFingerprint("my-org", null, "24h"); + expect(fp).toBe("my-org:*:24h"); + }); + + test("handles various period formats", () => { + expect(buildTransactionFingerprint("o", "p", "1h")).toBe("o:p:1h"); + expect(buildTransactionFingerprint("o", "p", "24h")).toBe("o:p:24h"); + expect(buildTransactionFingerprint("o", "p", "7d")).toBe("o:p:7d"); + expect(buildTransactionFingerprint("o", "p", "30d")).toBe("o:p:30d"); + }); +}); + +// ============================================================================= +// setTransactionAliases / getTransactionAliases +// ============================================================================= + +describe("setTransactionAliases", () => { + const fingerprint = "test-org:test-project:7d"; + + const createEntry = (idx: number, alias: string): TransactionAliasEntry => ({ + idx, + alias, + transaction: `/api/0/${alias}/`, + orgSlug: "test-org", + projectSlug: "test-project", + }); + + test("stores and retrieves aliases", () => { + const aliases: TransactionAliasEntry[] = [ + createEntry(1, "issues"), + createEntry(2, "events"), + createEntry(3, "releases"), + ]; + + setTransactionAliases(aliases, fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result).toHaveLength(3); + expect(result[0]?.alias).toBe("issues"); + expect(result[1]?.alias).toBe("events"); + expect(result[2]?.alias).toBe("releases"); + }); + + test("replaces existing aliases with same fingerprint", () => { + setTransactionAliases([createEntry(1, "old")], fingerprint); + setTransactionAliases([createEntry(1, "new")], fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result).toHaveLength(1); + expect(result[0]?.alias).toBe("new"); + }); + + test("keeps aliases with different fingerprints separate", () => { + const fp1 = "org1:proj1:7d"; + const fp2 = "org2:proj2:7d"; + + setTransactionAliases([createEntry(1, "first")], fp1); + setTransactionAliases([createEntry(1, "second")], fp2); + + const result1 = getTransactionAliases(fp1); + const result2 = getTransactionAliases(fp2); + + expect(result1).toHaveLength(1); + expect(result1[0]?.alias).toBe("first"); + expect(result2).toHaveLength(1); + expect(result2[0]?.alias).toBe("second"); + }); + + test("stores empty array", () => { + setTransactionAliases([], fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result).toHaveLength(0); + }); + + test("normalizes aliases to lowercase", () => { + const entry: TransactionAliasEntry = { + idx: 1, + alias: "UPPERCASE", + transaction: "/api/test/", + orgSlug: "org", + projectSlug: "proj", + }; + + setTransactionAliases([entry], fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result[0]?.alias).toBe("uppercase"); + }); +}); + +// ============================================================================= +// getTransactionByIndex +// ============================================================================= + +describe("getTransactionByIndex", () => { + const fingerprint = "test-org:test-project:7d"; + + beforeEach(() => { + const aliases: TransactionAliasEntry[] = [ + { + idx: 1, + alias: "i", + transaction: "/api/0/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 2, + alias: "e", + transaction: "/api/0/events/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ]; + setTransactionAliases(aliases, fingerprint); + }); + + test("returns entry for valid index", () => { + const result = getTransactionByIndex(1, fingerprint); + expect(result).toBeDefined(); + expect(result?.transaction).toBe("/api/0/issues/"); + expect(result?.alias).toBe("i"); + }); + + test("returns null for non-existent index", () => { + const result = getTransactionByIndex(99, fingerprint); + expect(result).toBeNull(); + }); + + test("returns null for wrong fingerprint", () => { + const result = getTransactionByIndex(1, "different:fingerprint:7d"); + expect(result).toBeNull(); + }); + + test("returns null for index 0", () => { + const result = getTransactionByIndex(0, fingerprint); + expect(result).toBeNull(); + }); +}); + +// ============================================================================= +// getTransactionByAlias +// ============================================================================= + +describe("getTransactionByAlias", () => { + const fingerprint = "test-org:test-project:7d"; + + beforeEach(() => { + const aliases: TransactionAliasEntry[] = [ + { + idx: 1, + alias: "issues", + transaction: "/api/0/organizations/{org}/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 2, + alias: "events", + transaction: "/api/0/projects/{org}/{proj}/events/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ]; + setTransactionAliases(aliases, fingerprint); + }); + + test("returns entry for valid alias", () => { + const result = getTransactionByAlias("issues", fingerprint); + expect(result).toBeDefined(); + expect(result?.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(result?.idx).toBe(1); + }); + + test("returns null for non-existent alias", () => { + const result = getTransactionByAlias("unknown", fingerprint); + expect(result).toBeNull(); + }); + + test("returns null for wrong fingerprint", () => { + const result = getTransactionByAlias("issues", "different:fingerprint:7d"); + expect(result).toBeNull(); + }); + + test("alias lookup is case-insensitive", () => { + const lower = getTransactionByAlias("issues", fingerprint); + const upper = getTransactionByAlias("ISSUES", fingerprint); + const mixed = getTransactionByAlias("Issues", fingerprint); + + expect(lower?.transaction).toBe(upper?.transaction); + expect(lower?.transaction).toBe(mixed?.transaction); + }); +}); + +// ============================================================================= +// getStaleFingerprint / getStaleIndexFingerprint +// ============================================================================= + +describe("stale detection", () => { + test("getStaleFingerprint returns fingerprint when alias exists elsewhere", () => { + const oldFp = "old-org:old-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "old-org", + projectSlug: "old-project", + }, + ], + oldFp + ); + + const stale = getStaleFingerprint("issues"); + expect(stale).toBe(oldFp); + }); + + test("getStaleFingerprint returns null when alias doesn't exist", () => { + const stale = getStaleFingerprint("nonexistent"); + expect(stale).toBeNull(); + }); + + test("getStaleIndexFingerprint returns fingerprint when index exists elsewhere", () => { + const oldFp = "old-org:old-project:7d"; + setTransactionAliases( + [ + { + idx: 5, + alias: "test", + transaction: "/api/test/", + orgSlug: "old-org", + projectSlug: "old-project", + }, + ], + oldFp + ); + + const stale = getStaleIndexFingerprint(5); + expect(stale).toBe(oldFp); + }); + + test("getStaleIndexFingerprint returns null when index doesn't exist", () => { + const stale = getStaleIndexFingerprint(999); + expect(stale).toBeNull(); + }); +}); + +// ============================================================================= +// clearTransactionAliases +// ============================================================================= + +describe("clearTransactionAliases", () => { + test("removes all transaction aliases", () => { + const fp1 = "org1:proj1:7d"; + const fp2 = "org2:proj2:7d"; + + setTransactionAliases( + [ + { + idx: 1, + alias: "a", + transaction: "/a/", + orgSlug: "org1", + projectSlug: "proj1", + }, + ], + fp1 + ); + setTransactionAliases( + [ + { + idx: 1, + alias: "b", + transaction: "/b/", + orgSlug: "org2", + projectSlug: "proj2", + }, + ], + fp2 + ); + + clearTransactionAliases(); + + expect(getTransactionAliases(fp1)).toHaveLength(0); + expect(getTransactionAliases(fp2)).toHaveLength(0); + }); + + test("safe to call when no aliases exist", () => { + // Should not throw + clearTransactionAliases(); + expect(getTransactionAliases("any:fingerprint:7d")).toHaveLength(0); + }); +}); diff --git a/test/lib/resolve-transaction.test.ts b/test/lib/resolve-transaction.test.ts new file mode 100644 index 00000000..95d84a12 --- /dev/null +++ b/test/lib/resolve-transaction.test.ts @@ -0,0 +1,355 @@ +/** + * Transaction Resolver Tests + * + * Tests for resolving transaction references (numbers, aliases, full names) + * to full transaction names for profile commands. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + clearTransactionAliases, + setTransactionAliases, +} from "../../src/lib/db/transaction-aliases.js"; +import { ConfigError } from "../../src/lib/errors.js"; +import { resolveTransaction } from "../../src/lib/resolve-transaction.js"; +import type { TransactionAliasEntry } from "../../src/types/index.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; + +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-resolve-transaction-"); + process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; +}); + +afterEach(async () => { + delete process.env.SENTRY_CLI_CONFIG_DIR; + await cleanupTestDir(testConfigDir); +}); + +const defaultOptions = { + org: "test-org", + project: "test-project", + period: "7d", +}; + +const setupAliases = () => { + const fingerprint = "test-org:test-project:7d"; + const aliases: TransactionAliasEntry[] = [ + { + idx: 1, + alias: "i", + transaction: "/api/0/organizations/{org}/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 2, + alias: "e", + transaction: "/api/0/projects/{org}/{proj}/events/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 3, + alias: "iu", + transaction: "/extensions/jira/issue-updated/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ]; + setTransactionAliases(aliases, fingerprint); +}; + +// ============================================================================= +// Full Transaction Name Pass-Through +// ============================================================================= + +describe("full transaction name pass-through", () => { + test("URL paths pass through unchanged", () => { + const result = resolveTransaction( + "/api/0/organizations/{org}/issues/", + defaultOptions + ); + + expect(result.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(result.orgSlug).toBe("test-org"); + expect(result.projectSlug).toBe("test-project"); + }); + + test("dotted task names pass through unchanged", () => { + const result = resolveTransaction( + "tasks.sentry.process_event", + defaultOptions + ); + + expect(result.transaction).toBe("tasks.sentry.process_event"); + expect(result.orgSlug).toBe("test-org"); + expect(result.projectSlug).toBe("test-project"); + }); + + test("uses empty string for project when null", () => { + const result = resolveTransaction("/api/test/", { + ...defaultOptions, + project: null, + }); + + expect(result.projectSlug).toBe(""); + }); +}); + +// ============================================================================= +// Numeric Index Resolution +// ============================================================================= + +describe("numeric index resolution", () => { + beforeEach(() => { + setupAliases(); + }); + + test("resolves valid index to transaction", () => { + const result = resolveTransaction("1", defaultOptions); + + expect(result.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(result.orgSlug).toBe("test-org"); + expect(result.projectSlug).toBe("test-project"); + }); + + test("resolves different indices", () => { + const r1 = resolveTransaction("1", defaultOptions); + const r2 = resolveTransaction("2", defaultOptions); + const r3 = resolveTransaction("3", defaultOptions); + + expect(r1.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(r2.transaction).toBe("/api/0/projects/{org}/{proj}/events/"); + expect(r3.transaction).toBe("/extensions/jira/issue-updated/"); + }); + + test("throws ConfigError for unknown index", () => { + expect(() => resolveTransaction("99", defaultOptions)).toThrow(ConfigError); + }); + + test("error message includes index and suggestion", () => { + try { + resolveTransaction("99", defaultOptions); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("99"); + expect(configError.message).toContain("index"); + expect(configError.suggestion).toContain("sentry profile list"); + } + }); +}); + +// ============================================================================= +// Alias Resolution +// ============================================================================= + +describe("alias resolution", () => { + beforeEach(() => { + setupAliases(); + }); + + test("resolves valid alias to transaction", () => { + const result = resolveTransaction("i", defaultOptions); + + expect(result.transaction).toBe("/api/0/organizations/{org}/issues/"); + }); + + test("resolves multi-character alias", () => { + const result = resolveTransaction("iu", defaultOptions); + + expect(result.transaction).toBe("/extensions/jira/issue-updated/"); + }); + + test("alias lookup is case-insensitive", () => { + const lower = resolveTransaction("i", defaultOptions); + const upper = resolveTransaction("I", defaultOptions); + + expect(lower.transaction).toBe(upper.transaction); + }); + + test("throws ConfigError for unknown alias", () => { + expect(() => resolveTransaction("xyz", defaultOptions)).toThrow( + ConfigError + ); + }); + + test("error message includes alias and suggestion", () => { + try { + resolveTransaction("xyz", defaultOptions); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("xyz"); + expect(configError.message).toContain("alias"); + expect(configError.suggestion).toContain("sentry profile list"); + } + }); +}); + +// ============================================================================= +// Stale Alias Detection +// ============================================================================= + +describe("stale alias detection", () => { + test("detects stale index from different period", () => { + // Store aliases with 7d period + const oldFingerprint = "test-org:test-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "i", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ], + oldFingerprint + ); + + // Try to resolve with 24h period + try { + resolveTransaction("1", { ...defaultOptions, period: "24h" }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("different time period"); + expect(configError.message).toContain("7d"); + expect(configError.message).toContain("24h"); + } + }); + + test("detects stale alias from different project", () => { + const oldFingerprint = "test-org:old-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "old-project", + }, + ], + oldFingerprint + ); + + try { + resolveTransaction("issues", { + ...defaultOptions, + project: "new-project", + }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("different project"); + } + }); + + test("detects stale alias from different org", () => { + const oldFingerprint = "old-org:test-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "old-org", + projectSlug: "test-project", + }, + ], + oldFingerprint + ); + + try { + resolveTransaction("issues", { ...defaultOptions, org: "new-org" }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("different organization"); + } + }); + + test("stale error includes refresh command suggestion", () => { + const oldFingerprint = "test-org:test-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "i", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ], + oldFingerprint + ); + + try { + resolveTransaction("1", { ...defaultOptions, period: "24h" }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.suggestion).toContain("sentry profile list"); + expect(configError.suggestion).toContain("--period 24h"); + } + }); +}); + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe("edge cases", () => { + test("handles empty alias cache gracefully", () => { + clearTransactionAliases(); + + expect(() => resolveTransaction("1", defaultOptions)).toThrow(ConfigError); + expect(() => resolveTransaction("i", defaultOptions)).toThrow(ConfigError); + }); + + test("multi-project fingerprint (null project)", () => { + const multiProjectFp = "test-org:*:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "i", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "backend", + }, + ], + multiProjectFp + ); + + const result = resolveTransaction("1", { + org: "test-org", + project: null, + period: "7d", + }); + + expect(result.transaction).toBe("/api/issues/"); + expect(result.projectSlug).toBe("backend"); + }); + + test("numeric-looking full paths still pass through", () => { + // Transaction name contains numbers but also has path separators + const result = resolveTransaction("/api/0/test/", defaultOptions); + expect(result.transaction).toBe("/api/0/test/"); + }); + + test("dotted names with numbers pass through", () => { + const result = resolveTransaction("celery.task.v2.run", defaultOptions); + expect(result.transaction).toBe("celery.task.v2.run"); + }); +}); diff --git a/test/lib/transaction-alias.property.test.ts b/test/lib/transaction-alias.property.test.ts new file mode 100644 index 00000000..293943e1 --- /dev/null +++ b/test/lib/transaction-alias.property.test.ts @@ -0,0 +1,334 @@ +/** + * Property-Based Tests for Transaction Alias Generation + * + * Uses fast-check to verify properties that should always hold true + * for transaction alias functions, regardless of input. + */ + +import { describe, expect, test } from "bun:test"; +import { + array, + constantFrom, + assert as fcAssert, + property, + tuple, + uniqueArray, +} from "fast-check"; +import { + buildTransactionAliases, + extractTransactionSegment, +} from "../../src/lib/transaction-alias.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.ts"; + +// Arbitraries for generating test data + +/** Valid slug characters */ +const slugChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + +/** Generate simple slug segments */ +const simpleSegmentArb = array(constantFrom(...slugChars.split("")), { + minLength: 1, + maxLength: 15, +}).map((chars) => chars.join("")); + +/** Generate URL path segments */ +const urlSegmentArb = array(constantFrom(...slugChars.split("")), { + minLength: 2, + maxLength: 20, +}).map((chars) => chars.join("")); + +/** Generate URL placeholder like {org}, {project_id} */ +const placeholderArb = simpleSegmentArb.map((s) => `{${s}}`); + +/** Generate URL-style transaction names */ +const urlTransactionArb = tuple( + array(constantFrom("api", "extensions", "webhooks", "v1", "v2", "internal"), { + minLength: 1, + maxLength: 2, + }), + array(placeholderArb, { minLength: 0, maxLength: 2 }), + urlSegmentArb // The meaningful last segment +).map(([prefixes, placeholders, lastSegment]) => { + const parts = [...prefixes, ...placeholders, lastSegment]; + return `/${parts.join("/")}/`; +}); + +/** Generate dotted task-style transaction names */ +const taskTransactionArb = tuple( + array(simpleSegmentArb, { minLength: 1, maxLength: 3 }), + simpleSegmentArb +).map(([namespaces, lastSegment]) => [...namespaces, lastSegment].join(".")); + +/** Generate any valid transaction name */ +const transactionArb = constantFrom("url", "task").chain((type) => + type === "url" ? urlTransactionArb : taskTransactionArb +); + +/** Generate org slugs */ +const orgSlugArb = simpleSegmentArb; + +/** Generate project slugs */ +const projectSlugArb = simpleSegmentArb; + +/** Generate transaction input for alias building */ +const transactionInputArb = tuple( + transactionArb, + orgSlugArb, + projectSlugArb +).map(([transaction, orgSlug, projectSlug]) => ({ + transaction, + orgSlug, + projectSlug, +})); + +// Properties for extractTransactionSegment + +describe("property: extractTransactionSegment", () => { + test("returns non-empty string for any valid transaction", () => { + fcAssert( + property(transactionArb, (transaction) => { + const segment = extractTransactionSegment(transaction); + expect(segment.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("returns lowercase string", () => { + fcAssert( + property(transactionArb, (transaction) => { + const segment = extractTransactionSegment(transaction); + expect(segment).toBe(segment.toLowerCase()); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("removes hyphens and underscores", () => { + fcAssert( + property(transactionArb, (transaction) => { + const segment = extractTransactionSegment(transaction); + expect(segment.includes("-")).toBe(false); + expect(segment.includes("_")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("does not return placeholder patterns", () => { + fcAssert( + property(transactionArb, (transaction) => { + const segment = extractTransactionSegment(transaction); + // Should not be a placeholder like {org} + expect(segment.startsWith("{")).toBe(false); + expect(segment.endsWith("}")).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("does not return purely numeric segments", () => { + fcAssert( + property(transactionArb, (transaction) => { + const segment = extractTransactionSegment(transaction); + // Should not be purely numeric like "0" from /api/0/ + expect(/^\d+$/.test(segment)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("extracts last meaningful segment from URL paths", () => { + // Specific test cases for URL paths + const testCases = [ + ["/api/0/organizations/{org}/issues/", "issues"], + ["/api/0/projects/{org}/{proj}/events/", "events"], + ["/extensions/jira/issue-updated/", "issueupdated"], + ["/webhooks/github/push/", "push"], + ] as const; + + for (const [input, expected] of testCases) { + expect(extractTransactionSegment(input)).toBe(expected); + } + }); + + test("extracts last segment from dotted task names", () => { + const testCases = [ + ["tasks.sentry.process_event", "processevent"], + ["sentry.tasks.store.save_event", "saveevent"], + ["celery.task.run", "run"], + ] as const; + + for (const [input, expected] of testCases) { + expect(extractTransactionSegment(input)).toBe(expected); + } + }); +}); + +// Properties for buildTransactionAliases + +describe("property: buildTransactionAliases", () => { + test("returns same number of aliases as unique transactions", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + expect(aliases.length).toBe(inputs.length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("indices are 1-based and sequential", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + + for (let i = 0; i < aliases.length; i++) { + expect(aliases[i]?.idx).toBe(i + 1); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("aliases are non-empty and lowercase", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + + for (const entry of aliases) { + expect(entry.alias.length).toBeGreaterThan(0); + expect(entry.alias).toBe(entry.alias.toLowerCase()); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("preserves original transaction names", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + + for (let i = 0; i < inputs.length; i++) { + expect(aliases[i]?.transaction).toBe(inputs[i]?.transaction); + expect(aliases[i]?.orgSlug).toBe(inputs[i]?.orgSlug); + expect(aliases[i]?.projectSlug).toBe(inputs[i]?.projectSlug); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("aliases are unique when segments are unique", () => { + // Generate inputs with guaranteed unique last segments + fcAssert( + property( + tuple( + orgSlugArb, + projectSlugArb, + uniqueArray(urlSegmentArb, { + minLength: 2, + maxLength: 5, + comparator: (a, b) => a.toLowerCase() === b.toLowerCase(), + }) + ), + ([org, project, segments]) => { + const inputs = segments.map((seg) => ({ + transaction: `/api/0/${seg}/`, + orgSlug: org, + projectSlug: project, + })); + + const aliases = buildTransactionAliases(inputs); + const aliasValues = aliases.map((a) => a.alias); + const uniqueAliases = new Set(aliasValues); + + expect(uniqueAliases.size).toBe(aliasValues.length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("empty input returns empty array", () => { + const aliases = buildTransactionAliases([]); + expect(aliases).toEqual([]); + }); + + test("deterministic results for same input", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const result1 = buildTransactionAliases(inputs); + const result2 = buildTransactionAliases(inputs); + + expect(result1.length).toBe(result2.length); + + for (let i = 0; i < result1.length; i++) { + expect(result1[i]?.idx).toBe(result2[i]?.idx); + expect(result1[i]?.alias).toBe(result2[i]?.alias); + expect(result1[i]?.transaction).toBe(result2[i]?.transaction); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Integration properties + +describe("property: alias lookup invariants", () => { + test("alias is a prefix of the extracted segment", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + + for (const entry of aliases) { + const segment = extractTransactionSegment(entry.transaction); + expect(segment.startsWith(entry.alias)).toBe(true); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("can reconstruct transaction from alias entry", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 10 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + + // Create lookup by alias + const aliasMap = new Map(aliases.map((a) => [a.alias, a])); + + // Each alias should map back to a valid entry + for (const entry of aliases) { + const found = aliasMap.get(entry.alias); + expect(found).toBeDefined(); + expect(found?.transaction).toBe(entry.transaction); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); From 456ebf56d16894f686a4b37b704e57aab4441939 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 15:07:45 +0000 Subject: [PATCH 06/32] chore: regenerate SKILL.md after merge --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 809d8c9e..23434977 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -410,32 +410,6 @@ Update the Sentry CLI to the latest version - `--check - Check for updates without installing` - `--method - Installation method to use (curl, npm, pnpm, bun, yarn)` -### Profile - -Analyze CPU profiling data - -#### `sentry profile list ` - -List transactions with profiling data - -**Flags:** -- `--period - Time period: 1h, 24h, 7d, 14d, 30d - (default: "24h")` -- `-n, --limit - Maximum number of transactions to return - (default: "20")` -- `--json - Output as JSON` - -#### `sentry profile view ` - -View CPU profiling analysis for a transaction - -**Flags:** -- `--org - Organization slug` -- `--project - Project slug` -- `--period - Stats period: 1h, 24h, 7d, 14d, 30d - (default: "7d")` -- `-n, --limit - Number of hot paths to show (max 20) - (default: "10")` -- `--allFrames - Include library/system frames (default: user code only)` -- `--json - Output as JSON` -- `-w, --web - Open in browser` - ### Log View Sentry logs @@ -489,6 +463,32 @@ sentry log list my-org/backend -f -q 'level:error' sentry log list --json | jq '.[] | select(.level == "error")' ``` +### Profile + +Analyze CPU profiling data + +#### `sentry profile list ` + +List transactions with profiling data + +**Flags:** +- `--period - Time period: 1h, 24h, 7d, 14d, 30d - (default: "24h")` +- `-n, --limit - Maximum number of transactions to return - (default: "20")` +- `--json - Output as JSON` + +#### `sentry profile view ` + +View CPU profiling analysis for a transaction + +**Flags:** +- `--org - Organization slug` +- `--project - Project slug` +- `--period - Stats period: 1h, 24h, 7d, 14d, 30d - (default: "7d")` +- `-n, --limit - Number of hot paths to show (max 20) - (default: "10")` +- `--allFrames - Include library/system frames (default: user code only)` +- `--json - Output as JSON` +- `-w, --web - Open in browser` + ### Issues List issues in a project From 7068f493833023e77f43b3f9347ab6c4ef0db4f8 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 15:12:16 +0000 Subject: [PATCH 07/32] fix(profile): ensure extractTransactionSegment never returns numeric segments The fallback path was returning numeric segments when all other segments were placeholders or numeric. Now the fallback also filters out numeric and placeholder patterns, defaulting to 'txn' if nothing meaningful found. Fixes flaky property-based test 'does not return purely numeric segments'. --- src/lib/transaction-alias.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts index 82375db9..1c8e0043 100644 --- a/src/lib/transaction-alias.ts +++ b/src/lib/transaction-alias.ts @@ -60,8 +60,11 @@ export function extractTransactionSegment(transaction: string): string { return segment.replace(/[-_]/g, "").toLowerCase(); } - // Fallback: use first non-empty segment if no meaningful one found - const firstSegment = segments.find((s) => s.length > 0); + // Fallback: use first non-empty, non-numeric segment if no meaningful one found + const firstSegment = segments.find( + (s) => + s.length > 0 && !NUMERIC_PATTERN.test(s) && !PLACEHOLDER_PATTERN.test(s) + ); return firstSegment?.replace(/[-_]/g, "").toLowerCase() ?? "txn"; } From b14520f3542753fe5e084bee1deef6d2d370b942 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 17:01:01 +0000 Subject: [PATCH 08/32] fix(profile): address PR review comments - Fix mismatched default periods: both list and view now use 24h default - Handle duplicate segments with numeric suffixes (issues, issues2, etc.) - Add --web/-w flag to profile list command - Fix schema repair for transaction_aliases to use custom DDL with composite primary key --- src/commands/profile/list.ts | 20 +++++++++++++++- src/commands/profile/view.ts | 2 +- src/lib/db/schema.ts | 28 ++++++++++++++++++++++ src/lib/transaction-alias.ts | 45 ++++++++++++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index fc481e74..64dd0dab 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -9,6 +9,7 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getProject, listProfiledTransactions } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { openInBrowser } from "../../lib/browser.js"; import { buildTransactionFingerprint, setTransactionAliases, @@ -23,6 +24,7 @@ import { writeJson, } from "../../lib/formatters/index.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { buildProfilingSummaryUrl } from "../../lib/sentry-urls.js"; import { buildTransactionAliases } from "../../lib/transaction-alias.js"; import type { TransactionAliasEntry, Writer } from "../../types/index.js"; @@ -30,6 +32,7 @@ type ListFlags = { readonly period: string; readonly limit: number; readonly json: boolean; + readonly web: boolean; }; /** Valid period values */ @@ -101,8 +104,13 @@ export const listCommand = buildCommand({ brief: "Output as JSON", default: false, }, + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, }, - aliases: { n: "limit" }, + aliases: { n: "limit", w: "web" }, }, async func( this: SentryContext, @@ -147,6 +155,16 @@ export const listCommand = buildCommand({ // Set telemetry context setContext([resolvedTarget.org], [resolvedTarget.project]); + // Open in browser if requested + if (flags.web) { + await openInBrowser( + stdout, + buildProfilingSummaryUrl(resolvedTarget.org, resolvedTarget.project), + "profiling" + ); + return; + } + // Get project to retrieve numeric ID (required for profile API) const project = await getProject( resolvedTarget.org, diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 6d9676db..398af09f 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -92,7 +92,7 @@ export const viewCommand = buildCommand({ kind: "parsed", parse: parsePeriod, brief: "Stats period: 1h, 24h, 7d, 14d, 30d", - default: "7d", + default: "24h", }, limit: { kind: "parsed", diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 2e5c39b6..34dce0a4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -372,8 +372,33 @@ export type RepairResult = { failed: string[]; }; +/** Tables that require custom DDL (not auto-generated from TABLE_SCHEMAS) */ +const CUSTOM_DDL_TABLES = new Set(["transaction_aliases"]); + +function repairTransactionAliasesTable( + db: Database, + result: RepairResult +): void { + if (tableExists(db, "transaction_aliases")) { + return; + } + try { + db.exec(TRANSACTION_ALIASES_DDL); + db.exec(TRANSACTION_ALIASES_INDEX); + result.fixed.push("Created table transaction_aliases"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result.failed.push(`Failed to create table transaction_aliases: ${msg}`); + } +} + function repairMissingTables(db: Database, result: RepairResult): void { for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) { + // Skip tables that need custom DDL + if (CUSTOM_DDL_TABLES.has(tableName)) { + continue; + } + if (tableExists(db, tableName)) { continue; } @@ -385,6 +410,9 @@ function repairMissingTables(db: Database, result: RepairResult): void { result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } + + // Handle tables with custom DDL + repairTransactionAliasesTable(db, result); } function repairMissingColumns(db: Database, result: RepairResult): void { diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts index 1c8e0043..c8813750 100644 --- a/src/lib/transaction-alias.ts +++ b/src/lib/transaction-alias.ts @@ -78,9 +78,36 @@ type TransactionInput = { projectSlug: string; }; +/** + * Disambiguate duplicate segments by appending numeric suffixes. + * e.g., ["issues", "events", "issues"] → ["issues", "events", "issues2"] + * + * @param segments - Array of extracted segments (may contain duplicates) + * @returns Array of unique segments with numeric suffixes for duplicates + */ +function disambiguateSegments(segments: string[]): string[] { + const seen = new Map(); + const result: string[] = []; + + for (const segment of segments) { + const count = seen.get(segment) ?? 0; + seen.set(segment, count + 1); + + if (count === 0) { + result.push(segment); + } else { + // Append numeric suffix for duplicates (issues2, issues3, etc.) + result.push(`${segment}${count + 1}`); + } + } + + return result; +} + /** * Build aliases for a list of transactions. * Uses shortest unique prefix algorithm on extracted segments. + * Handles duplicate segments by appending numeric suffixes. * * @param transactions - Array of transaction inputs with org/project context * @returns Array of TransactionAliasEntry with idx, alias, and transaction @@ -94,6 +121,17 @@ type TransactionInput = { * // { idx: 1, alias: "i", transaction: "/api/0/organizations/{org}/issues/", ... }, * // { idx: 2, alias: "e", transaction: "/api/0/projects/{org}/{proj}/events/", ... }, * // ] + * + * @example + * // Duplicate segments get numeric suffixes + * buildTransactionAliases([ + * { transaction: "/api/v1/issues/", ... }, + * { transaction: "/api/v2/issues/", ... }, + * ]) + * // => [ + * // { idx: 1, alias: "i", ... }, // from "issues" + * // { idx: 2, alias: "is", ... }, // from "issues2" (disambiguated) + * // ] */ export function buildTransactionAliases( transactions: TransactionInput[] @@ -103,11 +141,14 @@ export function buildTransactionAliases( } // Extract segments from each transaction - const segments = transactions.map((t) => + const rawSegments = transactions.map((t) => extractTransactionSegment(t.transaction) ); - // Find shortest unique prefixes for the segments + // Disambiguate duplicate segments with numeric suffixes + const segments = disambiguateSegments(rawSegments); + + // Find shortest unique prefixes for the disambiguated segments const prefixMap = findShortestUniquePrefixes(segments); // Build result with 1-based indices From f98c5de89f83333769c9c8113ef54d1cb0d1ae27 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 17:03:06 +0000 Subject: [PATCH 09/32] chore: regenerate SKILL.md after adding --web flag to profile list --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 23434977..c97ccb43 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -475,6 +475,7 @@ List transactions with profiling data - `--period - Time period: 1h, 24h, 7d, 14d, 30d - (default: "24h")` - `-n, --limit - Maximum number of transactions to return - (default: "20")` - `--json - Output as JSON` +- `-w, --web - Open in browser` #### `sentry profile view ` @@ -483,7 +484,7 @@ View CPU profiling analysis for a transaction **Flags:** - `--org - Organization slug` - `--project - Project slug` -- `--period - Stats period: 1h, 24h, 7d, 14d, 30d - (default: "7d")` +- `--period - Stats period: 1h, 24h, 7d, 14d, 30d - (default: "24h")` - `-n, --limit - Number of hot paths to show (max 20) - (default: "10")` - `--allFrames - Include library/system frames (default: user code only)` - `--json - Output as JSON` From cb968977e3a74070f7afd6c22eeaa12d4be5acf4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 12:24:09 +0000 Subject: [PATCH 10/32] refactor(profile): replace --org/--project flags with positional args Cherry-pick from refactor/positional-args-flags branch (PR #204). Migrate profile view command from --org/--project flags to use the / positional argument syntax for consistency with other commands. Changes: - profile view now uses: sentry profile view [/] - Export parsePositionalArgs for unit testing - Add unit tests for parsePositionalArgs Co-authored-by: Burak Yigit Kaya --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 4 +- src/commands/profile/view.ts | 187 ++++++++++++++---- test/commands/profile/view.test.ts | 103 ++++++++++ 3 files changed, 251 insertions(+), 43 deletions(-) create mode 100644 test/commands/profile/view.test.ts diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c97ccb43..49f5028d 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -477,13 +477,11 @@ List transactions with profiling data - `--json - Output as JSON` - `-w, --web - Open in browser` -#### `sentry profile view ` +#### `sentry profile view ` View CPU profiling analysis for a transaction **Flags:** -- `--org - Organization slug` -- `--project - Project slug` - `--period - Stats period: 1h, 24h, 7d, 14d, 30d - (default: "24h")` - `-n, --limit - Number of hot paths to show (max 20) - (default: "10")` - `--allFrames - Include library/system frames (default: user code only)` diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 398af09f..60384ff7 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -7,7 +7,15 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getFlamegraph, getProject } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + getFlamegraph, + getProject, +} from "../../lib/api-client.js"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError } from "../../lib/errors.js"; import { @@ -24,8 +32,6 @@ import { resolveTransaction } from "../../lib/resolve-transaction.js"; import { buildProfileUrl } from "../../lib/sentry-urls.js"; type ViewFlags = { - readonly org?: string; - readonly project?: string; readonly period: string; readonly limit: number; readonly allFrames: boolean; @@ -36,6 +42,9 @@ type ViewFlags = { /** Valid period values */ const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"]; +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry profile view / "; + /** * Parse and validate the stats period. */ @@ -48,6 +57,91 @@ function parsePeriod(value: string): string { return value; } +/** + * Parse positional arguments for profile view. + * Handles: `` or ` ` + * + * @returns Parsed transaction and optional target arg + */ +export function parsePositionalArgs(args: string[]): { + transactionRef: string; + targetArg: string | undefined; +} { + if (args.length === 0) { + throw new ContextError("Transaction name or alias", USAGE_HINT); + } + + const first = args[0]; + if (first === undefined) { + throw new ContextError("Transaction name or alias", USAGE_HINT); + } + + if (args.length === 1) { + // Single arg - must be transaction reference + return { transactionRef: first, targetArg: undefined }; + } + + const second = args[1]; + if (second === undefined) { + // Should not happen given length check, but TypeScript needs this + return { transactionRef: first, targetArg: undefined }; + } + + // Two or more args - first is target, second is transaction + return { transactionRef: second, targetArg: first }; +} + +/** Resolved target type for internal use */ +type ResolvedProfileTarget = { + org: string; + project: string; + orgDisplay: string; + projectDisplay: string; + detectedFrom?: string; +}; + +/** + * Resolve target from a project search result. + */ +async function resolveFromProjectSearch( + projectSlug: string, + transactionRef: string +): Promise { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const alternatives = found.map( + (p) => `${p.organization?.slug ?? "unknown"}/${p.slug}` + ); + throw new ContextError( + `Project "${projectSlug}" exists in multiple organizations`, + `sentry profile view /${projectSlug} ${transactionRef}`, + alternatives + ); + } + const foundProject = found[0]; + if (!foundProject) { + throw new ContextError(`Project "${projectSlug}" not found`, USAGE_HINT); + } + const orgSlug = foundProject.organization?.slug; + if (!orgSlug) { + throw new ContextError( + `Could not determine organization for project "${projectSlug}"`, + USAGE_HINT + ); + } + return { + org: orgSlug, + project: foundProject.slug, + orgDisplay: orgSlug, + projectDisplay: foundProject.slug, + }; +} + export const viewCommand = buildCommand({ docs: { brief: "View CPU profiling analysis for a transaction", @@ -58,36 +152,22 @@ export const viewCommand = buildCommand({ " - Hot paths (functions consuming the most CPU time)\n" + " - Recommendations for optimization\n\n" + "By default, only user application code is shown. Use --all-frames to include library code.\n\n" + - "The organization and project are resolved from:\n" + - " 1. --org and --project flags\n" + - " 2. Config defaults\n" + - " 3. SENTRY_DSN environment variable or source code detection", + "Target specification:\n" + + " sentry profile view # auto-detect from DSN or config\n" + + " sentry profile view / # explicit org and project\n" + + " sentry profile view # find project across all orgs", }, parameters: { positional: { - kind: "tuple", - parameters: [ - { - placeholder: "transaction", - brief: - 'Transaction: index (1), alias (i), or full name ("/api/users")', - parse: String, - }, - ], - }, - flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, - project: { - kind: "parsed", + kind: "array", + parameter: { + placeholder: "args", + brief: + '[/] - Target (optional) and transaction (required). Transaction can be index (1), alias (i), or full name ("/api/users")', parse: String, - brief: "Project slug", - optional: true, }, + }, + flags: { period: { kind: "parsed", parse: parsePeriod, @@ -121,23 +201,50 @@ export const viewCommand = buildCommand({ async func( this: SentryContext, flags: ViewFlags, - transactionRef: string + ...args: string[] ): Promise { const { stdout, cwd, setContext } = this; - // Resolve org and project from flags or detection - const target = await resolveOrgAndProject({ - org: flags.org, - project: flags.project, - cwd, - usageHint: `sentry profile view "${transactionRef}" --org --project `, - }); + // Parse positional args + const { transactionRef, targetArg } = parsePositionalArgs(args); + const parsed = parseOrgProjectArg(targetArg); + + let target: ResolvedProfileTarget | null = null; + + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + target = { + org: parsed.org, + project: parsed.project, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }; + break; + + case ProjectSpecificationType.ProjectSearch: + target = await resolveFromProjectSearch( + parsed.projectSlug, + transactionRef + ); + break; + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "A specific project is required for profile view", + USAGE_HINT + ); + + case ProjectSpecificationType.AutoDetect: + target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + break; + + default: + // Exhaustive check - should never reach here + throw new ContextError("Invalid target specification", USAGE_HINT); + } if (!target) { - throw new ContextError( - "Organization and project", - `sentry profile view "${transactionRef}" --org --project ` - ); + throw new ContextError("Organization and project", USAGE_HINT); } // Resolve transaction reference (alias, index, or full name) diff --git a/test/commands/profile/view.test.ts b/test/commands/profile/view.test.ts new file mode 100644 index 00000000..fa224ae1 --- /dev/null +++ b/test/commands/profile/view.test.ts @@ -0,0 +1,103 @@ +/** + * Profile View Command Tests + * + * Tests for positional argument parsing in src/commands/profile/view.ts + */ + +import { describe, expect, test } from "bun:test"; +import { parsePositionalArgs } from "../../../src/commands/profile/view.js"; +import { ContextError } from "../../../src/lib/errors.js"; + +describe("parsePositionalArgs", () => { + describe("single argument (transaction only)", () => { + test("parses single arg as transaction name", () => { + const result = parsePositionalArgs(["/api/users"]); + expect(result.transactionRef).toBe("/api/users"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses transaction index", () => { + const result = parsePositionalArgs(["1"]); + expect(result.transactionRef).toBe("1"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses transaction alias", () => { + const result = parsePositionalArgs(["a"]); + expect(result.transactionRef).toBe("a"); + expect(result.targetArg).toBeUndefined(); + }); + + test("parses complex transaction name", () => { + const result = parsePositionalArgs(["POST /api/v2/users/:id/settings"]); + expect(result.transactionRef).toBe("POST /api/v2/users/:id/settings"); + expect(result.targetArg).toBeUndefined(); + }); + }); + + describe("two arguments (target + transaction)", () => { + test("parses org/project target and transaction name", () => { + const result = parsePositionalArgs(["my-org/backend", "/api/users"]); + expect(result.targetArg).toBe("my-org/backend"); + expect(result.transactionRef).toBe("/api/users"); + }); + + test("parses project-only target and transaction", () => { + const result = parsePositionalArgs(["backend", "/api/users"]); + expect(result.targetArg).toBe("backend"); + expect(result.transactionRef).toBe("/api/users"); + }); + + test("parses org/ target (all projects) and transaction", () => { + const result = parsePositionalArgs(["my-org/", "/api/users"]); + expect(result.targetArg).toBe("my-org/"); + expect(result.transactionRef).toBe("/api/users"); + }); + + test("parses target and transaction index", () => { + const result = parsePositionalArgs(["my-org/backend", "1"]); + expect(result.targetArg).toBe("my-org/backend"); + expect(result.transactionRef).toBe("1"); + }); + + test("parses target and transaction alias", () => { + const result = parsePositionalArgs(["my-org/backend", "a"]); + expect(result.targetArg).toBe("my-org/backend"); + expect(result.transactionRef).toBe("a"); + }); + }); + + describe("error cases", () => { + test("throws ContextError for empty args", () => { + expect(() => parsePositionalArgs([])).toThrow(ContextError); + }); + + test("throws ContextError with usage hint", () => { + try { + parsePositionalArgs([]); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Transaction"); + } + }); + }); + + describe("edge cases", () => { + test("handles more than two args (ignores extras)", () => { + const result = parsePositionalArgs([ + "my-org/backend", + "/api/users", + "extra-arg", + ]); + expect(result.targetArg).toBe("my-org/backend"); + expect(result.transactionRef).toBe("/api/users"); + }); + + test("handles empty string transaction in two-arg case", () => { + const result = parsePositionalArgs(["my-org/backend", ""]); + expect(result.targetArg).toBe("my-org/backend"); + expect(result.transactionRef).toBe(""); + }); + }); +}); From e6fada5b41909044d497abc757829823d8f3359d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 12:29:36 +0000 Subject: [PATCH 11/32] chore: trigger CI From f329ace17fe4ec1c331d8312cf3d8279bafb6ef4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 13:06:35 +0000 Subject: [PATCH 12/32] test(profile): add tests for analyzer, formatters, and URLs - Add unit + property-based tests for profile analyzer (nsToMs, formatDuration, hasProfileData, analyzeHotPaths, calculatePercentiles, analyzeFlamegraph) - Add unit tests for profile formatters (formatProfileAnalysis, formatProfileListHeader, formatProfileListTableHeader, formatProfileListRow, formatProfileListFooter) - Add property tests for buildProfileUrl and buildProfilingSummaryUrl - Add edge case tests for extractTransactionSegment (empty, placeholder-only, numeric-only inputs) - Fix buildProfilingSummaryUrl to use numeric project ID instead of slug - Fix getFlamegraph default statsPeriod from '7d' to '24h' to match CLI --- src/commands/profile/list.ts | 14 +- src/lib/api-client.ts | 2 +- src/lib/sentry-urls.ts | 6 +- test/lib/formatters/profile.test.ts | 307 +++++++++++++ test/lib/profile/analyzer.test.ts | 465 ++++++++++++++++++++ test/lib/sentry-urls.property.test.ts | 117 +++++ test/lib/transaction-alias.property.test.ts | 28 ++ 7 files changed, 928 insertions(+), 11 deletions(-) create mode 100644 test/lib/formatters/profile.test.ts create mode 100644 test/lib/profile/analyzer.test.ts diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index 64dd0dab..7a37f784 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -155,22 +155,22 @@ export const listCommand = buildCommand({ // Set telemetry context setContext([resolvedTarget.org], [resolvedTarget.project]); + // Get project to retrieve numeric ID (required for profile API and web URLs) + const project = await getProject( + resolvedTarget.org, + resolvedTarget.project + ); + // Open in browser if requested if (flags.web) { await openInBrowser( stdout, - buildProfilingSummaryUrl(resolvedTarget.org, resolvedTarget.project), + buildProfilingSummaryUrl(resolvedTarget.org, project.id), "profiling" ); return; } - // Get project to retrieve numeric ID (required for profile API) - const project = await getProject( - resolvedTarget.org, - resolvedTarget.project - ); - // Fetch profiled transactions const response = await listProfiledTransactions( resolvedTarget.org, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index bc752586..1a6f138e 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1025,7 +1025,7 @@ export function getFlamegraph( orgSlug: string, projectId: string | number, transactionName: string, - statsPeriod = "7d" + statsPeriod = "24h" ): Promise { // Escape special characters in transaction name for query const escapedTransaction = transactionName diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index 5d840eb9..dee1d8e4 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -128,14 +128,14 @@ export function buildProfileUrl( * Build URL to the profiling summary page for a project. * * @param orgSlug - Organization slug - * @param projectSlug - Project slug + * @param projectId - Numeric project ID (Sentry frontend requires numeric ID for ?project= param) * @returns Full URL to the profiling summary page */ export function buildProfilingSummaryUrl( orgSlug: string, - projectSlug: string + projectId: string | number ): string { - return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/?project=${projectSlug}`; + return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/?project=${projectId}`; } // Logs URLs diff --git a/test/lib/formatters/profile.test.ts b/test/lib/formatters/profile.test.ts new file mode 100644 index 00000000..80cd6738 --- /dev/null +++ b/test/lib/formatters/profile.test.ts @@ -0,0 +1,307 @@ +/** + * Profile Formatter Tests + * + * Tests for profiling output formatters in src/lib/formatters/profile.ts. + */ + +import { describe, expect, test } from "bun:test"; +import { + formatProfileAnalysis, + formatProfileListFooter, + formatProfileListHeader, + formatProfileListRow, + formatProfileListTableHeader, +} from "../../../src/lib/formatters/profile.js"; +import type { + HotPath, + ProfileAnalysis, + ProfileFunctionRow, + TransactionAliasEntry, +} from "../../../src/types/index.js"; + +/** Strip ANSI color codes for easier testing */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI stripping + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function createHotPath(overrides: Partial = {}): HotPath { + return { + frames: [ + { + name: "processRequest", + file: "src/app.ts", + line: 42, + is_application: true, + fingerprint: 1, + }, + ], + frameInfo: { + count: 100, + weight: 5000, + sumDuration: 10_000_000, + sumSelfTime: 5_000_000, + p75Duration: 8_000_000, + p95Duration: 12_000_000, + p99Duration: 15_000_000, + }, + percentage: 45.2, + ...overrides, + }; +} + +function createAnalysis( + overrides: Partial = {} +): ProfileAnalysis { + return { + transactionName: "/api/users", + platform: "node", + period: "24h", + percentiles: { p75: 8, p95: 12, p99: 20 }, + hotPaths: [createHotPath()], + totalSamples: 500, + userCodeOnly: true, + ...overrides, + }; +} + +// formatProfileAnalysis + +describe("formatProfileAnalysis", () => { + test("includes transaction name and period in header", () => { + const analysis = createAnalysis(); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("/api/users"); + expect(output).toContain("last 24h"); + }); + + test("includes performance percentiles section", () => { + const analysis = createAnalysis({ + percentiles: { p75: 5, p95: 15, p99: 25 }, + }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Performance Percentiles"); + expect(output).toContain("p75:"); + expect(output).toContain("p95:"); + expect(output).toContain("p99:"); + }); + + test("includes hot paths section with user code only label", () => { + const analysis = createAnalysis({ userCodeOnly: true }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Hot Paths"); + expect(output).toContain("user code only"); + }); + + test("includes hot paths section without user code label when all frames", () => { + const analysis = createAnalysis({ userCodeOnly: false }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Hot Paths"); + expect(output).not.toContain("user code only"); + }); + + test("includes function name, file, and percentage in hot path rows", () => { + const analysis = createAnalysis(); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("processRequest"); + expect(output).toContain("src/app.ts:42"); + expect(output).toContain("45.2%"); + }); + + test("shows recommendation when top hot path exceeds 10%", () => { + const analysis = createAnalysis({ + hotPaths: [createHotPath({ percentage: 35.5 })], + }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Recommendations"); + expect(output).toContain("processRequest"); + expect(output).toContain("35.5%"); + expect(output).toContain("Consider optimizing"); + }); + + test("does not show recommendation when top hot path is below 10%", () => { + const analysis = createAnalysis({ + hotPaths: [createHotPath({ percentage: 5.0 })], + }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).not.toContain("Recommendations"); + }); + + test("handles empty hot paths", () => { + const analysis = createAnalysis({ hotPaths: [] }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("No profile data available"); + expect(output).not.toContain("Recommendations"); + }); + + test("returns array of strings", () => { + const analysis = createAnalysis(); + const lines = formatProfileAnalysis(analysis); + + expect(Array.isArray(lines)).toBe(true); + for (const line of lines) { + expect(typeof line).toBe("string"); + } + }); +}); + +// formatProfileListHeader + +describe("formatProfileListHeader", () => { + test("includes org/project and period", () => { + const result = formatProfileListHeader("my-org/backend", "7d"); + expect(result).toContain("my-org/backend"); + expect(result).toContain("last 7d"); + }); + + test("includes 'Transactions with Profiles' label", () => { + const result = formatProfileListHeader("org/proj", "24h"); + expect(result).toContain("Transactions with Profiles"); + }); +}); + +// formatProfileListTableHeader + +describe("formatProfileListTableHeader", () => { + test("includes ALIAS column when hasAliases is true", () => { + const result = stripAnsi(formatProfileListTableHeader(true)); + expect(result).toContain("ALIAS"); + expect(result).toContain("#"); + expect(result).toContain("TRANSACTION"); + expect(result).toContain("PROFILES"); + expect(result).toContain("p75"); + }); + + test("does not include ALIAS or # columns when hasAliases is false", () => { + const result = stripAnsi(formatProfileListTableHeader(false)); + expect(result).not.toContain("ALIAS"); + expect(result).toContain("TRANSACTION"); + expect(result).toContain("PROFILES"); + expect(result).toContain("p75"); + }); + + test("defaults to no aliases", () => { + const result = stripAnsi(formatProfileListTableHeader()); + expect(result).not.toContain("ALIAS"); + }); +}); + +// formatProfileListRow + +describe("formatProfileListRow", () => { + test("formats row with transaction, count, and p75", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count()": 150, + "p75(function.duration)": 8_000_000, // 8ms in nanoseconds + }; + + const result = stripAnsi(formatProfileListRow(row)); + + expect(result).toContain("/api/users"); + expect(result).toContain("150"); + expect(result).toContain("8.00ms"); + }); + + test("formats row with alias when provided", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count()": 150, + "p75(function.duration)": 8_000_000, + }; + + const alias: TransactionAliasEntry = { + idx: 1, + alias: "users", + transaction: "/api/users", + orgSlug: "my-org", + projectSlug: "backend", + }; + + const result = stripAnsi(formatProfileListRow(row, alias)); + + expect(result).toContain("1"); + expect(result).toContain("users"); + expect(result).toContain("/api/users"); + }); + + test("handles missing count", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + }; + + const result = stripAnsi(formatProfileListRow(row)); + expect(result).toContain("0"); + }); + + test("handles missing p75 duration", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count()": 10, + }; + + const result = stripAnsi(formatProfileListRow(row)); + expect(result).toContain("-"); + }); + + test("handles missing transaction name", () => { + const row: ProfileFunctionRow = { + "count()": 10, + "p75(function.duration)": 5_000_000, + }; + + const result = stripAnsi(formatProfileListRow(row)); + expect(result).toContain("unknown"); + }); + + test("truncates long transaction names", () => { + const longTransaction = + "/api/v2/organizations/{org}/projects/{project}/events/{event_id}/attachments/"; + const row: ProfileFunctionRow = { + transaction: longTransaction, + "count()": 1, + "p75(function.duration)": 1_000_000, + }; + + const result = formatProfileListRow(row); + // Without alias: truncated to 48 chars + expect(result.length).toBeLessThan(longTransaction.length + 30); + }); +}); + +// formatProfileListFooter + +describe("formatProfileListFooter", () => { + test("shows alias tip when aliases are available", () => { + const result = formatProfileListFooter(true); + expect(result).toContain("sentry profile view 1"); + expect(result).toContain(""); + }); + + test("shows transaction name tip when no aliases", () => { + const result = formatProfileListFooter(false); + expect(result).toContain(""); + expect(result).not.toContain(""); + }); + + test("defaults to no aliases", () => { + const result = formatProfileListFooter(); + expect(result).toContain(""); + }); +}); diff --git a/test/lib/profile/analyzer.test.ts b/test/lib/profile/analyzer.test.ts new file mode 100644 index 00000000..f2ffde43 --- /dev/null +++ b/test/lib/profile/analyzer.test.ts @@ -0,0 +1,465 @@ +/** + * Profile Analyzer Tests + * + * Tests for flamegraph analysis utilities in src/lib/profile/analyzer.ts. + * Combines property-based tests (for pure functions) with unit tests (for analysis). + */ + +import { describe, expect, test } from "bun:test"; +import { double, assert as fcAssert, integer, nat, property } from "fast-check"; +import { + analyzeFlamegraph, + analyzeHotPaths, + calculatePercentiles, + formatDuration, + hasProfileData, + nsToMs, +} from "../../../src/lib/profile/analyzer.js"; +import type { + Flamegraph, + FlamegraphFrame, + FlamegraphFrameInfo, +} from "../../../src/types/index.js"; +import { DEFAULT_NUM_RUNS } from "../../model-based/helpers.js"; + +// Helpers + +function createFrame( + overrides: Partial = {} +): FlamegraphFrame { + return { + file: "src/app.ts", + is_application: true, + line: 42, + name: "processRequest", + fingerprint: 1, + ...overrides, + }; +} + +function createFrameInfo( + overrides: Partial = {} +): FlamegraphFrameInfo { + return { + count: 100, + weight: 5000, + sumDuration: 10_000_000, + sumSelfTime: 5_000_000, + p75Duration: 8_000_000, + p95Duration: 12_000_000, + p99Duration: 15_000_000, + ...overrides, + }; +} + +function createFlamegraph( + frames: FlamegraphFrame[] = [createFrame()], + frameInfos: FlamegraphFrameInfo[] = [createFrameInfo()] +): Flamegraph { + return { + activeProfileIndex: 0, + platform: "node", + profiles: [ + { + endValue: 1000, + isMainThread: true, + name: "main", + samples: [[0], [0, 1]], + startValue: 0, + threadID: 1, + type: "sampled", + unit: "nanoseconds", + weights: [100, 200], + }, + ], + projectID: 123, + shared: { + frames, + frame_infos: frameInfos, + }, + }; +} + +// nsToMs + +describe("nsToMs", () => { + test("property: converts nanoseconds to milliseconds", () => { + fcAssert( + property(double({ min: 0, max: 1e15, noNaN: true }), (ns) => { + expect(nsToMs(ns)).toBeCloseTo(ns / 1_000_000, 5); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("zero nanoseconds is zero milliseconds", () => { + expect(nsToMs(0)).toBe(0); + }); + + test("1 million nanoseconds is 1 millisecond", () => { + expect(nsToMs(1_000_000)).toBe(1); + }); +}); + +// formatDuration + +describe("formatDuration", () => { + test("formats seconds for values >= 1000ms", () => { + expect(formatDuration(1000)).toBe("1.0s"); + expect(formatDuration(1500)).toBe("1.5s"); + expect(formatDuration(12_345)).toBe("12.3s"); + }); + + test("formats whole milliseconds for values >= 100ms", () => { + expect(formatDuration(100)).toBe("100ms"); + expect(formatDuration(999)).toBe("999ms"); + expect(formatDuration(500)).toBe("500ms"); + }); + + test("formats 1 decimal place for values >= 10ms", () => { + expect(formatDuration(10)).toBe("10.0ms"); + expect(formatDuration(55.5)).toBe("55.5ms"); + expect(formatDuration(99.9)).toBe("99.9ms"); + }); + + test("formats 2 decimal places for values >= 1ms", () => { + expect(formatDuration(1)).toBe("1.00ms"); + expect(formatDuration(5.55)).toBe("5.55ms"); + expect(formatDuration(9.99)).toBe("9.99ms"); + }); + + test("formats microseconds for sub-millisecond values", () => { + expect(formatDuration(0.5)).toBe("500\u00B5s"); + expect(formatDuration(0.001)).toBe("1\u00B5s"); + }); + + test("formats nanoseconds for sub-microsecond values", () => { + expect(formatDuration(0.0001)).toBe("100ns"); + expect(formatDuration(0.000_001)).toBe("1ns"); + }); + + test("property: output always contains a unit", () => { + fcAssert( + property(double({ min: 0.000_001, max: 100_000, noNaN: true }), (ms) => { + const result = formatDuration(ms); + const hasUnit = + result.endsWith("s") || + result.endsWith("ms") || + result.endsWith("\u00B5s") || + result.endsWith("ns"); + expect(hasUnit).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("property: output is non-empty for positive values", () => { + fcAssert( + property(double({ min: 0.000_001, max: 100_000, noNaN: true }), (ms) => { + expect(formatDuration(ms).length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// hasProfileData + +describe("hasProfileData", () => { + test("returns true when flamegraph has profiles, frames, and frame_infos", () => { + const flamegraph = createFlamegraph(); + expect(hasProfileData(flamegraph)).toBe(true); + }); + + test("returns false when profiles array is empty", () => { + const flamegraph = createFlamegraph(); + flamegraph.profiles = []; + expect(hasProfileData(flamegraph)).toBe(false); + }); + + test("returns false when frames array is empty", () => { + const flamegraph = createFlamegraph([], [createFrameInfo()]); + expect(hasProfileData(flamegraph)).toBe(false); + }); + + test("returns false when frame_infos array is empty", () => { + const flamegraph = createFlamegraph([createFrame()], []); + expect(hasProfileData(flamegraph)).toBe(false); + }); + + test("returns false when all arrays are empty", () => { + const flamegraph = createFlamegraph([], []); + flamegraph.profiles = []; + expect(hasProfileData(flamegraph)).toBe(false); + }); +}); + +// analyzeHotPaths + +describe("analyzeHotPaths", () => { + test("returns empty array when no frames exist", () => { + const flamegraph = createFlamegraph([], []); + expect(analyzeHotPaths(flamegraph, 10, false)).toEqual([]); + }); + + test("returns empty array when total self time is zero", () => { + const flamegraph = createFlamegraph( + [createFrame()], + [createFrameInfo({ sumSelfTime: 0 })] + ); + expect(analyzeHotPaths(flamegraph, 10, false)).toEqual([]); + }); + + test("returns hot paths sorted by self time descending", () => { + const frames = [ + createFrame({ name: "low", fingerprint: 1 }), + createFrame({ name: "high", fingerprint: 2 }), + createFrame({ name: "medium", fingerprint: 3 }), + ]; + const infos = [ + createFrameInfo({ sumSelfTime: 100 }), + createFrameInfo({ sumSelfTime: 500 }), + createFrameInfo({ sumSelfTime: 300 }), + ]; + + const flamegraph = createFlamegraph(frames, infos); + const hotPaths = analyzeHotPaths(flamegraph, 10, false); + + expect(hotPaths.length).toBe(3); + expect(hotPaths[0]?.frames[0]?.name).toBe("high"); + expect(hotPaths[1]?.frames[0]?.name).toBe("medium"); + expect(hotPaths[2]?.frames[0]?.name).toBe("low"); + }); + + test("respects limit parameter", () => { + const frames = [ + createFrame({ name: "a", fingerprint: 1 }), + createFrame({ name: "b", fingerprint: 2 }), + createFrame({ name: "c", fingerprint: 3 }), + ]; + const infos = [ + createFrameInfo({ sumSelfTime: 300 }), + createFrameInfo({ sumSelfTime: 200 }), + createFrameInfo({ sumSelfTime: 100 }), + ]; + + const flamegraph = createFlamegraph(frames, infos); + const hotPaths = analyzeHotPaths(flamegraph, 2, false); + + expect(hotPaths.length).toBe(2); + expect(hotPaths[0]?.frames[0]?.name).toBe("a"); + expect(hotPaths[1]?.frames[0]?.name).toBe("b"); + }); + + test("filters to user code only when requested", () => { + const frames = [ + createFrame({ name: "userFunc", is_application: true, fingerprint: 1 }), + createFrame({ name: "libFunc", is_application: false, fingerprint: 2 }), + ]; + const infos = [ + createFrameInfo({ sumSelfTime: 100 }), + createFrameInfo({ sumSelfTime: 500 }), + ]; + + const flamegraph = createFlamegraph(frames, infos); + const hotPaths = analyzeHotPaths(flamegraph, 10, true); + + expect(hotPaths.length).toBe(1); + expect(hotPaths[0]?.frames[0]?.name).toBe("userFunc"); + }); + + test("includes all frames when userCodeOnly is false", () => { + const frames = [ + createFrame({ name: "userFunc", is_application: true, fingerprint: 1 }), + createFrame({ name: "libFunc", is_application: false, fingerprint: 2 }), + ]; + const infos = [ + createFrameInfo({ sumSelfTime: 100 }), + createFrameInfo({ sumSelfTime: 500 }), + ]; + + const flamegraph = createFlamegraph(frames, infos); + const hotPaths = analyzeHotPaths(flamegraph, 10, false); + + expect(hotPaths.length).toBe(2); + }); + + test("calculates correct percentages", () => { + const frames = [ + createFrame({ name: "a", fingerprint: 1 }), + createFrame({ name: "b", fingerprint: 2 }), + ]; + const infos = [ + createFrameInfo({ sumSelfTime: 750 }), + createFrameInfo({ sumSelfTime: 250 }), + ]; + + const flamegraph = createFlamegraph(frames, infos); + const hotPaths = analyzeHotPaths(flamegraph, 10, false); + + expect(hotPaths[0]?.percentage).toBeCloseTo(75, 1); + expect(hotPaths[1]?.percentage).toBeCloseTo(25, 1); + }); + + test("property: percentages sum to <= 100", () => { + fcAssert( + property(integer({ min: 1, max: 10 }), (frameCount) => { + const frames = Array.from({ length: frameCount }, (_, i) => + createFrame({ name: `func${i}`, fingerprint: i }) + ); + const infos = Array.from({ length: frameCount }, () => + createFrameInfo({ sumSelfTime: Math.floor(Math.random() * 1000) + 1 }) + ); + + const flamegraph = createFlamegraph(frames, infos); + const hotPaths = analyzeHotPaths(flamegraph, frameCount, false); + + const totalPct = hotPaths.reduce((sum, hp) => sum + hp.percentage, 0); + expect(totalPct).toBeLessThanOrEqual(100.01); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// calculatePercentiles + +describe("calculatePercentiles", () => { + test("returns zeros for empty frame_infos", () => { + const flamegraph = createFlamegraph([], []); + const result = calculatePercentiles(flamegraph); + expect(result).toEqual({ p75: 0, p95: 0, p99: 0 }); + }); + + test("returns max percentiles across all frames in milliseconds", () => { + const infos = [ + createFrameInfo({ + p75Duration: 5_000_000, + p95Duration: 10_000_000, + p99Duration: 20_000_000, + }), + createFrameInfo({ + p75Duration: 8_000_000, + p95Duration: 12_000_000, + p99Duration: 15_000_000, + }), + ]; + + const flamegraph = createFlamegraph( + [createFrame({ fingerprint: 1 }), createFrame({ fingerprint: 2 })], + infos + ); + const result = calculatePercentiles(flamegraph); + + // Max of each: p75=8M ns = 8ms, p95=12M ns = 12ms, p99=20M ns = 20ms + expect(result.p75).toBe(8); + expect(result.p95).toBe(12); + expect(result.p99).toBe(20); + }); + + test("property: p75 <= p95 <= p99 when frame infos have that ordering", () => { + fcAssert( + property( + nat(1_000_000_000), + nat(1_000_000_000), + nat(1_000_000_000), + (a, b, c) => { + const sorted = [a, b, c].sort((x, y) => x - y) as [ + number, + number, + number, + ]; + const info = createFrameInfo({ + p75Duration: sorted[0], + p95Duration: sorted[1], + p99Duration: sorted[2], + }); + const flamegraph = createFlamegraph([createFrame()], [info]); + const result = calculatePercentiles(flamegraph); + + expect(result.p75).toBeLessThanOrEqual(result.p95); + expect(result.p95).toBeLessThanOrEqual(result.p99); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// analyzeFlamegraph + +describe("analyzeFlamegraph", () => { + test("returns structured analysis with all fields", () => { + const flamegraph = createFlamegraph(); + const result = analyzeFlamegraph(flamegraph, { + transactionName: "/api/users", + period: "24h", + limit: 10, + userCodeOnly: true, + }); + + expect(result.transactionName).toBe("/api/users"); + expect(result.platform).toBe("node"); + expect(result.period).toBe("24h"); + expect(result.userCodeOnly).toBe(true); + expect(result.percentiles).toBeDefined(); + expect(result.hotPaths).toBeDefined(); + expect(result.totalSamples).toBeGreaterThan(0); + }); + + test("counts total samples across all profiles", () => { + const flamegraph = createFlamegraph(); + // Default has 2 samples in one profile + const result = analyzeFlamegraph(flamegraph, { + transactionName: "test", + period: "7d", + limit: 10, + userCodeOnly: false, + }); + + expect(result.totalSamples).toBe(2); + }); + + test("propagates userCodeOnly to hot paths analysis", () => { + const frames = [ + createFrame({ name: "userFunc", is_application: true, fingerprint: 1 }), + createFrame({ name: "libFunc", is_application: false, fingerprint: 2 }), + ]; + const infos = [ + createFrameInfo({ sumSelfTime: 100 }), + createFrameInfo({ sumSelfTime: 500 }), + ]; + const flamegraph = createFlamegraph(frames, infos); + + const userOnly = analyzeFlamegraph(flamegraph, { + transactionName: "test", + period: "24h", + limit: 10, + userCodeOnly: true, + }); + + const allFrames = analyzeFlamegraph(flamegraph, { + transactionName: "test", + period: "24h", + limit: 10, + userCodeOnly: false, + }); + + expect(userOnly.hotPaths.length).toBe(1); + expect(allFrames.hotPaths.length).toBe(2); + }); + + test("handles empty flamegraph gracefully", () => { + const flamegraph = createFlamegraph([], []); + const result = analyzeFlamegraph(flamegraph, { + transactionName: "test", + period: "24h", + limit: 10, + userCodeOnly: false, + }); + + expect(result.hotPaths).toEqual([]); + expect(result.percentiles).toEqual({ p75: 0, p95: 0, p99: 0 }); + expect(result.totalSamples).toBe(2); // profiles still have samples + }); +}); diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index ac645104..3f2d3f1a 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { constantFrom, assert as fcAssert, + nat, oneof, property, stringMatching, @@ -20,6 +21,8 @@ import { buildLogsUrl, buildOrgSettingsUrl, buildOrgUrl, + buildProfileUrl, + buildProfilingSummaryUrl, buildProjectUrl, buildSeerSettingsUrl, buildTraceUrl, @@ -93,6 +96,18 @@ const hashArb = stringMatching(/^[a-zA-Z][a-zA-Z0-9-]{0,20}$/); /** Product names for billing URLs */ const productArb = constantFrom("seer", "errors", "performance", "replays"); +/** Numeric project IDs (Sentry uses numeric IDs for ?project= params) */ +const projectIdArb = nat({ max: 9_999_999 }).filter((n) => n > 0); + +/** Transaction names (URL-style paths) */ +const transactionNameArb = constantFrom( + "/api/users", + "/api/0/organizations/{org}/issues/", + "POST /api/v2/users/:id", + "tasks.process_event", + "/health" +); + describe("isSentrySaasUrl properties", () => { test("sentry.io always returns true", () => { expect(isSentrySaasUrl("https://sentry.io")).toBe(true); @@ -456,6 +471,106 @@ describe("buildTraceUrl properties", () => { }); }); +describe("buildProfileUrl properties", () => { + test("output contains org slug, project slug, and encoded transaction", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, transactionNameArb), + ([orgSlug, projectSlug, transaction]) => { + const result = buildProfileUrl(orgSlug, projectSlug, transaction); + expect(result).toContain(orgSlug); + expect(result).toContain(projectSlug); + expect(result).toContain(encodeURIComponent(transaction)); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains /profiling/profile/ path", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, transactionNameArb), + ([orgSlug, projectSlug, transaction]) => { + const result = buildProfileUrl(orgSlug, projectSlug, transaction); + expect(result).toContain("/profiling/profile/"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, transactionNameArb), + ([orgSlug, projectSlug, transaction]) => { + const result = buildProfileUrl(orgSlug, projectSlug, transaction); + expect(() => new URL(result)).not.toThrow(); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains flamegraph and transaction query", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, transactionNameArb), + ([orgSlug, projectSlug, transaction]) => { + const result = buildProfileUrl(orgSlug, projectSlug, transaction); + expect(result).toContain("/flamegraph/"); + expect(result).toContain("query=transaction"); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("buildProfilingSummaryUrl properties", () => { + test("output contains org slug and project ID", async () => { + await fcAssert( + property(tuple(slugArb, projectIdArb), ([orgSlug, projectId]) => { + const result = buildProfilingSummaryUrl(orgSlug, projectId); + expect(result).toContain(orgSlug); + expect(result).toContain(`${projectId}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output contains /profiling/ path", async () => { + await fcAssert( + property(tuple(slugArb, projectIdArb), ([orgSlug, projectId]) => { + const result = buildProfilingSummaryUrl(orgSlug, projectId); + expect(result).toContain("/profiling/"); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output is a valid URL", async () => { + await fcAssert( + property(tuple(slugArb, projectIdArb), ([orgSlug, projectId]) => { + const result = buildProfilingSummaryUrl(orgSlug, projectId); + expect(() => new URL(result)).not.toThrow(); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("output has project query parameter with numeric ID", async () => { + await fcAssert( + property(tuple(slugArb, projectIdArb), ([orgSlug, projectId]) => { + const result = buildProfilingSummaryUrl(orgSlug, projectId); + expect(result).toContain(`?project=${projectId}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + describe("URL building cross-function properties", () => { test("all URL builders produce valid URLs", async () => { await fcAssert( @@ -471,6 +586,8 @@ describe("URL building cross-function properties", () => { buildSeerSettingsUrl(orgSlug), buildBillingUrl(orgSlug), buildBillingUrl(orgSlug, product), + buildProfileUrl(orgSlug, projectSlug, "/api/users"), + buildProfilingSummaryUrl(orgSlug, 12_345), ]; for (const url of urls) { diff --git a/test/lib/transaction-alias.property.test.ts b/test/lib/transaction-alias.property.test.ts index 293943e1..98383cf6 100644 --- a/test/lib/transaction-alias.property.test.ts +++ b/test/lib/transaction-alias.property.test.ts @@ -290,6 +290,34 @@ describe("property: buildTransactionAliases", () => { }); }); +// Edge cases for extractTransactionSegment + +describe("extractTransactionSegment edge cases", () => { + test("returns 'txn' fallback for empty string", () => { + expect(extractTransactionSegment("")).toBe("txn"); + }); + + test("returns 'txn' fallback for placeholder-only transaction", () => { + expect(extractTransactionSegment("/{org}/{project}/")).toBe("txn"); + }); + + test("returns 'txn' fallback for purely numeric transaction", () => { + expect(extractTransactionSegment("/0/1/2/")).toBe("txn"); + }); + + test("returns 'txn' fallback for mixed placeholders and numerics", () => { + expect(extractTransactionSegment("/{org}/0/{project}/1/")).toBe("txn"); + }); + + test("handles single slash", () => { + expect(extractTransactionSegment("/")).toBe("txn"); + }); + + test("handles single dot", () => { + expect(extractTransactionSegment(".")).toBe("txn"); + }); +}); + // Integration properties describe("property: alias lookup invariants", () => { From 56ff6ac376e7ff895a2a111b9947efaebfdbd3dc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 13:46:24 +0000 Subject: [PATCH 13/32] test(profile): add command-level tests for list and view - Add 18 mock-based tests for listCommand covering target resolution, --web, --json, empty state, human-readable output, alias building - Add resolveFromProjectSearch tests (4 tests) with spyOn mocking - Add 13 mock-based tests for viewCommand covering target resolution, --web, --json, no profile data, human-readable output - Export resolveFromProjectSearch for testability - Use Stricli loader() pattern for command function access in tests --- src/commands/profile/view.ts | 2 +- test/commands/profile/list.test.ts | 393 ++++++++++++++++++++++++ test/commands/profile/view.test.ts | 476 ++++++++++++++++++++++++++++- 3 files changed, 867 insertions(+), 4 deletions(-) create mode 100644 test/commands/profile/list.test.ts diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 60384ff7..5860a101 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -103,7 +103,7 @@ type ResolvedProfileTarget = { /** * Resolve target from a project search result. */ -async function resolveFromProjectSearch( +export async function resolveFromProjectSearch( projectSlug: string, transactionRef: string ): Promise { diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts new file mode 100644 index 00000000..d7f36417 --- /dev/null +++ b/test/commands/profile/list.test.ts @@ -0,0 +1,393 @@ +/** + * Profile List Command Tests + * + * Tests for the listCommand in src/commands/profile/list.ts. + * Uses spyOn mocking for API calls and a mock SentryContext. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { listCommand } from "../../../src/commands/profile/list.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as transactionAliasesDb from "../../../src/lib/db/transaction-aliases.js"; +import { ContextError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; + +/** Captured stdout output */ +type MockContext = { + stdout: { write: ReturnType }; + cwd: string; + setContext: ReturnType; +}; + +function createMockContext(): MockContext { + return { + stdout: { write: mock(() => true) }, + cwd: "/tmp/test", + setContext: mock(() => true), + }; +} + +/** Collect all written output as a single string */ +function getOutput(ctx: MockContext): string { + return ctx.stdout.write.mock.calls.map((c) => c[0]).join(""); +} + +/** Default flags */ +const defaultFlags = { + period: "24h", + limit: 20, + json: false, + web: false, +}; + +// Spies +let resolveOrgAndProjectSpy: ReturnType; +let getProjectSpy: ReturnType; +let listProfiledTransactionsSpy: ReturnType; +let openInBrowserSpy: ReturnType; +let setTransactionAliasesSpy: ReturnType; + +beforeEach(() => { + resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); + getProjectSpy = spyOn(apiClient, "getProject"); + listProfiledTransactionsSpy = spyOn(apiClient, "listProfiledTransactions"); + openInBrowserSpy = spyOn(browser, "openInBrowser"); + setTransactionAliasesSpy = spyOn( + transactionAliasesDb, + "setTransactionAliases" + ); +}); + +afterEach(() => { + resolveOrgAndProjectSpy.mockRestore(); + getProjectSpy.mockRestore(); + listProfiledTransactionsSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + setTransactionAliasesSpy.mockRestore(); +}); + +/** Helper: set up default resolved target and project */ +function setupResolvedTarget( + overrides?: Partial<{ org: string; project: string; detectedFrom: string }> +) { + const target = { + org: overrides?.org ?? "my-org", + project: overrides?.project ?? "backend", + detectedFrom: overrides?.detectedFrom, + }; + resolveOrgAndProjectSpy.mockResolvedValue(target); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: target.project, + name: "Backend", + }); + return target; +} + +/** + * Load the actual function from Stricli's lazy loader. + * At runtime, loader() always returns the function, but the TypeScript + * type is a union of CommandModule | CommandFunction. We cast since + * we only use .call() in tests. + */ +async function loadListFunc(): Promise<(...args: any[]) => any> { + return (await listCommand.loader()) as (...args: any[]) => any; +} + +describe("listCommand.func", () => { + describe("target resolution", () => { + test("throws ContextError for org-all target (org/)", async () => { + const ctx = createMockContext(); + const func = await loadListFunc(); + + await expect(func.call(ctx, defaultFlags, "my-org/")).rejects.toThrow( + ContextError + ); + }); + + test("org-all error mentions specific project requirement", async () => { + const ctx = createMockContext(); + const func = await loadListFunc(); + + try { + await func.call(ctx, defaultFlags, "my-org/"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Project"); + } + }); + + test("throws ContextError when resolveOrgAndProject returns null", async () => { + const ctx = createMockContext(); + resolveOrgAndProjectSpy.mockResolvedValue(null); + const func = await loadListFunc(); + + await expect(func.call(ctx, defaultFlags)).rejects.toThrow(ContextError); + }); + + test("resolves explicit org/project target", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + expect(resolveOrgAndProjectSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: "my-org", project: "backend" }) + ); + }); + + test("resolves project-only target", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "backend"); + + expect(resolveOrgAndProjectSpy).toHaveBeenCalledWith( + expect.objectContaining({ project: "backend" }) + ); + }); + + test("auto-detect target when no positional arg", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags); + + expect(resolveOrgAndProjectSpy).toHaveBeenCalled(); + }); + + test("sets telemetry context after resolution", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + expect(ctx.setContext).toHaveBeenCalledWith(["my-org"], ["backend"]); + }); + }); + + describe("--web flag", () => { + test("opens browser and returns early", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + openInBrowserSpy.mockResolvedValue(undefined); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, web: true }, "my-org/backend"); + + expect(openInBrowserSpy).toHaveBeenCalledWith( + ctx.stdout, + expect.stringContaining("/profiling/"), + "profiling" + ); + // Should NOT have called listProfiledTransactions + expect(listProfiledTransactionsSpy).not.toHaveBeenCalled(); + }); + + test("passes numeric project ID in profiling URL", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + openInBrowserSpy.mockResolvedValue(undefined); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, web: true }, "my-org/backend"); + + expect(openInBrowserSpy).toHaveBeenCalledWith( + ctx.stdout, + expect.stringContaining("project=12345"), + "profiling" + ); + }); + }); + + describe("--json flag", () => { + test("outputs JSON and returns", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + const mockData = [ + { transaction: "/api/users", "count()": 50 }, + { transaction: "/api/events", "count()": 30 }, + ]; + listProfiledTransactionsSpy.mockResolvedValue({ data: mockData }); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, json: true }, "my-org/backend"); + + const output = getOutput(ctx); + const parsed = JSON.parse(output); + expect(parsed).toEqual(mockData); + }); + }); + + describe("empty state", () => { + test("shows empty state message when no data", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).toContain("No profiling data found"); + expect(output).toContain("my-org/backend"); + }); + }); + + describe("human-readable output", () => { + test("renders table with header, rows, and footer", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [ + { + transaction: "/api/users", + "count()": 150, + "p75(function.duration)": 8_000_000, + }, + { + transaction: "/api/events", + "count()": 75, + "p75(function.duration)": 15_000_000, + }, + ], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).toContain("Transactions with Profiles"); + expect(output).toContain("my-org/backend"); + expect(output).toContain("last 24h"); + expect(output).toContain("/api/users"); + expect(output).toContain("/api/events"); + expect(output).toContain("sentry profile view"); + }); + + test("passes period flag to API", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, period: "7d" }, "my-org/backend"); + + expect(listProfiledTransactionsSpy).toHaveBeenCalledWith( + "my-org", + "12345", + expect.objectContaining({ statsPeriod: "7d" }) + ); + }); + + test("passes limit flag to API", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, limit: 5 }, "my-org/backend"); + + expect(listProfiledTransactionsSpy).toHaveBeenCalledWith( + "my-org", + "12345", + expect.objectContaining({ limit: 5 }) + ); + }); + + test("shows detectedFrom hint when present", async () => { + const ctx = createMockContext(); + setupResolvedTarget({ detectedFrom: ".env file" }); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [{ transaction: "/api/users", "count()": 10 }], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).toContain("Detected from .env file"); + }); + + test("does not show detectedFrom when absent", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [{ transaction: "/api/users", "count()": 10 }], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).not.toContain("Detected from"); + }); + }); + + describe("alias building", () => { + test("stores transaction aliases in DB", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [ + { transaction: "/api/users", "count()": 50 }, + { transaction: "/api/events", "count()": 30 }, + ], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + expect(setTransactionAliasesSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ transaction: "/api/users" }), + expect.objectContaining({ transaction: "/api/events" }), + ]), + expect.any(String) // fingerprint + ); + }); + + test("filters out rows with no transaction name", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [ + { transaction: "/api/users", "count()": 50 }, + { "count()": 30 }, // no transaction name + ], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + // Aliases should only include the row with a transaction name + const aliasCall = setTransactionAliasesSpy.mock.calls[0]; + expect(aliasCall).toBeDefined(); + const aliases = aliasCall[0]; + expect(aliases.length).toBe(1); + expect(aliases[0].transaction).toBe("/api/users"); + }); + }); +}); diff --git a/test/commands/profile/view.test.ts b/test/commands/profile/view.test.ts index fa224ae1..bfa46c5f 100644 --- a/test/commands/profile/view.test.ts +++ b/test/commands/profile/view.test.ts @@ -1,12 +1,35 @@ /** * Profile View Command Tests * - * Tests for positional argument parsing in src/commands/profile/view.ts + * Tests for positional argument parsing, project resolution, + * and command execution in src/commands/profile/view.ts. */ -import { describe, expect, test } from "bun:test"; -import { parsePositionalArgs } from "../../../src/commands/profile/view.js"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { + parsePositionalArgs, + resolveFromProjectSearch, + viewCommand, +} from "../../../src/commands/profile/view.js"; +import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; import { ContextError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTransactionMod from "../../../src/lib/resolve-transaction.js"; +import type { Flamegraph } from "../../../src/types/index.js"; describe("parsePositionalArgs", () => { describe("single argument (transaction only)", () => { @@ -101,3 +124,450 @@ describe("parsePositionalArgs", () => { }); }); }); + +// resolveFromProjectSearch tests + +describe("resolveFromProjectSearch", () => { + let findProjectsBySlugSpy: ReturnType; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + describe("no projects found", () => { + test("throws ContextError when project not found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + await expect( + resolveFromProjectSearch("my-project", "/api/users") + ).rejects.toThrow(ContextError); + }); + + test("includes project name in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + try { + await resolveFromProjectSearch("frontend", "/api/users"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain('Project "frontend"'); + } + }); + }); + + describe("multiple projects found", () => { + test("throws ContextError when project exists in multiple orgs", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "frontend", + id: "1", + name: "Frontend", + organization: { id: "10", slug: "org-a", name: "Org A" }, + }, + { + slug: "frontend", + id: "2", + name: "Frontend", + organization: { id: "20", slug: "org-b", name: "Org B" }, + }, + ] as ProjectWithOrg[]); + + await expect( + resolveFromProjectSearch("frontend", "/api/users") + ).rejects.toThrow(ContextError); + }); + + test("includes org alternatives in error", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "api", + id: "1", + name: "API", + organization: { id: "10", slug: "acme", name: "Acme" }, + }, + { + slug: "api", + id: "2", + name: "API", + organization: { id: "20", slug: "beta", name: "Beta" }, + }, + ] as ProjectWithOrg[]); + + try { + await resolveFromProjectSearch("api", "/api/users"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + const msg = (error as ContextError).message; + expect(msg).toContain("multiple organizations"); + } + }); + }); + + describe("single project found", () => { + test("returns resolved target", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "backend", + id: "42", + name: "Backend", + organization: { id: "10", slug: "my-company", name: "My Company" }, + }, + ] as ProjectWithOrg[]); + + const result = await resolveFromProjectSearch("backend", "/api/users"); + + expect(result.org).toBe("my-company"); + expect(result.project).toBe("backend"); + }); + + test("throws ContextError when project has no organization slug", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "backend", + id: "42", + name: "Backend", + // organization without slug + }, + ] as ProjectWithOrg[]); + + await expect( + resolveFromProjectSearch("backend", "/api/users") + ).rejects.toThrow(ContextError); + }); + }); +}); + +// viewCommand.func tests + +/** Captured stdout output */ +type MockContext = { + stdout: { write: ReturnType }; + cwd: string; + setContext: ReturnType; +}; + +function createMockContext(): MockContext { + return { + stdout: { write: mock(() => true) }, + cwd: "/tmp/test", + setContext: mock(() => true), + }; +} + +function getOutput(ctx: MockContext): string { + return ctx.stdout.write.mock.calls.map((c) => c[0]).join(""); +} + +/** Create a minimal flamegraph with profile data */ +function createTestFlamegraph( + overrides?: Partial<{ hasData: boolean }> +): Flamegraph { + const hasData = overrides?.hasData ?? true; + return { + activeProfileIndex: 0, + platform: "node", + profiles: hasData + ? [ + { + endValue: 1000, + isMainThread: true, + name: "main", + samples: [[0], [0, 1]], + startValue: 0, + threadID: 1, + type: "sampled", + unit: "nanoseconds", + weights: [100, 200], + }, + ] + : [], + projectID: 12_345, + shared: { + frames: hasData + ? [ + { + file: "src/app.ts", + is_application: true, + line: 42, + name: "processRequest", + fingerprint: 1, + }, + ] + : [], + frame_infos: hasData + ? [ + { + count: 100, + weight: 5000, + sumDuration: 10_000_000, + sumSelfTime: 5_000_000, + p75Duration: 8_000_000, + p95Duration: 12_000_000, + p99Duration: 15_000_000, + }, + ] + : [], + }, + }; +} + +const defaultFlags = { + period: "24h", + limit: 10, + allFrames: false, + json: false, + web: false, +}; + +/** + * Load the actual function from Stricli's lazy loader. + * At runtime, loader() always returns the function, but the TypeScript + * type is a union of CommandModule | CommandFunction. We cast since + * we only use .call() in tests. + */ +async function loadViewFunc(): Promise<(...args: any[]) => any> { + return (await viewCommand.loader()) as (...args: any[]) => any; +} + +describe("viewCommand.func", () => { + let resolveOrgAndProjectSpy: ReturnType; + let getProjectSpy: ReturnType; + let getFlamegraphSpy: ReturnType; + let resolveTransactionSpy: ReturnType; + let openInBrowserSpy: ReturnType; + + beforeEach(() => { + resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); + getProjectSpy = spyOn(apiClient, "getProject"); + getFlamegraphSpy = spyOn(apiClient, "getFlamegraph"); + resolveTransactionSpy = spyOn(resolveTransactionMod, "resolveTransaction"); + openInBrowserSpy = spyOn(browser, "openInBrowser"); + }); + + afterEach(() => { + resolveOrgAndProjectSpy.mockRestore(); + getProjectSpy.mockRestore(); + getFlamegraphSpy.mockRestore(); + resolveTransactionSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + }); + + /** Standard setup for a resolved target that goes through the full flow */ + function setupFullFlow(flamegraph?: Flamegraph) { + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "my-org", + project: "backend", + }); + resolveTransactionSpy.mockReturnValue({ + transaction: "/api/users", + resolvedFrom: "full-name", + }); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); + getFlamegraphSpy.mockResolvedValue( + flamegraph ?? createTestFlamegraph({ hasData: true }) + ); + } + + describe("target resolution", () => { + test("throws ContextError for org-all target (org/)", async () => { + const ctx = createMockContext(); + resolveTransactionSpy.mockReturnValue({ + transaction: "/api/users", + resolvedFrom: "full-name", + }); + const func = await loadViewFunc(); + + await expect( + func.call(ctx, defaultFlags, "my-org/", "/api/users") + ).rejects.toThrow(ContextError); + }); + + test("throws ContextError when auto-detect returns null", async () => { + const ctx = createMockContext(); + resolveOrgAndProjectSpy.mockResolvedValue(null); + resolveTransactionSpy.mockReturnValue({ + transaction: "/api/users", + resolvedFrom: "full-name", + }); + const func = await loadViewFunc(); + + await expect(func.call(ctx, defaultFlags, "/api/users")).rejects.toThrow( + ContextError + ); + }); + + test("resolves explicit org/project target", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend", "/api/users"); + + // Should NOT call resolveOrgAndProject for explicit targets + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + }); + + test("auto-detects target when only transaction arg given", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, defaultFlags, "/api/users"); + + expect(resolveOrgAndProjectSpy).toHaveBeenCalled(); + }); + + test("sets telemetry context", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, defaultFlags, "/api/users"); + + expect(ctx.setContext).toHaveBeenCalledWith(["my-org"], ["backend"]); + }); + }); + + describe("--web flag", () => { + test("opens browser and returns early", async () => { + const ctx = createMockContext(); + setupFullFlow(); + openInBrowserSpy.mockResolvedValue(undefined); + const func = await loadViewFunc(); + + await func.call(ctx, { ...defaultFlags, web: true }, "/api/users"); + + expect(openInBrowserSpy).toHaveBeenCalledWith( + ctx.stdout, + expect.stringContaining("/profiling/"), + "profile" + ); + // Should NOT fetch flamegraph + expect(getFlamegraphSpy).not.toHaveBeenCalled(); + }); + }); + + describe("no profile data", () => { + test("shows message when flamegraph has no data", async () => { + const ctx = createMockContext(); + setupFullFlow(createTestFlamegraph({ hasData: false })); + const func = await loadViewFunc(); + + await func.call(ctx, defaultFlags, "/api/users"); + + const output = getOutput(ctx); + expect(output).toContain("No profiling data found"); + expect(output).toContain("/api/users"); + }); + }); + + describe("--json flag", () => { + test("outputs JSON analysis", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, { ...defaultFlags, json: true }, "/api/users"); + + const output = getOutput(ctx); + const parsed = JSON.parse(output); + expect(parsed.transactionName).toBe("/api/users"); + expect(parsed.platform).toBe("node"); + expect(parsed.percentiles).toBeDefined(); + expect(parsed.hotPaths).toBeDefined(); + }); + }); + + describe("human-readable output", () => { + test("renders profile analysis with hot paths", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, defaultFlags, "/api/users"); + + const output = getOutput(ctx); + expect(output).toContain("/api/users"); + expect(output).toContain("CPU Profile Analysis"); + expect(output).toContain("Performance Percentiles"); + expect(output).toContain("Hot Paths"); + }); + + test("passes period to getFlamegraph", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, { ...defaultFlags, period: "7d" }, "/api/users"); + + expect(getFlamegraphSpy).toHaveBeenCalledWith( + "my-org", + "12345", + "/api/users", + "7d" + ); + }); + + test("respects --all-frames flag", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + await func.call(ctx, { ...defaultFlags, allFrames: true }, "/api/users"); + + const output = getOutput(ctx); + // With allFrames, should NOT show "user code only" + expect(output).not.toContain("user code only"); + }); + + test("shows detectedFrom when present", async () => { + const ctx = createMockContext(); + resolveOrgAndProjectSpy.mockResolvedValue({ + org: "my-org", + project: "backend", + detectedFrom: ".env file", + }); + resolveTransactionSpy.mockReturnValue({ + transaction: "/api/users", + resolvedFrom: "full-name", + }); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); + getFlamegraphSpy.mockResolvedValue( + createTestFlamegraph({ hasData: true }) + ); + const func = await loadViewFunc(); + + await func.call(ctx, defaultFlags, "/api/users"); + + const output = getOutput(ctx); + expect(output).toContain("Detected from .env file"); + }); + + test("clamps limit to 1-20 range", async () => { + const ctx = createMockContext(); + setupFullFlow(); + const func = await loadViewFunc(); + + // limit: 50 should be clamped to 20 + await func.call(ctx, { ...defaultFlags, limit: 50 }, "/api/users"); + + // The output should render without error + const output = getOutput(ctx); + expect(output).toContain("Hot Paths"); + }); + }); +}); From e44e230e384b9cda2e35ef3db1713a4fa2616610 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 13:53:27 +0000 Subject: [PATCH 14/32] fix(test): use uniqueArray in alias prefix property test The 'alias is a prefix of the extracted segment' property test could fail with duplicate transaction inputs because disambiguateSegments appends numeric suffixes to duplicates, breaking the prefix relationship with the raw extracted segment. Use uniqueArray to ensure unique transactions in the property test. --- test/lib/transaction-alias.property.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/test/lib/transaction-alias.property.test.ts b/test/lib/transaction-alias.property.test.ts index 98383cf6..a65b0c46 100644 --- a/test/lib/transaction-alias.property.test.ts +++ b/test/lib/transaction-alias.property.test.ts @@ -321,10 +321,17 @@ describe("extractTransactionSegment edge cases", () => { // Integration properties describe("property: alias lookup invariants", () => { - test("alias is a prefix of the extracted segment", () => { + test("alias is a prefix of the extracted segment (unique transactions)", () => { + // Use uniqueArray to avoid duplicate transactions, since disambiguateSegments + // appends numeric suffixes to duplicates which breaks the prefix relationship + // with the raw extracted segment. fcAssert( property( - array(transactionInputArb, { minLength: 1, maxLength: 10 }), + uniqueArray(transactionInputArb, { + minLength: 1, + maxLength: 10, + comparator: (a, b) => a.transaction === b.transaction, + }), (inputs) => { const aliases = buildTransactionAliases(inputs); From de41ae24ebf6e743f1c6921729307fddd26129ea Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 15:42:32 +0000 Subject: [PATCH 15/32] fix: address all 6 BugBot review comments on profile commands - Fix disambiguateSegments collision: track all result values in a Set and increment suffix until unique (Bug 1) - Fix orgSlug usage: replace resolveFromProjectSearch with shared resolveProjectBySlug that uses foundProject.orgSlug (Bug 2) - Deduplicate resolveFromProjectSearch across event/log/profile view commands into resolveProjectBySlug in resolve-target.ts (Bug 3) - Fix buildProfileUrl: wrap transaction in encoded quotes for proper Sentry search syntax with spaces (Bug 4) - Extract parsePeriod/VALID_PERIODS to shared.ts to avoid duplication between profile list and view commands (Bug 5) - Fix profile list project-search: add resolveListTarget with proper findProjectsBySlug handling instead of broken resolveOrgAndProject passthrough (Bug 6) --- src/commands/event/view.ts | 64 ++-------- src/commands/log/view.ts | 52 ++------ src/commands/profile/list.ts | 127 +++++++++++++------- src/commands/profile/shared.ts | 22 ++++ src/commands/profile/view.ts | 86 ++----------- src/lib/resolve-target.ts | 67 ++++++++++- src/lib/sentry-urls.ts | 2 +- src/lib/transaction-alias.ts | 25 ++-- test/commands/event/view.test.ts | 57 ++++++--- test/commands/log/view.test.ts | 64 +++++++--- test/commands/profile/list.test.ts | 47 +++++++- test/commands/profile/view.test.ts | 72 +++++------ test/lib/sentry-urls.property.test.ts | 7 +- test/lib/transaction-alias.property.test.ts | 59 +++++++++ 14 files changed, 453 insertions(+), 298 deletions(-) create mode 100644 src/commands/profile/shared.ts diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index be8b273e..5addb8bd 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getEvent } from "../../lib/api-client.js"; +import { getEvent } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -15,7 +15,11 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + type ResolvedTarget, + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -95,57 +99,10 @@ export function parsePositionalArgs(args: string[]): { /** * Resolved target type for event commands. + * Uses ResolvedTarget from resolve-target.ts. * @internal Exported for testing */ -export type ResolvedEventTarget = { - org: string; - project: string; - orgDisplay: string; - projectDisplay: string; - detectedFrom?: string; -}; - -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param eventId - Event ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - eventId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry event view /${projectSlug} ${eventId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - orgDisplay: foundProject.orgSlug, - projectDisplay: foundProject.slug, - }; -} +export type ResolvedEventTarget = ResolvedTarget; export const viewCommand = buildCommand({ docs: { @@ -206,7 +163,10 @@ export const viewCommand = buildCommand({ break; case ProjectSpecificationType.ProjectSearch: - target = await resolveFromProjectSearch(parsed.projectSlug, eventId); + target = await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + contextValue: eventId, + }); break; case ProjectSpecificationType.OrgAll: diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 3720a804..58b6ae11 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -6,12 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getLog } from "../../lib/api-client.js"; +import { getLog } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import type { DetailedSentryLog, Writer } from "../../types/index.js"; @@ -68,46 +71,6 @@ export type ResolvedLogTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param logId - Log ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - logId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry log view /${projectSlug} ${logId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - }; -} - /** * Write human-readable log output to stdout. * @@ -187,7 +150,10 @@ export const viewCommand = buildCommand({ break; case "project-search": - target = await resolveFromProjectSearch(parsed.projectSlug, logId); + target = await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + contextValue: logId, + }); break; case "org-all": diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index 7a37f784..e05f56d7 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -7,7 +7,11 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { getProject, listProfiledTransactions } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + getProject, + listProfiledTransactions, +} from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { @@ -27,6 +31,7 @@ import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import { buildProfilingSummaryUrl } from "../../lib/sentry-urls.js"; import { buildTransactionAliases } from "../../lib/transaction-alias.js"; import type { TransactionAliasEntry, Writer } from "../../types/index.js"; +import { parsePeriod } from "./shared.js"; type ListFlags = { readonly period: string; @@ -35,22 +40,88 @@ type ListFlags = { readonly web: boolean; }; -/** Valid period values */ -const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"]; - /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry profile list /"; +/** Resolved org and project for profile list */ +type ResolvedListTarget = { + org: string; + project: string; + detectedFrom?: string; +}; + /** - * Parse and validate the stats period. + * Resolve org/project from parsed argument or auto-detection. + * + * @throws {ContextError} When target cannot be resolved */ -function parsePeriod(value: string): string { - if (!VALID_PERIODS.includes(value)) { - throw new Error( - `Invalid period. Must be one of: ${VALID_PERIODS.join(", ")}` - ); +async function resolveListTarget( + target: string | undefined, + cwd: string +): Promise { + const parsed = parseOrgProjectArg(target); + + switch (parsed.type) { + case "org-all": + throw new ContextError( + "Project", + "Profile listing requires a specific project.\n\n" + + "Usage: sentry profile list /" + ); + + case "explicit": { + const resolved = await resolveOrgAndProject({ + org: parsed.org, + project: parsed.project, + cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + return resolved; + } + + case "project-search": { + const matches = await findProjectsBySlug(parsed.projectSlug); + if (matches.length === 0) { + throw new ContextError(`Project "${parsed.projectSlug}"`, USAGE_HINT, [ + "Check that you have access to a project with this slug", + ]); + } + if (matches.length > 1) { + const alternatives = matches.map( + (m) => `sentry profile list ${m.orgSlug}/${m.slug}` + ); + throw new ContextError( + `Project "${parsed.projectSlug}" exists in multiple organizations`, + `sentry profile list /${parsed.projectSlug}`, + alternatives + ); + } + const match = matches[0] as (typeof matches)[0]; + return { org: match.orgSlug, project: match.slug }; + } + + case "auto-detect": { + const resolved = await resolveOrgAndProject({ + cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + return resolved; + } + + default: { + const _exhaustiveCheck: never = parsed; + throw new ContextError( + `Unexpected target type: ${_exhaustiveCheck}`, + USAGE_HINT + ); + } } - return value; } /** @@ -119,38 +190,8 @@ export const listCommand = buildCommand({ ): Promise { const { stdout, cwd, setContext } = this; - // Parse positional argument to determine resolution strategy - const parsed = parseOrgProjectArg(target); - - // For profile list, we need both org and project - // We don't support org-wide profile listing (too expensive) - if (parsed.type === "org-all") { - throw new ContextError( - "Project", - "Profile listing requires a specific project.\n\n" + - "Usage: sentry profile list /" - ); - } - - // Determine project slug based on parsed type - let projectSlug: string | undefined; - if (parsed.type === "explicit") { - projectSlug = parsed.project; - } else if (parsed.type === "project-search") { - projectSlug = parsed.projectSlug; - } - - // Resolve org and project - const resolvedTarget = await resolveOrgAndProject({ - org: parsed.type === "explicit" ? parsed.org : undefined, - project: projectSlug, - cwd, - usageHint: USAGE_HINT, - }); - - if (!resolvedTarget) { - throw new ContextError("Organization and project", USAGE_HINT); - } + // Resolve org and project from positional arg or auto-detection + const resolvedTarget = await resolveListTarget(target, cwd); // Set telemetry context setContext([resolvedTarget.org], [resolvedTarget.project]); diff --git a/src/commands/profile/shared.ts b/src/commands/profile/shared.ts new file mode 100644 index 00000000..ca3b6b7f --- /dev/null +++ b/src/commands/profile/shared.ts @@ -0,0 +1,22 @@ +/** + * Shared utilities for profile commands. + */ + +/** Valid period values for profiling queries */ +export const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"]; + +/** + * Parse and validate a stats period string. + * + * @param value - Period string to validate + * @returns The validated period string + * @throws Error if the period is not in VALID_PERIODS + */ +export function parsePeriod(value: string): string { + if (!VALID_PERIODS.includes(value)) { + throw new Error( + `Invalid period. Must be one of: ${VALID_PERIODS.join(", ")}` + ); + } + return value; +} diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 5860a101..4d3815e1 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -7,11 +7,7 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { - findProjectsBySlug, - getFlamegraph, - getProject, -} from "../../lib/api-client.js"; +import { getFlamegraph, getProject } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -27,9 +23,14 @@ import { analyzeFlamegraph, hasProfileData, } from "../../lib/profile/analyzer.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + type ResolvedTarget, + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { resolveTransaction } from "../../lib/resolve-transaction.js"; import { buildProfileUrl } from "../../lib/sentry-urls.js"; +import { parsePeriod } from "./shared.js"; type ViewFlags = { readonly period: string; @@ -39,24 +40,9 @@ type ViewFlags = { readonly web: boolean; }; -/** Valid period values */ -const VALID_PERIODS = ["1h", "24h", "7d", "14d", "30d"]; - /** Usage hint for ContextError messages */ const USAGE_HINT = "sentry profile view / "; -/** - * Parse and validate the stats period. - */ -function parsePeriod(value: string): string { - if (!VALID_PERIODS.includes(value)) { - throw new Error( - `Invalid period. Must be one of: ${VALID_PERIODS.join(", ")}` - ); - } - return value; -} - /** * Parse positional arguments for profile view. * Handles: `` or ` ` @@ -92,55 +78,7 @@ export function parsePositionalArgs(args: string[]): { } /** Resolved target type for internal use */ -type ResolvedProfileTarget = { - org: string; - project: string; - orgDisplay: string; - projectDisplay: string; - detectedFrom?: string; -}; - -/** - * Resolve target from a project search result. - */ -export async function resolveFromProjectSearch( - projectSlug: string, - transactionRef: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const alternatives = found.map( - (p) => `${p.organization?.slug ?? "unknown"}/${p.slug}` - ); - throw new ContextError( - `Project "${projectSlug}" exists in multiple organizations`, - `sentry profile view /${projectSlug} ${transactionRef}`, - alternatives - ); - } - const foundProject = found[0]; - if (!foundProject) { - throw new ContextError(`Project "${projectSlug}" not found`, USAGE_HINT); - } - const orgSlug = foundProject.organization?.slug; - if (!orgSlug) { - throw new ContextError( - `Could not determine organization for project "${projectSlug}"`, - USAGE_HINT - ); - } - return { - org: orgSlug, - project: foundProject.slug, - orgDisplay: orgSlug, - projectDisplay: foundProject.slug, - }; -} +type ResolvedProfileTarget = ResolvedTarget; export const viewCommand = buildCommand({ docs: { @@ -222,10 +160,10 @@ export const viewCommand = buildCommand({ break; case ProjectSpecificationType.ProjectSearch: - target = await resolveFromProjectSearch( - parsed.projectSlug, - transactionRef - ); + target = await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + contextValue: transactionRef, + }); break; case ProjectSpecificationType.OrgAll: diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 3c503f3f..3ff598c8 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -15,6 +15,7 @@ import { basename } from "node:path"; import { findProjectByDsnKey, findProjectsByPattern, + findProjectsBySlug, getProject, } from "./api-client.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; @@ -33,7 +34,7 @@ import { formatMultipleProjectsFooter, getDsnSourceDescription, } from "./dsn/index.js"; -import { AuthError, ContextError } from "./errors.js"; +import { AuthError, ContextError, ValidationError } from "./errors.js"; /** * Resolved organization and project target for API calls. @@ -680,3 +681,67 @@ export async function resolveOrg( return null; } } + +/** + * Options for resolving a project by slug. + */ +export type ResolveProjectBySlugOptions = { + /** Usage hint shown in error messages */ + usageHint: string; + /** Additional context for error messages (e.g., event ID, log ID) */ + contextValue?: string; +}; + +/** + * Resolve a project by slug across all accessible organizations. + * + * Searches for a project by slug. Throws if no project found or if + * multiple projects with the same slug exist in different organizations. + * + * @param projectSlug - Project slug to search for + * @param options - Resolution options with usage hint and optional context + * @returns Resolved target with org and project info + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + * + * @example + * ```typescript + * const target = await resolveProjectBySlug("my-project", { + * usageHint: "sentry event view / ", + * contextValue: eventId, + * }); + * ``` + */ +export async function resolveProjectBySlug( + projectSlug: string, + options: ResolveProjectBySlugOptions +): Promise { + const { usageHint, contextValue } = options; + + const found = await findProjectsBySlug(projectSlug); + + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, usageHint, [ + "Check that you have access to a project with this slug", + ]); + } + + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + const contextSuffix = contextValue ? ` ${contextValue}` : ""; + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}\n\n` + + `Example: sentry /${projectSlug}${contextSuffix}` + ); + } + + // Safe assertion: length is exactly 1 after the checks above + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + orgDisplay: foundProject.orgSlug, + projectDisplay: foundProject.slug, + }; +} diff --git a/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index dee1d8e4..e7c9eaf9 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -121,7 +121,7 @@ export function buildProfileUrl( transactionName: string ): string { const encodedTransaction = encodeURIComponent(transactionName); - return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/profile/${projectSlug}/flamegraph/?query=transaction%3A${encodedTransaction}`; + return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/profile/${projectSlug}/flamegraph/?query=transaction%3A%22${encodedTransaction}%22`; } /** diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts index c8813750..3b3dad14 100644 --- a/src/lib/transaction-alias.ts +++ b/src/lib/transaction-alias.ts @@ -82,22 +82,31 @@ type TransactionInput = { * Disambiguate duplicate segments by appending numeric suffixes. * e.g., ["issues", "events", "issues"] → ["issues", "events", "issues2"] * + * Handles edge case where a suffixed name collides with an existing raw segment: + * e.g., ["issues", "issues2", "issues"] → ["issues", "issues2", "issues3"] + * * @param segments - Array of extracted segments (may contain duplicates) * @returns Array of unique segments with numeric suffixes for duplicates */ function disambiguateSegments(segments: string[]): string[] { - const seen = new Map(); const result: string[] = []; + const resultSet = new Set(); for (const segment of segments) { - const count = seen.get(segment) ?? 0; - seen.set(segment, count + 1); - - if (count === 0) { - result.push(segment); + if (resultSet.has(segment)) { + // Need a suffixed version - find next available + let suffix = 2; + let candidate = `${segment}${suffix}`; + while (resultSet.has(candidate)) { + suffix += 1; + candidate = `${segment}${suffix}`; + } + result.push(candidate); + resultSet.add(candidate); } else { - // Append numeric suffix for duplicates (issues2, issues3, etc.) - result.push(`${segment}${count + 1}`); + // Raw segment name is available + result.push(segment); + resultSet.add(segment); } } diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 2ce6a362..89f620aa 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/event/view.js"; +import { parsePositionalArgs } from "../../../src/commands/event/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (event ID only)", () => { @@ -93,9 +91,11 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { let findProjectsBySlugSpy: ReturnType; + const USAGE_HINT = "sentry event view / "; + beforeEach(() => { findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); @@ -109,7 +109,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); await expect( - resolveFromProjectSearch("my-project", "event-123") + resolveProjectBySlug("my-project", { + usageHint: USAGE_HINT, + contextValue: "event-123", + }) ).rejects.toThrow(ContextError); }); @@ -117,7 +120,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "event-123"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "event-123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -137,7 +143,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); await expect( - resolveFromProjectSearch("frontend", "event-123") + resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "event-123", + }) ).rejects.toThrow(ValidationError); }); @@ -148,7 +157,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "event-456"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "event-456", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -156,7 +168,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("event-456"); // Event ID in example + expect(message).toContain("event-456"); // Context value in example } }); @@ -168,14 +180,16 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug("api", { + usageHint: USAGE_HINT, + contextValue: "abc123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); const message = (error as ValidationError).message; - expect(message).toContain( - "Example: sentry event view /api abc123" - ); + // The shared function uses a generic example format + expect(message).toContain("Example: sentry /api abc123"); } }); }); @@ -186,7 +200,10 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "event-xyz"); + const result = await resolveProjectBySlug("backend", { + usageHint: USAGE_HINT, + contextValue: "event-xyz", + }); expect(result).toEqual({ org: "my-company", @@ -206,7 +223,10 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "evt-001"); + const result = await resolveProjectBySlug("mobile-app", { + usageHint: USAGE_HINT, + contextValue: "evt-001", + }); expect(result.org).toBe("acme-industries"); expect(result.orgDisplay).toBe("acme-industries"); @@ -217,7 +237,10 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "e123"); + const result = await resolveProjectBySlug("web-frontend", { + usageHint: USAGE_HINT, + contextValue: "e123", + }); expect(result.project).toBe("web-frontend"); expect(result.projectDisplay).toBe("web-frontend"); diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index 92d4ac4d..de47498e 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/log/view.js"; +import { parsePositionalArgs } from "../../../src/commands/log/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (log ID only)", () => { @@ -91,9 +89,11 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug (log context)", () => { let findProjectsBySlugSpy: ReturnType; + const USAGE_HINT = "sentry log view / "; + beforeEach(() => { findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); @@ -107,7 +107,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); await expect( - resolveFromProjectSearch("my-project", "log-123") + resolveProjectBySlug("my-project", { + usageHint: USAGE_HINT, + contextValue: "log-123", + }) ).rejects.toThrow(ContextError); }); @@ -115,7 +118,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "log-123"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "log-123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -135,7 +141,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); await expect( - resolveFromProjectSearch("frontend", "log-123") + resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "log-123", + }) ).rejects.toThrow(ValidationError); }); @@ -146,7 +155,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "log-456"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "log-456", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -154,7 +166,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("log-456"); // Log ID in example + expect(message).toContain("log-456"); // Context value in example } }); @@ -166,12 +178,16 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug("api", { + usageHint: USAGE_HINT, + contextValue: "abc123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); const message = (error as ValidationError).message; - expect(message).toContain("Example: sentry log view /api abc123"); + // The shared function uses a generic example format + expect(message).toContain("Example: sentry /api abc123"); } }); }); @@ -182,12 +198,16 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "log-xyz"); - - expect(result).toEqual({ - org: "my-company", - project: "backend", + const result = await resolveProjectBySlug("backend", { + usageHint: USAGE_HINT, + contextValue: "log-xyz", }); + + // resolveProjectBySlug returns full ResolvedTarget + expect(result.org).toBe("my-company"); + expect(result.project).toBe("backend"); + expect(result.orgDisplay).toBe("my-company"); + expect(result.projectDisplay).toBe("backend"); }); test("uses orgSlug from project result", async () => { @@ -200,7 +220,10 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "log-001"); + const result = await resolveProjectBySlug("mobile-app", { + usageHint: USAGE_HINT, + contextValue: "log-001", + }); expect(result.org).toBe("acme-industries"); }); @@ -210,7 +233,10 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "log123"); + const result = await resolveProjectBySlug("web-frontend", { + usageHint: USAGE_HINT, + contextValue: "log123", + }); expect(result.project).toBe("web-frontend"); }); diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts index d7f36417..117e815b 100644 --- a/test/commands/profile/list.test.ts +++ b/test/commands/profile/list.test.ts @@ -15,6 +15,7 @@ import { test, } from "bun:test"; import { listCommand } from "../../../src/commands/profile/list.js"; +import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking @@ -57,6 +58,7 @@ const defaultFlags = { let resolveOrgAndProjectSpy: ReturnType; let getProjectSpy: ReturnType; let listProfiledTransactionsSpy: ReturnType; +let findProjectsBySlugSpy: ReturnType; let openInBrowserSpy: ReturnType; let setTransactionAliasesSpy: ReturnType; @@ -64,6 +66,7 @@ beforeEach(() => { resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); getProjectSpy = spyOn(apiClient, "getProject"); listProfiledTransactionsSpy = spyOn(apiClient, "listProfiledTransactions"); + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); openInBrowserSpy = spyOn(browser, "openInBrowser"); setTransactionAliasesSpy = spyOn( transactionAliasesDb, @@ -75,6 +78,7 @@ afterEach(() => { resolveOrgAndProjectSpy.mockRestore(); getProjectSpy.mockRestore(); listProfiledTransactionsSpy.mockRestore(); + findProjectsBySlugSpy.mockRestore(); openInBrowserSpy.mockRestore(); setTransactionAliasesSpy.mockRestore(); }); @@ -152,16 +156,51 @@ describe("listCommand.func", () => { ); }); - test("resolves project-only target", async () => { + test("resolves project-only target via findProjectsBySlug", async () => { const ctx = createMockContext(); - setupResolvedTarget(); + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "backend", + id: "42", + name: "Backend", + orgSlug: "my-org", + }, + ] as ProjectWithOrg[]); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); const func = await loadListFunc(); await func.call(ctx, defaultFlags, "backend"); - expect(resolveOrgAndProjectSpy).toHaveBeenCalledWith( - expect.objectContaining({ project: "backend" }) + expect(findProjectsBySlugSpy).toHaveBeenCalledWith("backend"); + // Should NOT call resolveOrgAndProject for project-search + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + }); + + test("throws ContextError when project-only search finds nothing", async () => { + const ctx = createMockContext(); + findProjectsBySlugSpy.mockResolvedValue([]); + const func = await loadListFunc(); + + await expect(func.call(ctx, defaultFlags, "nonexistent")).rejects.toThrow( + ContextError + ); + }); + + test("throws ContextError when project-only search finds multiple orgs", async () => { + const ctx = createMockContext(); + findProjectsBySlugSpy.mockResolvedValue([ + { slug: "backend", id: "1", name: "Backend", orgSlug: "org-a" }, + { slug: "backend", id: "2", name: "Backend", orgSlug: "org-b" }, + ] as ProjectWithOrg[]); + const func = await loadListFunc(); + + await expect(func.call(ctx, defaultFlags, "backend")).rejects.toThrow( + ContextError ); }); diff --git a/test/commands/profile/view.test.ts b/test/commands/profile/view.test.ts index bfa46c5f..4dcdbf27 100644 --- a/test/commands/profile/view.test.ts +++ b/test/commands/profile/view.test.ts @@ -16,7 +16,6 @@ import { } from "bun:test"; import { parsePositionalArgs, - resolveFromProjectSearch, viewCommand, } from "../../../src/commands/profile/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; @@ -24,9 +23,10 @@ import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; import * as apiClient from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as browser from "../../../src/lib/browser.js"; -import { ContextError } from "../../../src/lib/errors.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTransactionMod from "../../../src/lib/resolve-transaction.js"; import type { Flamegraph } from "../../../src/types/index.js"; @@ -125,11 +125,13 @@ describe("parsePositionalArgs", () => { }); }); -// resolveFromProjectSearch tests +// resolveProjectBySlug tests (profile context) -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug (profile context)", () => { let findProjectsBySlugSpy: ReturnType; + const USAGE_HINT = "sentry profile view / "; + beforeEach(() => { findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); @@ -143,7 +145,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); await expect( - resolveFromProjectSearch("my-project", "/api/users") + resolveProjectBySlug("my-project", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }) ).rejects.toThrow(ContextError); }); @@ -151,7 +156,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "/api/users"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -161,25 +169,28 @@ describe("resolveFromProjectSearch", () => { }); describe("multiple projects found", () => { - test("throws ContextError when project exists in multiple orgs", async () => { + test("throws ValidationError when project exists in multiple orgs", async () => { findProjectsBySlugSpy.mockResolvedValue([ { slug: "frontend", id: "1", name: "Frontend", - organization: { id: "10", slug: "org-a", name: "Org A" }, + orgSlug: "org-a", }, { slug: "frontend", id: "2", name: "Frontend", - organization: { id: "20", slug: "org-b", name: "Org B" }, + orgSlug: "org-b", }, ] as ProjectWithOrg[]); await expect( - resolveFromProjectSearch("frontend", "/api/users") - ).rejects.toThrow(ContextError); + resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }) + ).rejects.toThrow(ValidationError); }); test("includes org alternatives in error", async () => { @@ -188,57 +199,50 @@ describe("resolveFromProjectSearch", () => { slug: "api", id: "1", name: "API", - organization: { id: "10", slug: "acme", name: "Acme" }, + orgSlug: "acme", }, { slug: "api", id: "2", name: "API", - organization: { id: "20", slug: "beta", name: "Beta" }, + orgSlug: "beta", }, ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "/api/users"); + await resolveProjectBySlug("api", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }); expect.unreachable("Should have thrown"); } catch (error) { - expect(error).toBeInstanceOf(ContextError); - const msg = (error as ContextError).message; + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; expect(msg).toContain("multiple organizations"); } }); }); describe("single project found", () => { - test("returns resolved target", async () => { + test("returns resolved target using orgSlug", async () => { findProjectsBySlugSpy.mockResolvedValue([ { slug: "backend", id: "42", name: "Backend", - organization: { id: "10", slug: "my-company", name: "My Company" }, + orgSlug: "my-company", }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "/api/users"); + const result = await resolveProjectBySlug("backend", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }); expect(result.org).toBe("my-company"); expect(result.project).toBe("backend"); - }); - - test("throws ContextError when project has no organization slug", async () => { - findProjectsBySlugSpy.mockResolvedValue([ - { - slug: "backend", - id: "42", - name: "Backend", - // organization without slug - }, - ] as ProjectWithOrg[]); - - await expect( - resolveFromProjectSearch("backend", "/api/users") - ).rejects.toThrow(ContextError); + expect(result.orgDisplay).toBe("my-company"); + expect(result.projectDisplay).toBe("backend"); }); }); }); diff --git a/test/lib/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index 3f2d3f1a..528e9ae7 100644 --- a/test/lib/sentry-urls.property.test.ts +++ b/test/lib/sentry-urls.property.test.ts @@ -513,14 +513,17 @@ describe("buildProfileUrl properties", () => { ); }); - test("output contains flamegraph and transaction query", async () => { + test("output contains flamegraph and quoted transaction query", async () => { await fcAssert( property( tuple(slugArb, slugArb, transactionNameArb), ([orgSlug, projectSlug, transaction]) => { const result = buildProfileUrl(orgSlug, projectSlug, transaction); expect(result).toContain("/flamegraph/"); - expect(result).toContain("query=transaction"); + // Transaction should be wrapped in encoded quotes (%22) for Sentry search syntax + expect(result).toContain( + `query=transaction%3A%22${encodeURIComponent(transaction)}%22` + ); } ), { numRuns: DEFAULT_NUM_RUNS } diff --git a/test/lib/transaction-alias.property.test.ts b/test/lib/transaction-alias.property.test.ts index a65b0c46..2f593390 100644 --- a/test/lib/transaction-alias.property.test.ts +++ b/test/lib/transaction-alias.property.test.ts @@ -290,6 +290,65 @@ describe("property: buildTransactionAliases", () => { }); }); +// Edge cases for disambiguateSegments (internal function tested via buildTransactionAliases) + +describe("disambiguateSegments collision handling", () => { + test("handles suffixed name colliding with raw segment", () => { + // ["issues", "issues2", "issues"] would produce collision if not handled: + // - "issues" → "issues" + // - "issues2" → "issues2" (raw) + // - "issues" (2nd) → would try "issues2" but it's taken → should use "issues3" + const inputs = [ + { transaction: "/api/issues/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/api/issues2/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/v2/issues/", orgSlug: "org", projectSlug: "proj" }, + ]; + + const aliases = buildTransactionAliases(inputs); + const aliasValues = aliases.map((a) => a.alias); + const uniqueAliases = new Set(aliasValues); + + // All aliases must be unique + expect(uniqueAliases.size).toBe(aliasValues.length); + }); + + test("handles multiple collision levels", () => { + // Multiple segments that would collide: issues, issues2, issues3, issues + const inputs = [ + { transaction: "/a/issues/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/b/issues2/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/c/issues3/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/d/issues/", orgSlug: "org", projectSlug: "proj" }, + ]; + + const aliases = buildTransactionAliases(inputs); + const aliasValues = aliases.map((a) => a.alias); + const uniqueAliases = new Set(aliasValues); + + // All aliases must be unique + expect(uniqueAliases.size).toBe(aliasValues.length); + }); + + test("property: disambiguated segments are always unique", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 15 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + // The internal disambiguateSegments should produce unique segments + // which means aliases should be unique (by the uniqueness of prefixes) + // Note: aliases could still collide if two different segments share a prefix, + // but the segments themselves should all be unique + const aliasSet = new Set(aliases.map((a) => a.alias)); + // With unique segments, findShortestUniquePrefixes guarantees unique aliases + expect(aliasSet.size).toBe(aliases.length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + // Edge cases for extractTransactionSegment describe("extractTransactionSegment edge cases", () => { From 44f682e37ff8d78e3e28638d67132da66b98300d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 16:25:44 +0000 Subject: [PATCH 16/32] fix: deduplicate resolveListTarget and rename formatDuration to formatDurationMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile list: delegate project-search to shared resolveProjectBySlug() instead of duplicating findProjectsBySlug + error handling logic - Rename formatDuration → formatDurationMs in analyzer.ts to avoid name collision with formatDuration in formatters/human.ts (different input units) - Update all call sites and tests accordingly --- src/commands/profile/list.ts | 35 +++++++------------------- src/lib/formatters/profile.ts | 10 ++++---- src/lib/profile/analyzer.ts | 7 ++++-- test/commands/profile/list.test.ts | 6 ++--- test/lib/profile/analyzer.test.ts | 40 +++++++++++++++--------------- 5 files changed, 42 insertions(+), 56 deletions(-) diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index e05f56d7..dbfbecd5 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -7,11 +7,7 @@ import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { - findProjectsBySlug, - getProject, - listProfiledTransactions, -} from "../../lib/api-client.js"; +import { getProject, listProfiledTransactions } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { @@ -27,7 +23,10 @@ import { formatProfileListTableHeader, writeJson, } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildProfilingSummaryUrl } from "../../lib/sentry-urls.js"; import { buildTransactionAliases } from "../../lib/transaction-alias.js"; import type { TransactionAliasEntry, Writer } from "../../types/index.js"; @@ -82,26 +81,10 @@ async function resolveListTarget( return resolved; } - case "project-search": { - const matches = await findProjectsBySlug(parsed.projectSlug); - if (matches.length === 0) { - throw new ContextError(`Project "${parsed.projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (matches.length > 1) { - const alternatives = matches.map( - (m) => `sentry profile list ${m.orgSlug}/${m.slug}` - ); - throw new ContextError( - `Project "${parsed.projectSlug}" exists in multiple organizations`, - `sentry profile list /${parsed.projectSlug}`, - alternatives - ); - } - const match = matches[0] as (typeof matches)[0]; - return { org: match.orgSlug, project: match.slug }; - } + case "project-search": + return await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + }); case "auto-detect": { const resolved = await resolveOrgAndProject({ diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index 3a25022a..75300a1a 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -10,7 +10,7 @@ import type { ProfileFunctionRow, TransactionAliasEntry, } from "../../types/index.js"; -import { formatDuration } from "../profile/analyzer.js"; +import { formatDurationMs } from "../profile/analyzer.js"; import { bold, muted, yellow } from "./colors.js"; /** Minimum width for header separator line */ @@ -46,9 +46,9 @@ function formatPercentiles(analysis: ProfileAnalysis): string[] { lines.push(""); lines.push(bold("Performance Percentiles")); lines.push( - ` p75: ${formatDuration(percentiles.p75)} ` + - `p95: ${formatDuration(percentiles.p95)} ` + - `p99: ${formatDuration(percentiles.p99)}` + ` p75: ${formatDurationMs(percentiles.p75)} ` + + `p95: ${formatDurationMs(percentiles.p95)} ` + + `p99: ${formatDurationMs(percentiles.p99)}` ); return lines; @@ -208,7 +208,7 @@ export function formatProfileListRow( ): string { const count = `${row["count()"] ?? 0}`.padStart(10); const p75Ms = row["p75(function.duration)"] - ? formatDuration(row["p75(function.duration)"] / 1_000_000) // ns to ms + ? formatDurationMs(row["p75(function.duration)"] / 1_000_000) // ns to ms : "-"; const p75 = p75Ms.padStart(10); diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts index 381619b5..294a73a9 100644 --- a/src/lib/profile/analyzer.ts +++ b/src/lib/profile/analyzer.ts @@ -24,10 +24,13 @@ export function nsToMs(ns: number): number { } /** - * Format duration in milliseconds to a human-readable string. + * Format duration in milliseconds to a compact human-readable string. * Shows appropriate precision based on magnitude. + * + * Named `formatDurationMs` to distinguish from `formatDuration` in + * `formatters/human.ts` which takes seconds and returns verbose strings. */ -export function formatDuration(ms: number): string { +export function formatDurationMs(ms: number): string { if (ms >= 1000) { return `${(ms / 1000).toFixed(1)}s`; } diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts index 117e815b..5c5bcede 100644 --- a/test/commands/profile/list.test.ts +++ b/test/commands/profile/list.test.ts @@ -22,7 +22,7 @@ import * as apiClient from "../../../src/lib/api-client.js"; import * as browser from "../../../src/lib/browser.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as transactionAliasesDb from "../../../src/lib/db/transaction-aliases.js"; -import { ContextError } from "../../../src/lib/errors.js"; +import { ContextError, ValidationError } from "../../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as resolveTarget from "../../../src/lib/resolve-target.js"; @@ -191,7 +191,7 @@ describe("listCommand.func", () => { ); }); - test("throws ContextError when project-only search finds multiple orgs", async () => { + test("throws ValidationError when project-only search finds multiple orgs", async () => { const ctx = createMockContext(); findProjectsBySlugSpy.mockResolvedValue([ { slug: "backend", id: "1", name: "Backend", orgSlug: "org-a" }, @@ -200,7 +200,7 @@ describe("listCommand.func", () => { const func = await loadListFunc(); await expect(func.call(ctx, defaultFlags, "backend")).rejects.toThrow( - ContextError + ValidationError ); }); diff --git a/test/lib/profile/analyzer.test.ts b/test/lib/profile/analyzer.test.ts index f2ffde43..15d5cfd3 100644 --- a/test/lib/profile/analyzer.test.ts +++ b/test/lib/profile/analyzer.test.ts @@ -11,7 +11,7 @@ import { analyzeFlamegraph, analyzeHotPaths, calculatePercentiles, - formatDuration, + formatDurationMs, hasProfileData, nsToMs, } from "../../../src/lib/profile/analyzer.js"; @@ -103,45 +103,45 @@ describe("nsToMs", () => { // formatDuration -describe("formatDuration", () => { +describe("formatDurationMs", () => { test("formats seconds for values >= 1000ms", () => { - expect(formatDuration(1000)).toBe("1.0s"); - expect(formatDuration(1500)).toBe("1.5s"); - expect(formatDuration(12_345)).toBe("12.3s"); + expect(formatDurationMs(1000)).toBe("1.0s"); + expect(formatDurationMs(1500)).toBe("1.5s"); + expect(formatDurationMs(12_345)).toBe("12.3s"); }); test("formats whole milliseconds for values >= 100ms", () => { - expect(formatDuration(100)).toBe("100ms"); - expect(formatDuration(999)).toBe("999ms"); - expect(formatDuration(500)).toBe("500ms"); + expect(formatDurationMs(100)).toBe("100ms"); + expect(formatDurationMs(999)).toBe("999ms"); + expect(formatDurationMs(500)).toBe("500ms"); }); test("formats 1 decimal place for values >= 10ms", () => { - expect(formatDuration(10)).toBe("10.0ms"); - expect(formatDuration(55.5)).toBe("55.5ms"); - expect(formatDuration(99.9)).toBe("99.9ms"); + expect(formatDurationMs(10)).toBe("10.0ms"); + expect(formatDurationMs(55.5)).toBe("55.5ms"); + expect(formatDurationMs(99.9)).toBe("99.9ms"); }); test("formats 2 decimal places for values >= 1ms", () => { - expect(formatDuration(1)).toBe("1.00ms"); - expect(formatDuration(5.55)).toBe("5.55ms"); - expect(formatDuration(9.99)).toBe("9.99ms"); + expect(formatDurationMs(1)).toBe("1.00ms"); + expect(formatDurationMs(5.55)).toBe("5.55ms"); + expect(formatDurationMs(9.99)).toBe("9.99ms"); }); test("formats microseconds for sub-millisecond values", () => { - expect(formatDuration(0.5)).toBe("500\u00B5s"); - expect(formatDuration(0.001)).toBe("1\u00B5s"); + expect(formatDurationMs(0.5)).toBe("500\u00B5s"); + expect(formatDurationMs(0.001)).toBe("1\u00B5s"); }); test("formats nanoseconds for sub-microsecond values", () => { - expect(formatDuration(0.0001)).toBe("100ns"); - expect(formatDuration(0.000_001)).toBe("1ns"); + expect(formatDurationMs(0.0001)).toBe("100ns"); + expect(formatDurationMs(0.000_001)).toBe("1ns"); }); test("property: output always contains a unit", () => { fcAssert( property(double({ min: 0.000_001, max: 100_000, noNaN: true }), (ms) => { - const result = formatDuration(ms); + const result = formatDurationMs(ms); const hasUnit = result.endsWith("s") || result.endsWith("ms") || @@ -156,7 +156,7 @@ describe("formatDuration", () => { test("property: output is non-empty for positive values", () => { fcAssert( property(double({ min: 0.000_001, max: 100_000, noNaN: true }), (ms) => { - expect(formatDuration(ms).length).toBeGreaterThan(0); + expect(formatDurationMs(ms).length).toBeGreaterThan(0); }), { numRuns: DEFAULT_NUM_RUNS } ); From c685648e0f88af32e17705d0f12846ad3b9d69e0 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 20:53:47 +0000 Subject: [PATCH 17/32] fix: resolve bare transaction names and formatDurationMs boundary rounding - Replace isFullTransactionName (checked for / or .) with isAliasLike which positively identifies alias-shaped input (single-case letters + optional digits). Bare names like process_request, handle-webhook, ProcessEvent now correctly pass through as full transaction names. - Fix formatDurationMs rounding at tier boundaries: 999.5ms now formats as '1.0s' instead of '1000ms', 99.95ms as '100ms' instead of '100.0ms'. - Add tests for both fixes. --- src/lib/profile/analyzer.ts | 14 +++- src/lib/resolve-transaction.ts | 96 ++++++++++++++++++---------- test/lib/profile/analyzer.test.ts | 12 ++++ test/lib/resolve-transaction.test.ts | 33 ++++++++++ 4 files changed, 121 insertions(+), 34 deletions(-) diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts index 294a73a9..789d0a05 100644 --- a/src/lib/profile/analyzer.ts +++ b/src/lib/profile/analyzer.ts @@ -35,10 +35,20 @@ export function formatDurationMs(ms: number): string { return `${(ms / 1000).toFixed(1)}s`; } if (ms >= 100) { - return `${Math.round(ms)}ms`; + const rounded = Math.round(ms); + // Rounding can push past the 1000ms boundary (e.g. 999.5 → 1000) + if (rounded >= 1000) { + return `${(ms / 1000).toFixed(1)}s`; + } + return `${rounded}ms`; } if (ms >= 10) { - return `${ms.toFixed(1)}ms`; + const formatted = ms.toFixed(1); + // toFixed(1) can push past 100ms boundary (e.g. 99.95 → "100.0") + if (Number.parseFloat(formatted) >= 100) { + return `${Math.round(ms)}ms`; + } + return `${formatted}ms`; } if (ms >= 1) { return `${ms.toFixed(2)}ms`; diff --git a/src/lib/resolve-transaction.ts b/src/lib/resolve-transaction.ts index bd49ff1a..5fed813b 100644 --- a/src/lib/resolve-transaction.ts +++ b/src/lib/resolve-transaction.ts @@ -38,11 +38,38 @@ export type ResolveTransactionOptions = { const NUMERIC_PATTERN = /^\d+$/; /** - * Check if input is a full transaction name (contains / or .). - * Full names are passed through without alias lookup. + * Maximum length for an alias generated by `buildTransactionAliases`. + * Aliases are shortest-unique-prefixes of normalized transaction segments, + * so they're always short lowercase strings. This generous upper bound + * prevents misclassifying bare transaction names (e.g. "process_request") + * as aliases. */ -function isFullTransactionName(input: string): boolean { - return input.includes("/") || input.includes("."); +const MAX_ALIAS_LENGTH = 20; + +/** + * Pattern matching alias-shaped input: purely ASCII letters in a single case + * (all lowercase or all uppercase for caps-lock tolerance), optionally + * followed by a numeric disambiguator (e.g. "issues2", "ISSUES2"). + * + * This mirrors how `buildTransactionAliases` generates aliases: + * segments are lowercased, stripped of hyphens/underscores, then + * `findShortestUniquePrefixes` produces a lowercase-letter prefix, + * and `disambiguateSegments` may append a numeric suffix. + * + * Mixed-case inputs like "ProcessEvent" are treated as full transaction + * names since aliases are always lowercase (or all-caps with caps lock). + */ +const ALIAS_PATTERN = /^(?:[a-z]+|[A-Z]+)\d*$/; + +/** + * Check if input looks like a cached alias rather than a full transaction name. + * + * Aliases are short, single-case letter strings (with optional numeric suffix). + * Anything containing special characters like `/`, `.`, `-`, `_`, spaces, + * colons, or mixed-case letters is treated as a full transaction name. + */ +function isAliasLike(input: string): boolean { + return input.length <= MAX_ALIAS_LENGTH && ALIAS_PATTERN.test(input); } /** @@ -118,10 +145,13 @@ function buildUnknownRefError( /** * Resolve a transaction reference to its full name. * - * Accepts: - * - Numeric index: "1", "2", "10" → looks up by cached index - * - Alias: "i", "e", "iu" → looks up by cached alias - * - Full transaction name: "/api/0/..." or "tasks.process" → passed through + * Resolution order: + * 1. Numeric index: "1", "2", "10" → looks up by cached index + * 2. Alias-shaped input (single-case letters + optional digits, ≤20 chars): + * "i", "e", "iu", "issues2", "I" (caps lock) → looks up by cached alias + * 3. Everything else is treated as a full transaction name and passed through: + * "/api/0/...", "tasks.process", "process_request", "handle-webhook", + * "ProcessEvent", "GET /users" * * @throws ConfigError if alias/index not found or stale */ @@ -129,22 +159,13 @@ export function resolveTransaction( input: string, options: ResolveTransactionOptions ): ResolvedTransaction { - // Full transaction names pass through directly - if (isFullTransactionName(input)) { - return { - transaction: input, - orgSlug: options.org, - projectSlug: options.project ?? "", - }; - } - + // Numeric input → look up by index (checked first since "123" is unambiguous) const currentFingerprint = buildTransactionFingerprint( options.org, options.project, options.period ); - // Numeric input → look up by index if (NUMERIC_PATTERN.test(input)) { const idx = Number.parseInt(input, 10); const entry = getTransactionByIndex(idx, currentFingerprint); @@ -166,22 +187,33 @@ export function resolveTransaction( throw buildUnknownRefError(input, options); } - // Non-numeric input → look up by alias - const entry = getTransactionByAlias(input, currentFingerprint); + // Alias-shaped input → look up by alias + if (isAliasLike(input)) { + const entry = getTransactionByAlias(input, currentFingerprint); - if (entry) { - return { - transaction: entry.transaction, - orgSlug: entry.orgSlug, - projectSlug: entry.projectSlug, - }; - } + if (entry) { + return { + transaction: entry.transaction, + orgSlug: entry.orgSlug, + projectSlug: entry.projectSlug, + }; + } - // Check if there's a stale entry for this alias - const staleFingerprint = getStaleFingerprint(input); - if (staleFingerprint) { - throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + // Check if there's a stale entry for this alias + const staleFingerprint = getStaleFingerprint(input); + if (staleFingerprint) { + throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + } + + throw buildUnknownRefError(input, options); } - throw buildUnknownRefError(input, options); + // Everything else is a full transaction name — pass through directly. + // This handles URL paths ("/api/users"), dotted names ("tasks.process"), + // and bare names with special chars ("process_request", "handle-webhook"). + return { + transaction: input, + orgSlug: options.org, + projectSlug: options.project ?? "", + }; } diff --git a/test/lib/profile/analyzer.test.ts b/test/lib/profile/analyzer.test.ts index 15d5cfd3..395ad3fb 100644 --- a/test/lib/profile/analyzer.test.ts +++ b/test/lib/profile/analyzer.test.ts @@ -138,6 +138,18 @@ describe("formatDurationMs", () => { expect(formatDurationMs(0.000_001)).toBe("1ns"); }); + test("handles boundary rounding: 999.5ms promotes to seconds", () => { + // Math.round(999.5) = 1000, which should display as "1.0s" not "1000ms" + expect(formatDurationMs(999.5)).toBe("1.0s"); + expect(formatDurationMs(999.9)).toBe("1.0s"); + }); + + test("handles boundary rounding: 99.95ms promotes to whole ms", () => { + // (99.95).toFixed(1) = "100.0", which should display as "100ms" not "100.0ms" + expect(formatDurationMs(99.95)).toBe("100ms"); + expect(formatDurationMs(99.99)).toBe("100ms"); + }); + test("property: output always contains a unit", () => { fcAssert( property(double({ min: 0.000_001, max: 100_000, noNaN: true }), (ms) => { diff --git a/test/lib/resolve-transaction.test.ts b/test/lib/resolve-transaction.test.ts index 95d84a12..492a08fd 100644 --- a/test/lib/resolve-transaction.test.ts +++ b/test/lib/resolve-transaction.test.ts @@ -96,6 +96,39 @@ describe("full transaction name pass-through", () => { expect(result.projectSlug).toBe(""); }); + + test("underscored bare names pass through unchanged", () => { + const result = resolveTransaction("process_request", defaultOptions); + expect(result.transaction).toBe("process_request"); + }); + + test("hyphenated bare names pass through unchanged", () => { + const result = resolveTransaction("handle-webhook", defaultOptions); + expect(result.transaction).toBe("handle-webhook"); + }); + + test("uppercase bare names pass through unchanged", () => { + const result = resolveTransaction("GET /users", defaultOptions); + expect(result.transaction).toBe("GET /users"); + }); + + test("mixed-case bare names pass through unchanged", () => { + const result = resolveTransaction("ProcessEvent", defaultOptions); + expect(result.transaction).toBe("ProcessEvent"); + }); + + test("names with spaces pass through unchanged", () => { + const result = resolveTransaction( + "send email notification", + defaultOptions + ); + expect(result.transaction).toBe("send email notification"); + }); + + test("names with colons pass through unchanged", () => { + const result = resolveTransaction("worker:process_job", defaultOptions); + expect(result.transaction).toBe("worker:process_job"); + }); }); // ============================================================================= From ab7d70764d459f8019858b30682afbb0d63269ae Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 21:03:19 +0000 Subject: [PATCH 18/32] fix: exclude current fingerprint in stale alias detection queries getStaleFingerprint and getStaleIndexFingerprint now accept the current fingerprint and use 'AND fingerprint != ?' to exclude it from results. Previously the queries could return the current context's own fingerprint, leading to incorrect stale-alias error messages. --- src/lib/db/transaction-aliases.ts | 34 +++++++++++---- src/lib/resolve-transaction.ts | 8 ++-- test/lib/db/transaction-aliases.test.ts | 56 ++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/lib/db/transaction-aliases.ts b/src/lib/db/transaction-aliases.ts index c5a35b85..fd590337 100644 --- a/src/lib/db/transaction-aliases.ts +++ b/src/lib/db/transaction-aliases.ts @@ -155,30 +155,48 @@ export function getTransactionAliases( /** * Check if an alias exists for a different fingerprint (stale check). - * Returns the stale fingerprint if found, null otherwise. + * Excludes the current fingerprint so we only find entries from other contexts. + * + * @param alias - The alias to look up + * @param currentFingerprint - The fingerprint to exclude from results + * @returns The stale fingerprint if found, null otherwise */ -export function getStaleFingerprint(alias: string): string | null { +export function getStaleFingerprint( + alias: string, + currentFingerprint: string +): string | null { const db = getDatabase(); const row = db .query( - "SELECT fingerprint FROM transaction_aliases WHERE alias = ? LIMIT 1" + "SELECT fingerprint FROM transaction_aliases WHERE alias = ? AND fingerprint != ? LIMIT 1" ) - .get(alias.toLowerCase()) as { fingerprint: string } | undefined; + .get(alias.toLowerCase(), currentFingerprint) as + | { fingerprint: string } + | undefined; return row?.fingerprint ?? null; } /** * Check if an index exists for a different fingerprint (stale check). - * Returns the stale fingerprint if found, null otherwise. + * Excludes the current fingerprint so we only find entries from other contexts. + * + * @param idx - The numeric index to look up + * @param currentFingerprint - The fingerprint to exclude from results + * @returns The stale fingerprint if found, null otherwise */ -export function getStaleIndexFingerprint(idx: number): string | null { +export function getStaleIndexFingerprint( + idx: number, + currentFingerprint: string +): string | null { const db = getDatabase(); const row = db - .query("SELECT fingerprint FROM transaction_aliases WHERE idx = ? LIMIT 1") - .get(idx) as { fingerprint: string } | undefined; + .query( + "SELECT fingerprint FROM transaction_aliases WHERE idx = ? AND fingerprint != ? LIMIT 1" + ) + .get(idx, currentFingerprint) as { fingerprint: string } | undefined; return row?.fingerprint ?? null; } diff --git a/src/lib/resolve-transaction.ts b/src/lib/resolve-transaction.ts index 5fed813b..453ad38d 100644 --- a/src/lib/resolve-transaction.ts +++ b/src/lib/resolve-transaction.ts @@ -178,8 +178,8 @@ export function resolveTransaction( }; } - // Check if there's a stale entry for this index - const staleFingerprint = getStaleIndexFingerprint(idx); + // Check if there's a stale entry for this index in a different context + const staleFingerprint = getStaleIndexFingerprint(idx, currentFingerprint); if (staleFingerprint) { throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); } @@ -199,8 +199,8 @@ export function resolveTransaction( }; } - // Check if there's a stale entry for this alias - const staleFingerprint = getStaleFingerprint(input); + // Check if there's a stale entry for this alias in a different context + const staleFingerprint = getStaleFingerprint(input, currentFingerprint); if (staleFingerprint) { throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); } diff --git a/test/lib/db/transaction-aliases.test.ts b/test/lib/db/transaction-aliases.test.ts index 58e1b2c2..9b9e6888 100644 --- a/test/lib/db/transaction-aliases.test.ts +++ b/test/lib/db/transaction-aliases.test.ts @@ -241,8 +241,9 @@ describe("getTransactionByAlias", () => { // ============================================================================= describe("stale detection", () => { - test("getStaleFingerprint returns fingerprint when alias exists elsewhere", () => { + test("getStaleFingerprint returns fingerprint when alias exists in different context", () => { const oldFp = "old-org:old-project:7d"; + const currentFp = "new-org:new-project:24h"; setTransactionAliases( [ { @@ -256,17 +257,39 @@ describe("stale detection", () => { oldFp ); - const stale = getStaleFingerprint("issues"); + const stale = getStaleFingerprint("issues", currentFp); expect(stale).toBe(oldFp); }); + test("getStaleFingerprint excludes current fingerprint", () => { + clearTransactionAliases(); + const fp = "my-org:my-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "my-org", + projectSlug: "my-project", + }, + ], + fp + ); + + // Searching with the same fingerprint should return null (not stale) + const stale = getStaleFingerprint("issues", fp); + expect(stale).toBeNull(); + }); + test("getStaleFingerprint returns null when alias doesn't exist", () => { - const stale = getStaleFingerprint("nonexistent"); + const stale = getStaleFingerprint("nonexistent", "any:fp:here"); expect(stale).toBeNull(); }); - test("getStaleIndexFingerprint returns fingerprint when index exists elsewhere", () => { + test("getStaleIndexFingerprint returns fingerprint when index exists in different context", () => { const oldFp = "old-org:old-project:7d"; + const currentFp = "new-org:new-project:24h"; setTransactionAliases( [ { @@ -280,12 +303,33 @@ describe("stale detection", () => { oldFp ); - const stale = getStaleIndexFingerprint(5); + const stale = getStaleIndexFingerprint(5, currentFp); expect(stale).toBe(oldFp); }); + test("getStaleIndexFingerprint excludes current fingerprint", () => { + clearTransactionAliases(); + const fp = "my-org:my-project:7d"; + setTransactionAliases( + [ + { + idx: 5, + alias: "test", + transaction: "/api/test/", + orgSlug: "my-org", + projectSlug: "my-project", + }, + ], + fp + ); + + // Searching with the same fingerprint should return null (not stale) + const stale = getStaleIndexFingerprint(5, fp); + expect(stale).toBeNull(); + }); + test("getStaleIndexFingerprint returns null when index doesn't exist", () => { - const stale = getStaleIndexFingerprint(999); + const stale = getStaleIndexFingerprint(999, "any:fp:here"); expect(stale).toBeNull(); }); }); From c83e27b3d4ef16e2327bd74f63a97facdecfa2d4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 21:52:31 +0000 Subject: [PATCH 19/32] fix: guard against empty aliases from hyphen/underscore-only transaction segments --- src/lib/transaction-alias.ts | 9 +++++++-- test/lib/transaction-alias.property.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts index 3b3dad14..e278c3e1 100644 --- a/src/lib/transaction-alias.ts +++ b/src/lib/transaction-alias.ts @@ -57,7 +57,11 @@ export function extractTransactionSegment(transaction: string): string { } // Normalize: remove hyphens/underscores, lowercase - return segment.replace(/[-_]/g, "").toLowerCase(); + const normalized = segment.replace(/[-_]/g, "").toLowerCase(); + if (normalized.length > 0) { + return normalized; + } + // Segment was entirely hyphens/underscores (e.g. "---"), skip it } // Fallback: use first non-empty, non-numeric segment if no meaningful one found @@ -65,7 +69,8 @@ export function extractTransactionSegment(transaction: string): string { (s) => s.length > 0 && !NUMERIC_PATTERN.test(s) && !PLACEHOLDER_PATTERN.test(s) ); - return firstSegment?.replace(/[-_]/g, "").toLowerCase() ?? "txn"; + const fallback = firstSegment?.replace(/[-_]/g, "").toLowerCase(); + return fallback && fallback.length > 0 ? fallback : "txn"; } /** Input for alias generation */ diff --git a/test/lib/transaction-alias.property.test.ts b/test/lib/transaction-alias.property.test.ts index 2f593390..8c827844 100644 --- a/test/lib/transaction-alias.property.test.ts +++ b/test/lib/transaction-alias.property.test.ts @@ -375,6 +375,22 @@ describe("extractTransactionSegment edge cases", () => { test("handles single dot", () => { expect(extractTransactionSegment(".")).toBe("txn"); }); + + test("returns 'txn' fallback for hyphen-only segment", () => { + expect(extractTransactionSegment("/-/")).toBe("txn"); + }); + + test("returns 'txn' fallback for underscore-only segment", () => { + expect(extractTransactionSegment("/_/")).toBe("txn"); + }); + + test("returns 'txn' fallback for mixed hyphens and underscores", () => { + expect(extractTransactionSegment("/-_--__-/")).toBe("txn"); + }); + + test("skips hyphen-only segments and finds next meaningful one", () => { + expect(extractTransactionSegment("/---/users/")).toBe("users"); + }); }); // Integration properties From 9ce148d2233ccf560fb2b6ab01f298e27038c6f3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 22:26:00 +0000 Subject: [PATCH 20/32] fix: prefix-based disambiguation to avoid alias overlap, and null-safe p75 check --- src/lib/formatters/profile.ts | 8 +++-- src/lib/transaction-alias.ts | 30 ++++++++++------- test/lib/transaction-alias.property.test.ts | 36 +++++++++++++++------ 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index 75300a1a..80604d5b 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -207,9 +207,11 @@ export function formatProfileListRow( alias?: TransactionAliasEntry ): string { const count = `${row["count()"] ?? 0}`.padStart(10); - const p75Ms = row["p75(function.duration)"] - ? formatDurationMs(row["p75(function.duration)"] / 1_000_000) // ns to ms - : "-"; + const rawP75 = row["p75(function.duration)"]; + const p75Ms = + rawP75 !== null && rawP75 !== undefined + ? formatDurationMs(rawP75 / 1_000_000) // ns to ms + : "-"; const p75 = p75Ms.padStart(10); if (alias) { diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts index e278c3e1..22f621b4 100644 --- a/src/lib/transaction-alias.ts +++ b/src/lib/transaction-alias.ts @@ -84,14 +84,20 @@ type TransactionInput = { }; /** - * Disambiguate duplicate segments by appending numeric suffixes. - * e.g., ["issues", "events", "issues"] → ["issues", "events", "issues2"] + * Disambiguate duplicate segments by prepending "x" characters. + * e.g., ["issues", "events", "issues"] → ["issues", "events", "xissues"] * - * Handles edge case where a suffixed name collides with an existing raw segment: - * e.g., ["issues", "issues2", "issues"] → ["issues", "issues2", "issues3"] + * Uses a prefix ("x", "xx", "xxx", ...) instead of a numeric suffix to avoid + * creating prefix relationships between the original and disambiguated strings. + * A numeric suffix like "issues2" shares a prefix with "issues", which causes + * findShortestUniquePrefixes to degrade both to full-length strings. Prepending + * "x" breaks the prefix relationship, enabling short aliases (e.g., "i" and "x"). + * + * Handles edge case where a prefixed name collides with an existing raw segment: + * e.g., ["issues", "xissues", "issues"] → ["issues", "xissues", "xxissues"] * * @param segments - Array of extracted segments (may contain duplicates) - * @returns Array of unique segments with numeric suffixes for duplicates + * @returns Array of unique segments with "x" prefixes for duplicates */ function disambiguateSegments(segments: string[]): string[] { const result: string[] = []; @@ -99,12 +105,12 @@ function disambiguateSegments(segments: string[]): string[] { for (const segment of segments) { if (resultSet.has(segment)) { - // Need a suffixed version - find next available - let suffix = 2; - let candidate = `${segment}${suffix}`; + // Need a prefixed version - find next available + let prefix = "x"; + let candidate = `${prefix}${segment}`; while (resultSet.has(candidate)) { - suffix += 1; - candidate = `${segment}${suffix}`; + prefix += "x"; + candidate = `${prefix}${segment}`; } result.push(candidate); resultSet.add(candidate); @@ -137,14 +143,14 @@ function disambiguateSegments(segments: string[]): string[] { * // ] * * @example - * // Duplicate segments get numeric suffixes + * // Duplicate segments get "x" prefix for disambiguation * buildTransactionAliases([ * { transaction: "/api/v1/issues/", ... }, * { transaction: "/api/v2/issues/", ... }, * ]) * // => [ * // { idx: 1, alias: "i", ... }, // from "issues" - * // { idx: 2, alias: "is", ... }, // from "issues2" (disambiguated) + * // { idx: 2, alias: "x", ... }, // from "xissues" (disambiguated) * // ] */ export function buildTransactionAliases( diff --git a/test/lib/transaction-alias.property.test.ts b/test/lib/transaction-alias.property.test.ts index 8c827844..9d42e518 100644 --- a/test/lib/transaction-alias.property.test.ts +++ b/test/lib/transaction-alias.property.test.ts @@ -293,14 +293,30 @@ describe("property: buildTransactionAliases", () => { // Edge cases for disambiguateSegments (internal function tested via buildTransactionAliases) describe("disambiguateSegments collision handling", () => { - test("handles suffixed name colliding with raw segment", () => { - // ["issues", "issues2", "issues"] would produce collision if not handled: + test("duplicate segments produce short aliases (no prefix overlap)", () => { + // This is the core fix: before, ["issues", "issues2"] had a prefix relationship + // causing findShortestUniquePrefixes to return full strings. + // With "x" prefix: ["issues", "xissues"] have no prefix relationship. + const inputs = [ + { transaction: "/api/v1/issues/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/api/v2/issues/", orgSlug: "org", projectSlug: "proj" }, + ]; + + const aliases = buildTransactionAliases(inputs); + + // First "issues" → alias "i", disambiguated "xissues" → alias "x" + expect(aliases[0]?.alias).toBe("i"); + expect(aliases[1]?.alias).toBe("x"); + }); + + test("handles prefixed name colliding with raw segment", () => { + // ["issues", "xissues", "issues"] would produce collision if not handled: // - "issues" → "issues" - // - "issues2" → "issues2" (raw) - // - "issues" (2nd) → would try "issues2" but it's taken → should use "issues3" + // - "xissues" → "xissues" (raw) + // - "issues" (2nd) → would try "xissues" but it's taken → should use "xxissues" const inputs = [ { transaction: "/api/issues/", orgSlug: "org", projectSlug: "proj" }, - { transaction: "/api/issues2/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/api/xissues/", orgSlug: "org", projectSlug: "proj" }, { transaction: "/v2/issues/", orgSlug: "org", projectSlug: "proj" }, ]; @@ -313,11 +329,11 @@ describe("disambiguateSegments collision handling", () => { }); test("handles multiple collision levels", () => { - // Multiple segments that would collide: issues, issues2, issues3, issues + // Multiple segments that would collide: issues, xissues, xxissues, issues const inputs = [ { transaction: "/a/issues/", orgSlug: "org", projectSlug: "proj" }, - { transaction: "/b/issues2/", orgSlug: "org", projectSlug: "proj" }, - { transaction: "/c/issues3/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/b/xissues/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/c/xxissues/", orgSlug: "org", projectSlug: "proj" }, { transaction: "/d/issues/", orgSlug: "org", projectSlug: "proj" }, ]; @@ -398,8 +414,8 @@ describe("extractTransactionSegment edge cases", () => { describe("property: alias lookup invariants", () => { test("alias is a prefix of the extracted segment (unique transactions)", () => { // Use uniqueArray to avoid duplicate transactions, since disambiguateSegments - // appends numeric suffixes to duplicates which breaks the prefix relationship - // with the raw extracted segment. + // prepends "x" prefixes to duplicates which changes the string relative to + // the raw extracted segment. fcAssert( property( uniqueArray(transactionInputArb, { From 0d5c2b3ddac6893071716154990cefc6400a5f48 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 23:06:48 +0000 Subject: [PATCH 21/32] fix: use nullish() for ProfileFunctionRow fields to accept null from API --- src/types/profile.ts | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/types/profile.ts b/src/types/profile.ts index 8a7424d8..f0104918 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -152,14 +152,14 @@ export type Flamegraph = z.infer; */ export const ProfileFunctionRowSchema = z .object({ - /** Transaction name */ - transaction: z.string().optional(), + /** Transaction name (null when transaction data is missing) */ + transaction: z.string().nullish(), /** Number of profiles/samples */ - "count()": z.number().optional(), + "count()": z.number().nullish(), /** 75th percentile duration */ - "p75(function.duration)": z.number().optional(), + "p75(function.duration)": z.number().nullish(), /** 95th percentile duration */ - "p95(function.duration)": z.number().optional(), + "p95(function.duration)": z.number().nullish(), }) .passthrough(); @@ -168,15 +168,17 @@ export type ProfileFunctionRow = z.infer; /** * Response from the Explore Events API for profile_functions dataset. */ -export const ProfileFunctionsResponseSchema = z.object({ - data: z.array(ProfileFunctionRowSchema), - meta: z - .object({ - fields: z.record(z.string()).optional(), - }) - .passthrough() - .optional(), -}); +export const ProfileFunctionsResponseSchema = z + .object({ + data: z.array(ProfileFunctionRowSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); export type ProfileFunctionsResponse = z.infer< typeof ProfileFunctionsResponseSchema From b6154527e9b70b5c7cc7b066cf92fb2ee0e88614 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 11:54:40 +0000 Subject: [PATCH 22/32] fix(profile): remove unused orgDisplay/projectDisplay from ResolvedProfileTarget --- src/commands/profile/view.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 3003084a..4a9f2668 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -80,8 +80,6 @@ export function parsePositionalArgs(args: string[]): { type ResolvedProfileTarget = { org: string; project: string; - orgDisplay: string; - projectDisplay: string; detectedFrom?: string; }; @@ -159,22 +157,15 @@ export const viewCommand = buildCommand({ target = { org: parsed.org, project: parsed.project, - orgDisplay: parsed.org, - projectDisplay: parsed.project, }; break; case ProjectSpecificationType.ProjectSearch: { - const resolved = await resolveProjectBySlug( + target = await resolveProjectBySlug( parsed.projectSlug, USAGE_HINT, `sentry profile view /${parsed.projectSlug} ${transactionRef}` ); - target = { - ...resolved, - orgDisplay: resolved.org, - projectDisplay: resolved.project, - }; break; } From d45567ecefd87a397c54d51c28ecd60caf55164a Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 12:28:14 +0000 Subject: [PATCH 23/32] fix(profile): improve output formatting - Remove misleading PROFILES column (always 0 from profile_functions dataset) - Add p95 column alongside p75 for better performance context - Sort by p75 descending instead of count (which was always 0) - Smart transaction name truncation: strip common prefix, middle-truncate - Rename File:Line column to Location, widen to 30 chars with middle-truncation - Improve no-data error message to suggest running profile list - Add tests for truncateMiddle and findCommonPrefix utilities --- src/commands/profile/list.ts | 15 ++- src/commands/profile/view.ts | 9 +- src/lib/api-client.ts | 9 +- src/lib/formatters/profile.ts | 138 +++++++++++++++++++++++----- test/commands/profile/list.test.ts | 5 +- test/lib/formatters/profile.test.ts | 87 ++++++++++++++---- 6 files changed, 212 insertions(+), 51 deletions(-) diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index ec7135b8..9101ae21 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -17,10 +17,12 @@ import { import { ContextError } from "../../lib/errors.js"; import { divider, + findCommonPrefix, formatProfileListFooter, formatProfileListHeader, formatProfileListRow, formatProfileListTableHeader, + profileListDividerWidth, writeJson, } from "../../lib/formatters/index.js"; import { @@ -244,16 +246,23 @@ export const listCommand = buildCommand({ // Human-readable output with aliases const hasAliases = aliases.length > 0; + + // Compute common prefix for smarter transaction name display + const transactionNames = response.data + .map((r) => r.transaction) + .filter((t): t is string => t !== null && t !== undefined); + const commonPrefix = findCommonPrefix(transactionNames); + stdout.write(`${formatProfileListHeader(orgProject, flags.period)}\n\n`); stdout.write(`${formatProfileListTableHeader(hasAliases)}\n`); - stdout.write(`${divider(82)}\n`); + stdout.write(`${divider(profileListDividerWidth(hasAliases))}\n`); for (const row of response.data) { const alias = row.transaction ? aliasMap.get(row.transaction) : undefined; - stdout.write(`${formatProfileListRow(row, alias)}\n`); + stdout.write(`${formatProfileListRow(row, alias, commonPrefix)}\n`); } - stdout.write(formatProfileListFooter(hasAliases)); + stdout.write(formatProfileListFooter(hasAliases, commonPrefix)); if (resolvedTarget.detectedFrom) { stdout.write(`\n\nDetected from ${resolvedTarget.detectedFrom}\n`); diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index 4a9f2668..a8594a1e 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -225,14 +225,13 @@ export const viewCommand = buildCommand({ // Check if we have profile data if (!hasProfileData(flamegraph)) { + const listCmd = `sentry profile list ${target.org}/${target.project} --period ${flags.period}`; stdout.write( - `No profiling data found for transaction "${transactionName}".\n\n` + `No profiling data found for transaction "${transactionName}" ` + + `in the last ${flags.period}.\n\n` ); stdout.write( - "Make sure:\n" + - " 1. Profiling is enabled for your project\n" + - " 2. The transaction name is correct\n" + - " 3. Profile data has been collected in the specified period\n" + `Run '${listCmd}' to see transactions with available profile data.\n` ); return; } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 59dbf4bb..292260d4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1315,12 +1315,15 @@ export async function listProfiledTransactions( { params: { dataset: "profile_functions", - field: ["transaction", "count()", "p75(function.duration)"], + field: [ + "transaction", + "p75(function.duration)", + "p95(function.duration)", + ], statsPeriod, per_page: limit, project: projectId, - // Sort by count descending to show most active transactions first - sort: "-count()", + sort: "-p75(function.duration)", }, schema: ProfileFunctionsResponseSchema, } diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index 80604d5b..badb431b 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -16,6 +16,58 @@ import { bold, muted, yellow } from "./colors.js"; /** Minimum width for header separator line */ const MIN_HEADER_WIDTH = 60; +/** Max width for the transaction column in the list table */ +const TRANSACTION_COL_WIDTH = 50; + +/** Max width for the location column in the hot paths table */ +const LOCATION_COL_WIDTH = 30; + +/** + * Truncate a string from the middle, preserving start and end for context. + * + * @param str - String to truncate + * @param maxLen - Maximum allowed length + * @returns Truncated string with ellipsis in the middle, or original if short enough + */ +export function truncateMiddle(str: string, maxLen: number): string { + if (str.length <= maxLen) { + return str; + } + const ellipsis = "…"; + const sideLen = Math.floor((maxLen - ellipsis.length) / 2); + return `${str.slice(0, sideLen)}${ellipsis}${str.slice(str.length - sideLen)}`; +} + +/** + * Find the longest common prefix among an array of strings, + * trimmed to the last segment boundary (/ or .). + * + * @example + * findCommonPrefix(["/api/0/organizations/foo/", "/api/0/projects/bar/"]) + * // => "/api/0/" + */ +export function findCommonPrefix(strings: string[]): string { + if (strings.length <= 1) { + return ""; + } + + const first = strings[0] ?? ""; + let prefix = first; + + for (const str of strings) { + while (prefix.length > 0 && !str.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + } + if (prefix.length === 0) { + return ""; + } + } + + // Trim to last segment boundary so we don't cut mid-word + const lastSep = Math.max(prefix.lastIndexOf("/"), prefix.lastIndexOf(".")); + return lastSep >= 0 ? prefix.slice(0, lastSep + 1) : ""; +} + /** * Format a section header with separator line. */ @@ -63,8 +115,11 @@ function formatHotPathRow( percentage: number ): string { const num = `${index + 1}`.padStart(3); - const funcName = frame.name.slice(0, 40).padEnd(40); - const location = `${frame.file}:${frame.line}`.slice(0, 20).padEnd(20); + const funcName = truncateMiddle(frame.name, 40).padEnd(40); + const location = truncateMiddle( + `${frame.file}:${frame.line}`, + LOCATION_COL_WIDTH + ).padEnd(LOCATION_COL_WIDTH); const pct = `${percentage.toFixed(1)}%`.padStart(7); return ` ${num} ${funcName} ${location} ${pct}`; @@ -84,10 +139,9 @@ function formatHotPaths(analysis: ProfileAnalysis): string[] { lines.push(...formatSectionHeader(title)); // Table header + const locationHeader = "Location".padEnd(LOCATION_COL_WIDTH); lines.push( - muted( - " # Function File:Line % Time" - ) + muted(` # ${"Function".padEnd(40)} ${locationHeader} % Time`) ); if (hotPaths.length === 0) { @@ -185,54 +239,96 @@ export function formatProfileListHeader( * @param hasAliases - Whether to include # and ALIAS columns */ export function formatProfileListTableHeader(hasAliases = false): string { + const txnHeader = "TRANSACTION".padEnd(TRANSACTION_COL_WIDTH); if (hasAliases) { return muted( - " # ALIAS TRANSACTION PROFILES p75" + ` # ALIAS ${txnHeader} ${"p75".padStart(10)} ${"p95".padStart(10)}` ); } - return muted( - " TRANSACTION PROFILES p75" - ); + return muted(` ${txnHeader} ${"p75".padStart(10)} ${"p95".padStart(10)}`); } /** * Format a single transaction row for the list. + * Transaction names are displayed with the common prefix stripped and + * middle-truncated to keep both start and end visible. * * @param row - Profile function row data * @param alias - Optional alias entry for this transaction + * @param commonPrefix - Common prefix stripped from all transaction names * @returns Formatted row string */ export function formatProfileListRow( row: ProfileFunctionRow, - alias?: TransactionAliasEntry + alias?: TransactionAliasEntry, + commonPrefix = "" ): string { - const count = `${row["count()"] ?? 0}`.padStart(10); const rawP75 = row["p75(function.duration)"]; - const p75Ms = + const p75 = ( rawP75 !== null && rawP75 !== undefined ? formatDurationMs(rawP75 / 1_000_000) // ns to ms - : "-"; - const p75 = p75Ms.padStart(10); + : "-" + ).padStart(10); + + const rawP95 = row["p95(function.duration)"]; + const p95 = ( + rawP95 !== null && rawP95 !== undefined + ? formatDurationMs(rawP95 / 1_000_000) // ns to ms + : "-" + ).padStart(10); + + // Strip common prefix and apply smart truncation + const rawTransaction = row.transaction ?? "unknown"; + const displayTransaction = commonPrefix + ? rawTransaction.slice(commonPrefix.length) + : rawTransaction; + const transaction = truncateMiddle( + displayTransaction, + TRANSACTION_COL_WIDTH + ).padEnd(TRANSACTION_COL_WIDTH); if (alias) { const idx = `${alias.idx}`.padStart(3); const aliasStr = alias.alias.padEnd(6); - const transaction = (row.transaction ?? "unknown").slice(0, 42).padEnd(42); - return ` ${idx} ${aliasStr} ${transaction} ${count} ${p75}`; + return ` ${idx} ${aliasStr} ${transaction} ${p75} ${p95}`; } - const transaction = (row.transaction ?? "unknown").slice(0, 48).padEnd(48); - return ` ${transaction} ${count} ${p75}`; + return ` ${transaction} ${p75} ${p95}`; +} + +/** + * Compute the table divider width based on whether aliases are shown. + */ +export function profileListDividerWidth(hasAliases: boolean): number { + // #(5) + sep(3) + alias(6) + sep(2) + txn(TRANSACTION_COL_WIDTH) + sep(2) + p75(10) + sep(2) + p95(10) = 90 + return hasAliases ? 90 : 80; } /** * Format the footer tip for profile list command. * * @param hasAliases - Whether aliases are available for quick access + * @param commonPrefix - If set, the common prefix that was stripped */ -export function formatProfileListFooter(hasAliases = false): string { +export function formatProfileListFooter( + hasAliases = false, + commonPrefix?: string +): string { + const lines: string[] = []; + + if (commonPrefix) { + lines.push(`\n${muted(`Common prefix stripped: ${commonPrefix}`)}`); + } + if (hasAliases) { - return "\nTip: Use 'sentry profile view 1' or 'sentry profile view ' to analyze."; + lines.push( + "\nTip: Use 'sentry profile view 1' or 'sentry profile view ' to analyze." + ); + } else { + lines.push( + "\nTip: Use 'sentry profile view \"\"' to analyze." + ); } - return "\nTip: Use 'sentry profile view \"\"' to analyze."; + + return lines.join(""); } diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts index 5c5bcede..88560a01 100644 --- a/test/commands/profile/list.test.ts +++ b/test/commands/profile/list.test.ts @@ -321,8 +321,9 @@ describe("listCommand.func", () => { expect(output).toContain("Transactions with Profiles"); expect(output).toContain("my-org/backend"); expect(output).toContain("last 24h"); - expect(output).toContain("/api/users"); - expect(output).toContain("/api/events"); + // Common prefix "/api/" is stripped, so we see just the segments + expect(output).toContain("users"); + expect(output).toContain("events"); expect(output).toContain("sentry profile view"); }); diff --git a/test/lib/formatters/profile.test.ts b/test/lib/formatters/profile.test.ts index 80cd6738..b66c8c7f 100644 --- a/test/lib/formatters/profile.test.ts +++ b/test/lib/formatters/profile.test.ts @@ -6,11 +6,13 @@ import { describe, expect, test } from "bun:test"; import { + findCommonPrefix, formatProfileAnalysis, formatProfileListFooter, formatProfileListHeader, formatProfileListRow, formatProfileListTableHeader, + truncateMiddle, } from "../../../src/lib/formatters/profile.js"; import type { HotPath, @@ -184,16 +186,16 @@ describe("formatProfileListTableHeader", () => { expect(result).toContain("ALIAS"); expect(result).toContain("#"); expect(result).toContain("TRANSACTION"); - expect(result).toContain("PROFILES"); expect(result).toContain("p75"); + expect(result).toContain("p95"); }); test("does not include ALIAS or # columns when hasAliases is false", () => { const result = stripAnsi(formatProfileListTableHeader(false)); expect(result).not.toContain("ALIAS"); expect(result).toContain("TRANSACTION"); - expect(result).toContain("PROFILES"); expect(result).toContain("p75"); + expect(result).toContain("p95"); }); test("defaults to no aliases", () => { @@ -205,18 +207,18 @@ describe("formatProfileListTableHeader", () => { // formatProfileListRow describe("formatProfileListRow", () => { - test("formats row with transaction, count, and p75", () => { + test("formats row with transaction and p75/p95", () => { const row: ProfileFunctionRow = { transaction: "/api/users", - "count()": 150, "p75(function.duration)": 8_000_000, // 8ms in nanoseconds + "p95(function.duration)": 15_000_000, // 15ms in nanoseconds }; const result = stripAnsi(formatProfileListRow(row)); expect(result).toContain("/api/users"); - expect(result).toContain("150"); expect(result).toContain("8.00ms"); + expect(result).toContain("15.0ms"); }); test("formats row with alias when provided", () => { @@ -241,23 +243,15 @@ describe("formatProfileListRow", () => { expect(result).toContain("/api/users"); }); - test("handles missing count", () => { - const row: ProfileFunctionRow = { - transaction: "/api/users", - }; - - const result = stripAnsi(formatProfileListRow(row)); - expect(result).toContain("0"); - }); - - test("handles missing p75 duration", () => { + test("handles missing p75 and p95", () => { const row: ProfileFunctionRow = { transaction: "/api/users", - "count()": 10, }; const result = stripAnsi(formatProfileListRow(row)); - expect(result).toContain("-"); + // Both p75 and p95 should show "-" when missing + const dashes = result.match(/-/g); + expect(dashes?.length).toBeGreaterThanOrEqual(2); }); test("handles missing transaction name", () => { @@ -305,3 +299,62 @@ describe("formatProfileListFooter", () => { expect(result).toContain(""); }); }); + +// truncateMiddle + +describe("truncateMiddle", () => { + test("returns short strings unchanged", () => { + expect(truncateMiddle("hello", 10)).toBe("hello"); + expect(truncateMiddle("hello", 5)).toBe("hello"); + }); + + test("truncates from the middle with ellipsis", () => { + const result = truncateMiddle("abcdefghijklmnop", 10); + expect(result.length).toBeLessThanOrEqual(10); + expect(result).toContain("…"); + // Should preserve start and end + expect(result.startsWith("abcd")).toBe(true); + expect(result.endsWith("mnop")).toBe(true); + }); + + test("handles very short maxLen", () => { + const result = truncateMiddle("abcdefghij", 3); + expect(result.length).toBe(3); + expect(result).toContain("…"); + }); +}); + +// findCommonPrefix + +describe("findCommonPrefix", () => { + test("finds common path prefix", () => { + const result = findCommonPrefix([ + "/api/0/organizations/foo/", + "/api/0/projects/bar/", + "/api/0/teams/baz/", + ]); + expect(result).toBe("/api/0/"); + }); + + test("returns empty for single item", () => { + expect(findCommonPrefix(["/api/foo"])).toBe(""); + }); + + test("returns empty for empty array", () => { + expect(findCommonPrefix([])).toBe(""); + }); + + test("returns empty when no common prefix", () => { + expect(findCommonPrefix(["/api/foo", "/remote/bar"])).toBe("/"); + }); + + test("trims to segment boundary", () => { + expect(findCommonPrefix(["/api/foo/a", "/api/foobar/b"])).toBe("/api/"); + }); + + test("handles dotted names", () => { + expect( + findCommonPrefix(["tasks.sentry.process", "tasks.sentry.cleanup"]) + ).toBe("tasks.sentry."); + }); +}); From ec75c70ab07ed7348b719591998c6532ed6b34ca Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 12:45:59 +0000 Subject: [PATCH 24/32] feat(profile): add SAMPLES column and docs page Use count_unique(timestamp) instead of count() to show real sample counts in the profile list table. Add documentation page with anonymized example outputs for both list and view commands. --- docs/src/content/docs/commands/index.md | 1 + docs/src/content/docs/commands/profile.md | 155 ++++++++++++++++++++++ src/lib/api-client.ts | 1 + src/lib/formatters/profile.ts | 17 +-- src/types/profile.ts | 4 +- test/commands/profile/list.test.ts | 20 +-- test/lib/formatters/profile.test.ts | 12 +- 7 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 docs/src/content/docs/commands/profile.md diff --git a/docs/src/content/docs/commands/index.md b/docs/src/content/docs/commands/index.md index 9decfeb0..10d2ee84 100644 --- a/docs/src/content/docs/commands/index.md +++ b/docs/src/content/docs/commands/index.md @@ -17,6 +17,7 @@ The Sentry CLI provides commands for interacting with various Sentry resources. | [`issue`](./issue/) | Issue tracking | | [`event`](./event/) | Event inspection | | [`log`](./log/) | Log viewing and streaming | +| [`profile`](./profile/) | CPU profiling analysis | | [`api`](./api/) | Direct API access | ## Global Options diff --git a/docs/src/content/docs/commands/profile.md b/docs/src/content/docs/commands/profile.md new file mode 100644 index 00000000..459783af --- /dev/null +++ b/docs/src/content/docs/commands/profile.md @@ -0,0 +1,155 @@ +--- +title: profile +description: CPU profiling commands for the Sentry CLI +--- + +Analyze CPU profiling data for your Sentry projects. + +## Commands + +### `sentry profile list` + +List transactions with profiling data, sorted by p75 duration. + +```bash +sentry profile list [/] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `/` | Target project (optional, auto-detected from DSN) | + +**Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--period` | Time period: `1h`, `24h`, `7d`, `14d`, `30d` | `24h` | +| `-n, --limit` | Maximum transactions to return | `20` | +| `-w, --web` | Open in browser | | +| `--json` | Output as JSON | | + +**Example:** + +```bash +sentry profile list my-org/backend --period 7d +``` + +``` +Transactions with Profiles in my-org/backend (last 7d): + + # ALIAS TRANSACTION SAMPLES p75 p95 +───────────────────────────────────────────────────────────────────────────────────────────────────── + 1 u projects/{project_id}/users/ 42 3.8s 5.0s + 2 a webhooks/provision/account/ 18 2.7s 2.7s + 3 c organizations/{org_id}/code-mappings/ 6 2.1s 2.1s + 4 e projects/{project_id}/events/ 291 1.5s 8.6s + 5 i organizations/{org_id}/issues/ 541 1.5s 2.8s + +Common prefix stripped: /api/0/ +Tip: Use 'sentry profile view 1' or 'sentry profile view ' to analyze. +``` + +Transaction names are shown with common prefixes stripped and middle-truncated for readability. Short aliases are generated for quick reference. + +### `sentry profile view` + +View CPU profiling analysis for a specific transaction. Displays hot paths, performance percentiles, and optimization recommendations. + +```bash +sentry profile view [/] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `/` | Target project (optional, auto-detected from DSN) | +| `` | Transaction index (`1`), alias (`e`), or full name (`/api/users`) | + +**Options:** + +| Option | Description | Default | +|--------|-------------|---------| +| `--period` | Time period: `1h`, `24h`, `7d`, `14d`, `30d` | `24h` | +| `-n, --limit` | Number of hot paths to show (max 20) | `10` | +| `--allFrames` | Include library/system frames | `false` | +| `-w, --web` | Open in browser | | +| `--json` | Output as JSON | | + +**Example using alias from list output:** + +```bash +sentry profile view my-org/backend e --period 7d +``` + +``` +/api/0/projects/{project_id}/events/: CPU Profile Analysis (last 7d) +════════════════════════════════════════════════════════════════════════════════ + +Performance Percentiles + p75: 1.7s p95: 12.1s p99: 12.1s + +Hot Paths (Top 10 by CPU time, user code only) +──────────────────────────────────────────────────────────── + # Function Location % Time + 1 EnvMiddleware..EnvMiddleware_impl middleware/env.py:14 7.7% + 2 access_log_middlewa….middleware middlew…ess_log.py:171 7.7% + 3 SubdomainMiddleware.__call__ middlew…ubdomain.py:53 7.7% + 4 AIAgentMiddleware.__call__ middlew…ai_agent.py:97 7.6% + 5 IntegrationControlMiddleware.__call__ middlew…_control.py:60 7.6% + 6 ApiGatewayMiddleware.__call__ hybridc…ddleware.py:19 7.6% + 7 DemoModeGuardMiddleware.__call__ middlew…de_guard.py:44 7.6% + 8 CustomerDomainMiddleware.__call__ middlew…r_domain.py:97 7.6% + 9 StaffMiddleware.__call__ middleware/staff.py:53 7.6% + 10 RatelimitMiddleware.__call__ middlew…atelimit.py:57 7.6% +``` + +**Include library/system frames:** + +```bash +sentry profile view my-org/backend e --allFrames --limit 5 +``` + +**JSON output for scripting:** + +```bash +sentry profile view my-org/backend e --json | jq '.hotPaths[0].frames[0].name' +``` + +## Workflow + +A typical profiling workflow: + +1. **List** transactions to see what has profiling data: + ```bash + sentry profile list my-org/backend + ``` + +2. **View** a specific transaction using its alias or index: + ```bash + sentry profile view my-org/backend e + ``` + +3. **Investigate** with all frames to see library overhead: + ```bash + sentry profile view my-org/backend e --allFrames + ``` + +4. **Open in browser** for the full Sentry UI experience: + ```bash + sentry profile view my-org/backend e -w + ``` + +## Transaction References + +The `profile view` command accepts three types of transaction references: + +| Type | Example | Description | +|------|---------|-------------| +| Index | `1`, `5` | Numeric position from `profile list` output | +| Alias | `e`, `i` | Short alias generated by `profile list` | +| Full name | `/api/users` | Exact transaction name (quoted if it has spaces) | + +Aliases and indices are cached from the most recent `profile list` run. If you change the project, org, or period, run `profile list` again to refresh them. diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 292260d4..e498e1bd 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1317,6 +1317,7 @@ export async function listProfiledTransactions( dataset: "profile_functions", field: [ "transaction", + "count_unique(timestamp)", "p75(function.duration)", "p95(function.duration)", ], diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index badb431b..49e70dbf 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -240,12 +240,11 @@ export function formatProfileListHeader( */ export function formatProfileListTableHeader(hasAliases = false): string { const txnHeader = "TRANSACTION".padEnd(TRANSACTION_COL_WIDTH); + const tail = `${"SAMPLES".padStart(9)} ${"p75".padStart(10)} ${"p95".padStart(10)}`; if (hasAliases) { - return muted( - ` # ALIAS ${txnHeader} ${"p75".padStart(10)} ${"p95".padStart(10)}` - ); + return muted(` # ALIAS ${txnHeader} ${tail}`); } - return muted(` ${txnHeader} ${"p75".padStart(10)} ${"p95".padStart(10)}`); + return muted(` ${txnHeader} ${tail}`); } /** @@ -263,6 +262,8 @@ export function formatProfileListRow( alias?: TransactionAliasEntry, commonPrefix = "" ): string { + const samples = `${row["count_unique(timestamp)"] ?? 0}`.padStart(9); + const rawP75 = row["p75(function.duration)"]; const p75 = ( rawP75 !== null && rawP75 !== undefined @@ -290,18 +291,18 @@ export function formatProfileListRow( if (alias) { const idx = `${alias.idx}`.padStart(3); const aliasStr = alias.alias.padEnd(6); - return ` ${idx} ${aliasStr} ${transaction} ${p75} ${p95}`; + return ` ${idx} ${aliasStr} ${transaction} ${samples} ${p75} ${p95}`; } - return ` ${transaction} ${p75} ${p95}`; + return ` ${transaction} ${samples} ${p75} ${p95}`; } /** * Compute the table divider width based on whether aliases are shown. */ export function profileListDividerWidth(hasAliases: boolean): number { - // #(5) + sep(3) + alias(6) + sep(2) + txn(TRANSACTION_COL_WIDTH) + sep(2) + p75(10) + sep(2) + p95(10) = 90 - return hasAliases ? 90 : 80; + // #(5) + sep(3) + alias(6) + sep(2) + txn(50) + sep(2) + samples(9) + sep(2) + p75(10) + sep(2) + p95(10) = 101 + return hasAliases ? 101 : 91; } /** diff --git a/src/types/profile.ts b/src/types/profile.ts index f0104918..4a538b09 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -154,8 +154,8 @@ export const ProfileFunctionRowSchema = z .object({ /** Transaction name (null when transaction data is missing) */ transaction: z.string().nullish(), - /** Number of profiles/samples */ - "count()": z.number().nullish(), + /** Number of unique profile samples */ + "count_unique(timestamp)": z.number().nullish(), /** 75th percentile duration */ "p75(function.duration)": z.number().nullish(), /** 95th percentile duration */ diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts index 88560a01..636decc6 100644 --- a/test/commands/profile/list.test.ts +++ b/test/commands/profile/list.test.ts @@ -266,8 +266,8 @@ describe("listCommand.func", () => { const ctx = createMockContext(); setupResolvedTarget(); const mockData = [ - { transaction: "/api/users", "count()": 50 }, - { transaction: "/api/events", "count()": 30 }, + { transaction: "/api/users", "count_unique(timestamp)": 50 }, + { transaction: "/api/events", "count_unique(timestamp)": 30 }, ]; listProfiledTransactionsSpy.mockResolvedValue({ data: mockData }); const func = await loadListFunc(); @@ -303,12 +303,12 @@ describe("listCommand.func", () => { data: [ { transaction: "/api/users", - "count()": 150, + "count_unique(timestamp)": 150, "p75(function.duration)": 8_000_000, }, { transaction: "/api/events", - "count()": 75, + "count_unique(timestamp)": 75, "p75(function.duration)": 15_000_000, }, ], @@ -361,7 +361,7 @@ describe("listCommand.func", () => { const ctx = createMockContext(); setupResolvedTarget({ detectedFrom: ".env file" }); listProfiledTransactionsSpy.mockResolvedValue({ - data: [{ transaction: "/api/users", "count()": 10 }], + data: [{ transaction: "/api/users", "count_unique(timestamp)": 10 }], }); const func = await loadListFunc(); @@ -375,7 +375,7 @@ describe("listCommand.func", () => { const ctx = createMockContext(); setupResolvedTarget(); listProfiledTransactionsSpy.mockResolvedValue({ - data: [{ transaction: "/api/users", "count()": 10 }], + data: [{ transaction: "/api/users", "count_unique(timestamp)": 10 }], }); const func = await loadListFunc(); @@ -392,8 +392,8 @@ describe("listCommand.func", () => { setupResolvedTarget(); listProfiledTransactionsSpy.mockResolvedValue({ data: [ - { transaction: "/api/users", "count()": 50 }, - { transaction: "/api/events", "count()": 30 }, + { transaction: "/api/users", "count_unique(timestamp)": 50 }, + { transaction: "/api/events", "count_unique(timestamp)": 30 }, ], }); const func = await loadListFunc(); @@ -414,8 +414,8 @@ describe("listCommand.func", () => { setupResolvedTarget(); listProfiledTransactionsSpy.mockResolvedValue({ data: [ - { transaction: "/api/users", "count()": 50 }, - { "count()": 30 }, // no transaction name + { transaction: "/api/users", "count_unique(timestamp)": 50 }, + { "count_unique(timestamp)": 30 }, // no transaction name ], }); const func = await loadListFunc(); diff --git a/test/lib/formatters/profile.test.ts b/test/lib/formatters/profile.test.ts index b66c8c7f..878167c6 100644 --- a/test/lib/formatters/profile.test.ts +++ b/test/lib/formatters/profile.test.ts @@ -186,6 +186,7 @@ describe("formatProfileListTableHeader", () => { expect(result).toContain("ALIAS"); expect(result).toContain("#"); expect(result).toContain("TRANSACTION"); + expect(result).toContain("SAMPLES"); expect(result).toContain("p75"); expect(result).toContain("p95"); }); @@ -194,6 +195,7 @@ describe("formatProfileListTableHeader", () => { const result = stripAnsi(formatProfileListTableHeader(false)); expect(result).not.toContain("ALIAS"); expect(result).toContain("TRANSACTION"); + expect(result).toContain("SAMPLES"); expect(result).toContain("p75"); expect(result).toContain("p95"); }); @@ -207,9 +209,10 @@ describe("formatProfileListTableHeader", () => { // formatProfileListRow describe("formatProfileListRow", () => { - test("formats row with transaction and p75/p95", () => { + test("formats row with transaction, samples, and p75/p95", () => { const row: ProfileFunctionRow = { transaction: "/api/users", + "count_unique(timestamp)": 42, "p75(function.duration)": 8_000_000, // 8ms in nanoseconds "p95(function.duration)": 15_000_000, // 15ms in nanoseconds }; @@ -217,6 +220,7 @@ describe("formatProfileListRow", () => { const result = stripAnsi(formatProfileListRow(row)); expect(result).toContain("/api/users"); + expect(result).toContain("42"); expect(result).toContain("8.00ms"); expect(result).toContain("15.0ms"); }); @@ -224,7 +228,7 @@ describe("formatProfileListRow", () => { test("formats row with alias when provided", () => { const row: ProfileFunctionRow = { transaction: "/api/users", - "count()": 150, + "count_unique(timestamp)": 150, "p75(function.duration)": 8_000_000, }; @@ -256,7 +260,7 @@ describe("formatProfileListRow", () => { test("handles missing transaction name", () => { const row: ProfileFunctionRow = { - "count()": 10, + "count_unique(timestamp)": 10, "p75(function.duration)": 5_000_000, }; @@ -269,7 +273,7 @@ describe("formatProfileListRow", () => { "/api/v2/organizations/{org}/projects/{project}/events/{event_id}/attachments/"; const row: ProfileFunctionRow = { transaction: longTransaction, - "count()": 1, + "count_unique(timestamp)": 1, "p75(function.duration)": 1_000_000, }; From d35a87842a71bd89b1718e2a1e9f8532effb4158 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Feb 2026 12:46:31 +0000 Subject: [PATCH 25/32] chore: regenerate SKILL.md --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 91700445..3165678f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -562,6 +562,14 @@ List transactions with profiling data - `--json - Output as JSON` - `-w, --web - Open in browser` +**Examples:** + +```bash +sentry profile list [/] + +sentry profile list my-org/backend --period 7d +``` + #### `sentry profile view ` View CPU profiling analysis for a transaction @@ -573,6 +581,26 @@ View CPU profiling analysis for a transaction - `--json - Output as JSON` - `-w, --web - Open in browser` +**Examples:** + +```bash +sentry profile view [/] + +sentry profile view my-org/backend e --period 7d + +sentry profile view my-org/backend e --allFrames --limit 5 + +sentry profile view my-org/backend e --json | jq '.hotPaths[0].frames[0].name' + +sentry profile list my-org/backend + +sentry profile view my-org/backend e + +sentry profile view my-org/backend e --allFrames + +sentry profile view my-org/backend e -w +``` + ### Trace View distributed traces From 2185365b1023b3f2b5ab05f4c94b195c9962c340 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 13:02:16 +0000 Subject: [PATCH 26/32] fix(profile): address BugBot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix common prefix stripping for null transactions ("unknown" fallback was being sliced by the prefix length, producing empty strings) - Fix misaligned table rows when hasAliases is true but a row has no alias (now pads index/alias columns to maintain column alignment) - Fix formatDurationMs boundary rounding for 1-10ms and µs ranges (9.999ms no longer renders as "10.00ms", 0.9995ms no longer as "1000µs") - Change formatProfileListRow to use options object for clearer API --- src/commands/profile/list.ts | 4 ++- src/lib/formatters/profile.ts | 30 +++++++++++++++------ src/lib/profile/analyzer.ts | 12 ++++++++- test/lib/formatters/profile.test.ts | 41 ++++++++++++++++++++++++++++- test/lib/profile/analyzer.test.ts | 11 ++++++++ 5 files changed, 87 insertions(+), 11 deletions(-) diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index 9101ae21..61bf58a6 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -259,7 +259,9 @@ export const listCommand = buildCommand({ for (const row of response.data) { const alias = row.transaction ? aliasMap.get(row.transaction) : undefined; - stdout.write(`${formatProfileListRow(row, alias, commonPrefix)}\n`); + stdout.write( + `${formatProfileListRow(row, { alias, commonPrefix, hasAliases })}\n` + ); } stdout.write(formatProfileListFooter(hasAliases, commonPrefix)); diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index 49e70dbf..de797f09 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -253,15 +253,21 @@ export function formatProfileListTableHeader(hasAliases = false): string { * middle-truncated to keep both start and end visible. * * @param row - Profile function row data - * @param alias - Optional alias entry for this transaction - * @param commonPrefix - Common prefix stripped from all transaction names + * @param options - Formatting options + * @param options.alias - Optional alias entry for this transaction + * @param options.commonPrefix - Common prefix stripped from all transaction names + * @param options.hasAliases - Whether the table uses alias layout (keeps columns aligned even for rows without an alias) * @returns Formatted row string */ export function formatProfileListRow( row: ProfileFunctionRow, - alias?: TransactionAliasEntry, - commonPrefix = "" + options: { + alias?: TransactionAliasEntry; + commonPrefix?: string; + hasAliases?: boolean; + } = {} ): string { + const { alias, commonPrefix = "", hasAliases = false } = options; const samples = `${row["count_unique(timestamp)"] ?? 0}`.padStart(9); const rawP75 = row["p75(function.duration)"]; @@ -278,11 +284,14 @@ export function formatProfileListRow( : "-" ).padStart(10); - // Strip common prefix and apply smart truncation + // Strip common prefix and apply smart truncation. + // Only strip when the transaction actually starts with the prefix; + // the "unknown" fallback does not share it. const rawTransaction = row.transaction ?? "unknown"; - const displayTransaction = commonPrefix - ? rawTransaction.slice(commonPrefix.length) - : rawTransaction; + const displayTransaction = + commonPrefix && rawTransaction.startsWith(commonPrefix) + ? rawTransaction.slice(commonPrefix.length) + : rawTransaction; const transaction = truncateMiddle( displayTransaction, TRANSACTION_COL_WIDTH @@ -294,6 +303,11 @@ export function formatProfileListRow( return ` ${idx} ${aliasStr} ${transaction} ${samples} ${p75} ${p95}`; } + // When the table has aliases but this row doesn't, pad to keep columns aligned + if (hasAliases) { + return ` ${"".padStart(3)} ${"".padEnd(6)} ${transaction} ${samples} ${p75} ${p95}`; + } + return ` ${transaction} ${samples} ${p75} ${p95}`; } diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts index 789d0a05..9f628517 100644 --- a/src/lib/profile/analyzer.ts +++ b/src/lib/profile/analyzer.ts @@ -51,11 +51,21 @@ export function formatDurationMs(ms: number): string { return `${formatted}ms`; } if (ms >= 1) { - return `${ms.toFixed(2)}ms`; + const formatted = ms.toFixed(2); + // toFixed(2) can push past 10ms boundary (e.g. 9.999 → "10.00") + if (Number.parseFloat(formatted) >= 10) { + return `${ms.toFixed(1)}ms`; + } + return `${formatted}ms`; } // Sub-millisecond const us = ms * 1000; if (us >= 1) { + const rounded = Math.round(us); + // Rounding can push past 1ms boundary (e.g. 0.9995ms → 1000µs) + if (rounded >= 1000) { + return `${ms.toFixed(2)}ms`; + } return `${us.toFixed(0)}µs`; } return `${(us * 1000).toFixed(0)}ns`; diff --git a/test/lib/formatters/profile.test.ts b/test/lib/formatters/profile.test.ts index 878167c6..b1cf6bc3 100644 --- a/test/lib/formatters/profile.test.ts +++ b/test/lib/formatters/profile.test.ts @@ -240,7 +240,7 @@ describe("formatProfileListRow", () => { projectSlug: "backend", }; - const result = stripAnsi(formatProfileListRow(row, alias)); + const result = stripAnsi(formatProfileListRow(row, { alias })); expect(result).toContain("1"); expect(result).toContain("users"); @@ -268,6 +268,45 @@ describe("formatProfileListRow", () => { expect(result).toContain("unknown"); }); + test("handles missing transaction with common prefix without garbling", () => { + const row: ProfileFunctionRow = { + "count_unique(timestamp)": 5, + "p75(function.duration)": 2_000_000, + }; + + const result = stripAnsi( + formatProfileListRow(row, { commonPrefix: "/api/0/" }) + ); + // "unknown" should not be sliced by the common prefix + expect(result).toContain("unknown"); + }); + + test("aligns columns when hasAliases is true but row has no alias", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count_unique(timestamp)": 10, + "p75(function.duration)": 5_000_000, + }; + + const withAlias = stripAnsi( + formatProfileListRow(row, { + alias: { + idx: 1, + alias: "users", + transaction: "/api/users", + orgSlug: "o", + projectSlug: "p", + }, + }) + ); + const withoutAlias = stripAnsi( + formatProfileListRow(row, { hasAliases: true }) + ); + + // Both rows should have the same total length so columns align + expect(withoutAlias.length).toBe(withAlias.length); + }); + test("truncates long transaction names", () => { const longTransaction = "/api/v2/organizations/{org}/projects/{project}/events/{event_id}/attachments/"; diff --git a/test/lib/profile/analyzer.test.ts b/test/lib/profile/analyzer.test.ts index 395ad3fb..9aea19d4 100644 --- a/test/lib/profile/analyzer.test.ts +++ b/test/lib/profile/analyzer.test.ts @@ -150,6 +150,17 @@ describe("formatDurationMs", () => { expect(formatDurationMs(99.99)).toBe("100ms"); }); + test("handles boundary rounding: 9.999ms promotes to 1-decimal format", () => { + // (9.999).toFixed(2) = "10.00", which should display as "10.0ms" not "10.00ms" + expect(formatDurationMs(9.999)).toBe("10.0ms"); + expect(formatDurationMs(9.9999)).toBe("10.0ms"); + }); + + test("handles boundary rounding: 0.9995ms promotes to ms", () => { + // 0.9995 * 1000 = 999.5, Math.round = 1000, should display as "1.00ms" not "1000µs" + expect(formatDurationMs(0.9995)).toBe("1.00ms"); + }); + test("property: output always contains a unit", () => { fcAssert( property(double({ min: 0.000_001, max: 100_000, noNaN: true }), (ms) => { From 3817e6079576ed850019a05d84e8cbd18c63b3d5 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 13:22:15 +0000 Subject: [PATCH 27/32] fix(db): use TABLE_SCHEMAS for transaction_aliases and bump schema to v6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove custom DDL handling (CUSTOM_DDL_TABLES, TRANSACTION_ALIASES_DDL, repairTransactionAliasesTable) since TABLE_SCHEMAS already supports compositePrimaryKey (used by pagination_cursors). Add compositePrimaryKey: ["fingerprint", "idx"] to the schema definition. Bump schema version 5→6 and split the migration so databases already at v5 (from pagination_cursors) correctly pick up the transaction_aliases table via the new v5→v6 migration block. --- src/lib/db/schema.ts | 72 +++++++++----------------------------------- 1 file changed, 15 insertions(+), 57 deletions(-) diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 60e56063..10ff1e60 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -13,7 +13,7 @@ import type { Database } from "bun:sqlite"; -export const CURRENT_SCHEMA_VERSION = 5; +export const CURRENT_SCHEMA_VERSION = 6; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -223,6 +223,7 @@ export const TABLE_SCHEMAS: Record = { default: "(unixepoch() * 1000)", }, }, + compositePrimaryKey: ["fingerprint", "idx"], }, }; @@ -397,47 +398,23 @@ export type RepairResult = { failed: string[]; }; -/** Tables that require custom DDL (not auto-generated from TABLE_SCHEMAS) */ -const CUSTOM_DDL_TABLES = new Set(["transaction_aliases"]); - -function repairTransactionAliasesTable( - db: Database, - result: RepairResult -): void { - if (tableExists(db, "transaction_aliases")) { - return; - } - try { - db.exec(TRANSACTION_ALIASES_DDL); - db.exec(TRANSACTION_ALIASES_INDEX); - result.fixed.push("Created table transaction_aliases"); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - result.failed.push(`Failed to create table transaction_aliases: ${msg}`); - } -} - function repairMissingTables(db: Database, result: RepairResult): void { for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) { - // Skip tables that need custom DDL - if (CUSTOM_DDL_TABLES.has(tableName)) { - continue; - } - if (tableExists(db, tableName)) { continue; } try { db.exec(ddl); + // Create associated indexes for tables that need them + if (tableName === "transaction_aliases") { + db.exec(TRANSACTION_ALIASES_INDEX); + } result.fixed.push(`Created table ${tableName}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } - - // Handle tables with custom DDL - repairTransactionAliasesTable(db, result); } function repairMissingColumns(db: Database, result: RepairResult): void { @@ -592,38 +569,15 @@ export function tryRepairAndRetry( return { attempted: false }; } -/** - * Custom DDL for transaction_aliases table with composite primary key. - * TABLE_SCHEMAS doesn't support composite primary keys, so we handle this specially. - */ -const TRANSACTION_ALIASES_DDL = ` - CREATE TABLE IF NOT EXISTS transaction_aliases ( - idx INTEGER NOT NULL, - alias TEXT NOT NULL, - transaction_name TEXT NOT NULL, - org_slug TEXT NOT NULL, - project_slug TEXT NOT NULL, - fingerprint TEXT NOT NULL, - cached_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), - PRIMARY KEY (fingerprint, idx) - ) -`; - +/** Index for efficient alias lookups by alias string + fingerprint */ const TRANSACTION_ALIASES_INDEX = ` CREATE INDEX IF NOT EXISTS idx_txn_alias_lookup ON transaction_aliases(alias, fingerprint) `; export function initSchema(db: Database): void { - // Generate combined DDL from all table schemas (except transaction_aliases which has custom DDL) - const ddlStatements = Object.entries(EXPECTED_TABLES) - .filter(([name]) => name !== "transaction_aliases") - .map(([, ddl]) => ddl) - .join(";\n\n"); + const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); db.exec(ddlStatements); - - // Add transaction_aliases with composite primary key - db.exec(TRANSACTION_ALIASES_DDL); db.exec(TRANSACTION_ALIASES_INDEX); const versionRow = db @@ -682,13 +636,17 @@ export function runMigrations(db: Database): void { db.exec(EXPECTED_TABLES.project_root_cache as string); } - // Migration 4 -> 5: Add transaction_aliases and pagination_cursors tables + // Migration 4 -> 5: Add pagination_cursors table if (currentVersion < 5) { - db.exec(TRANSACTION_ALIASES_DDL); - db.exec(TRANSACTION_ALIASES_INDEX); db.exec(EXPECTED_TABLES.pagination_cursors as string); } + // Migration 5 -> 6: Add transaction_aliases table + if (currentVersion < 6) { + db.exec(EXPECTED_TABLES.transaction_aliases as string); + db.exec(TRANSACTION_ALIASES_INDEX); + } + if (currentVersion < CURRENT_SCHEMA_VERSION) { db.query("UPDATE schema_version SET version = ?").run( CURRENT_SCHEMA_VERSION From 043a7af1cec8f5441358fc7106f4d2c8538f5922 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 13:39:24 +0000 Subject: [PATCH 28/32] fix(profile): align table header columns with data rows Pad # to padStart(3) and ALIAS to padEnd(6) in the header to match the data row formatting. Fix non-alias divider width from 91 to 87 to match actual content width. --- docs/src/content/docs/commands/profile.md | 14 +++++++------- src/lib/formatters/profile.ts | 10 +++++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/commands/profile.md b/docs/src/content/docs/commands/profile.md index 459783af..21a2e212 100644 --- a/docs/src/content/docs/commands/profile.md +++ b/docs/src/content/docs/commands/profile.md @@ -39,13 +39,13 @@ sentry profile list my-org/backend --period 7d ``` Transactions with Profiles in my-org/backend (last 7d): - # ALIAS TRANSACTION SAMPLES p75 p95 -───────────────────────────────────────────────────────────────────────────────────────────────────── - 1 u projects/{project_id}/users/ 42 3.8s 5.0s - 2 a webhooks/provision/account/ 18 2.7s 2.7s - 3 c organizations/{org_id}/code-mappings/ 6 2.1s 2.1s - 4 e projects/{project_id}/events/ 291 1.5s 8.6s - 5 i organizations/{org_id}/issues/ 541 1.5s 2.8s + # ALIAS TRANSACTION SAMPLES p75 p95 +───────────────────────────────────────────────────────────────────────────────────────────────────────── + 1 u projects/{project_id}/users/ 42 3.8s 5.0s + 2 a webhooks/provision/account/ 18 2.7s 2.7s + 3 c organizations/{org_id}/code-mappings/ 6 2.1s 2.1s + 4 e projects/{project_id}/events/ 291 1.5s 8.6s + 5 i organizations/{org_id}/issues/ 541 1.5s 2.8s Common prefix stripped: /api/0/ Tip: Use 'sentry profile view 1' or 'sentry profile view ' to analyze. diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts index de797f09..8d25d883 100644 --- a/src/lib/formatters/profile.ts +++ b/src/lib/formatters/profile.ts @@ -242,7 +242,10 @@ export function formatProfileListTableHeader(hasAliases = false): string { const txnHeader = "TRANSACTION".padEnd(TRANSACTION_COL_WIDTH); const tail = `${"SAMPLES".padStart(9)} ${"p75".padStart(10)} ${"p95".padStart(10)}`; if (hasAliases) { - return muted(` # ALIAS ${txnHeader} ${tail}`); + // Pad # and ALIAS to match data row widths (padStart(3) and padEnd(6)) + return muted( + ` ${"#".padStart(3)} ${"ALIAS".padEnd(6)} ${txnHeader} ${tail}` + ); } return muted(` ${txnHeader} ${tail}`); } @@ -315,8 +318,9 @@ export function formatProfileListRow( * Compute the table divider width based on whether aliases are shown. */ export function profileListDividerWidth(hasAliases: boolean): number { - // #(5) + sep(3) + alias(6) + sep(2) + txn(50) + sep(2) + samples(9) + sep(2) + p75(10) + sep(2) + p95(10) = 101 - return hasAliases ? 101 : 91; + // With aliases: indent(2) + #(3) + sep(3) + alias(6) + sep(2) + txn(50) + sep(2) + samples(9) + sep(2) + p75(10) + sep(2) + p95(10) = 101 + // Without: indent(2) + txn(50) + sep(2) + samples(9) + sep(2) + p75(10) + sep(2) + p95(10) = 87 + return hasAliases ? 101 : 87; } /** From 9a362ccfeb68f92eb7cec01849d46626495167b7 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 13:51:16 +0000 Subject: [PATCH 29/32] fix(profile): fix alias pattern and schema repair robustness - Remove trailing \d* from ALIAS_PATTERN since disambiguateSegments uses 'x' prefix, not numeric suffix. This prevents misclassifying inputs like 'process2' as aliases. - Separate index creation from table DDL in repairMissingTables so a failed index doesn't prevent subsequent repair passes from creating it (table exists check was skipping the index). --- src/lib/db/schema.ts | 34 ++++++++++++++++++++++++---------- src/lib/resolve-transaction.ts | 20 ++++++++++---------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 10ff1e60..c8993b7e 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -398,6 +398,12 @@ export type RepairResult = { failed: string[]; }; +/** Index for efficient alias lookups by alias string + fingerprint */ +const TRANSACTION_ALIASES_INDEX = ` + CREATE INDEX IF NOT EXISTS idx_txn_alias_lookup + ON transaction_aliases(alias, fingerprint) +`; + function repairMissingTables(db: Database, result: RepairResult): void { for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) { if (tableExists(db, tableName)) { @@ -405,16 +411,30 @@ function repairMissingTables(db: Database, result: RepairResult): void { } try { db.exec(ddl); - // Create associated indexes for tables that need them - if (tableName === "transaction_aliases") { - db.exec(TRANSACTION_ALIASES_INDEX); - } result.fixed.push(`Created table ${tableName}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } + // Create indexes for newly created tables (separate pass to avoid + // leaving the index uncreated if the table DDL succeeds but the + // index fails in the same try/catch) + ensureTableIndexes(db, result); +} + +/** Ensure indexes exist for tables that need them */ +function ensureTableIndexes(db: Database, result: RepairResult): void { + if (tableExists(db, "transaction_aliases")) { + try { + db.exec(TRANSACTION_ALIASES_INDEX); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result.failed.push( + `Failed to create index for transaction_aliases: ${msg}` + ); + } + } } function repairMissingColumns(db: Database, result: RepairResult): void { @@ -569,12 +589,6 @@ export function tryRepairAndRetry( return { attempted: false }; } -/** Index for efficient alias lookups by alias string + fingerprint */ -const TRANSACTION_ALIASES_INDEX = ` - CREATE INDEX IF NOT EXISTS idx_txn_alias_lookup - ON transaction_aliases(alias, fingerprint) -`; - export function initSchema(db: Database): void { const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); db.exec(ddlStatements); diff --git a/src/lib/resolve-transaction.ts b/src/lib/resolve-transaction.ts index 453ad38d..9c28e649 100644 --- a/src/lib/resolve-transaction.ts +++ b/src/lib/resolve-transaction.ts @@ -48,25 +48,25 @@ const MAX_ALIAS_LENGTH = 20; /** * Pattern matching alias-shaped input: purely ASCII letters in a single case - * (all lowercase or all uppercase for caps-lock tolerance), optionally - * followed by a numeric disambiguator (e.g. "issues2", "ISSUES2"). + * (all lowercase or all uppercase for caps-lock tolerance). * * This mirrors how `buildTransactionAliases` generates aliases: * segments are lowercased, stripped of hyphens/underscores, then - * `findShortestUniquePrefixes` produces a lowercase-letter prefix, - * and `disambiguateSegments` may append a numeric suffix. + * `findShortestUniquePrefixes` produces a lowercase-letter prefix. + * `disambiguateSegments` prepends "x" characters for duplicates + * (e.g. "xissues", "xxissues"), so aliases are always purely alphabetic. * * Mixed-case inputs like "ProcessEvent" are treated as full transaction * names since aliases are always lowercase (or all-caps with caps lock). */ -const ALIAS_PATTERN = /^(?:[a-z]+|[A-Z]+)\d*$/; +const ALIAS_PATTERN = /^(?:[a-z]+|[A-Z]+)$/; /** * Check if input looks like a cached alias rather than a full transaction name. * - * Aliases are short, single-case letter strings (with optional numeric suffix). - * Anything containing special characters like `/`, `.`, `-`, `_`, spaces, - * colons, or mixed-case letters is treated as a full transaction name. + * Aliases are short, purely alphabetic, single-case strings. + * Anything containing digits, special characters like `/`, `.`, `-`, `_`, + * spaces, colons, or mixed-case letters is treated as a full transaction name. */ function isAliasLike(input: string): boolean { return input.length <= MAX_ALIAS_LENGTH && ALIAS_PATTERN.test(input); @@ -147,8 +147,8 @@ function buildUnknownRefError( * * Resolution order: * 1. Numeric index: "1", "2", "10" → looks up by cached index - * 2. Alias-shaped input (single-case letters + optional digits, ≤20 chars): - * "i", "e", "iu", "issues2", "I" (caps lock) → looks up by cached alias + * 2. Alias-shaped input (single-case letters only, ≤20 chars): + * "i", "e", "iu", "xissues", "I" (caps lock) → looks up by cached alias * 3. Everything else is treated as a full transaction name and passed through: * "/api/0/...", "tasks.process", "process_request", "handle-webhook", * "ProcessEvent", "GET /users" From 47e63fdc9b262fc73a86a61e0d48797812e43342 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 14:02:52 +0000 Subject: [PATCH 30/32] fix(profile): fix invalid --org flag in error suggestion When project is null, the stale/unknown ref error messages suggested 'sentry profile list --org ' but profile list has no --org flag. Now omits the target when project is unknown, letting auto-detection handle it. --- src/lib/resolve-transaction.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/lib/resolve-transaction.ts b/src/lib/resolve-transaction.ts index 9c28e649..ef4f6ea0 100644 --- a/src/lib/resolve-transaction.ts +++ b/src/lib/resolve-transaction.ts @@ -113,9 +113,11 @@ function buildStaleAliasError( const isNumeric = NUMERIC_PATTERN.test(ref); const refType = isNumeric ? "index" : "alias"; - const listCmd = current.project - ? `sentry profile list ${current.org}/${current.project} --period ${current.period}` - : `sentry profile list --org ${current.org} --period ${current.period}`; + const listCmd = buildListCommand( + current.org, + current.project, + current.period + ); return new ConfigError( `Transaction ${refType} '${ref}' is from a ${reason}.`, @@ -123,6 +125,20 @@ function buildStaleAliasError( ); } +/** + * Build a suggested `sentry profile list` command string. + * Uses positional `/` when a project is known, otherwise + * omits the target to let auto-detection handle it. + */ +function buildListCommand( + org: string, + project: string | null, + period: string +): string { + const target = project ? ` ${org}/${project}` : ""; + return `sentry profile list${target} --period ${period}`; +} + /** * Build error for unknown alias/index. */ @@ -132,9 +148,11 @@ function buildUnknownRefError( ): ConfigError { const isNumeric = NUMERIC_PATTERN.test(ref); const refType = isNumeric ? "index" : "alias"; - const listCmd = options.project - ? `sentry profile list ${options.org}/${options.project} --period ${options.period}` - : `sentry profile list --org ${options.org} --period ${options.period}`; + const listCmd = buildListCommand( + options.org, + options.project, + options.period + ); return new ConfigError( `Unknown transaction ${refType} '${ref}'.`, From 16f269cb89cba43d91c671766bb36217763aca51 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 14:23:18 +0000 Subject: [PATCH 31/32] fix(profile): simplify explicit target resolution and add disambiguation example - Explicit org/project targets now use parsed values directly instead of routing through resolveOrgAndProject (consistent with view.ts) - Add disambiguationExample to resolveProjectBySlug call in list.ts so users see an actionable command when a project exists in multiple orgs --- src/commands/profile/list.ts | 20 +++++++------------- test/commands/profile/list.test.ts | 27 ++++++++++++++++++--------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/commands/profile/list.ts b/src/commands/profile/list.ts index 61bf58a6..da0c40ba 100644 --- a/src/commands/profile/list.ts +++ b/src/commands/profile/list.ts @@ -70,21 +70,15 @@ async function resolveListTarget( "Usage: sentry profile list /" ); - case "explicit": { - const resolved = await resolveOrgAndProject({ - org: parsed.org, - project: parsed.project, - cwd, - usageHint: USAGE_HINT, - }); - if (!resolved) { - throw new ContextError("Organization and project", USAGE_HINT); - } - return resolved; - } + case "explicit": + return { org: parsed.org, project: parsed.project }; case "project-search": - return await resolveProjectBySlug(parsed.projectSlug, USAGE_HINT); + return await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry profile list /${parsed.projectSlug}` + ); case "auto-detect": { const resolved = await resolveOrgAndProject({ diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts index 636decc6..edb95ba8 100644 --- a/test/commands/profile/list.test.ts +++ b/test/commands/profile/list.test.ts @@ -143,17 +143,21 @@ describe("listCommand.func", () => { await expect(func.call(ctx, defaultFlags)).rejects.toThrow(ContextError); }); - test("resolves explicit org/project target", async () => { + test("resolves explicit org/project target directly", async () => { const ctx = createMockContext(); - setupResolvedTarget(); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); const func = await loadListFunc(); await func.call(ctx, defaultFlags, "my-org/backend"); - expect(resolveOrgAndProjectSpy).toHaveBeenCalledWith( - expect.objectContaining({ org: "my-org", project: "backend" }) - ); + // Explicit targets skip resolveOrgAndProject and use parsed values directly + expect(resolveOrgAndProjectSpy).not.toHaveBeenCalled(); + expect(ctx.setContext).toHaveBeenCalledWith(["my-org"], ["backend"]); }); test("resolves project-only target via findProjectsBySlug", async () => { @@ -357,7 +361,7 @@ describe("listCommand.func", () => { ); }); - test("shows detectedFrom hint when present", async () => { + test("shows detectedFrom hint when auto-detected", async () => { const ctx = createMockContext(); setupResolvedTarget({ detectedFrom: ".env file" }); listProfiledTransactionsSpy.mockResolvedValue({ @@ -365,15 +369,20 @@ describe("listCommand.func", () => { }); const func = await loadListFunc(); - await func.call(ctx, defaultFlags, "my-org/backend"); + // No target arg → auto-detect path, which returns detectedFrom + await func.call(ctx, defaultFlags); const output = getOutput(ctx); expect(output).toContain("Detected from .env file"); }); - test("does not show detectedFrom when absent", async () => { + test("does not show detectedFrom for explicit target", async () => { const ctx = createMockContext(); - setupResolvedTarget(); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); listProfiledTransactionsSpy.mockResolvedValue({ data: [{ transaction: "/api/users", "count_unique(timestamp)": 10 }], }); From ff4195fb8603a3919275df9bc3b1a91c1e3f9336 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 17 Feb 2026 14:40:04 +0000 Subject: [PATCH 32/32] fix(profile): use exhaustive switch check in view.ts default case --- src/commands/profile/view.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/profile/view.ts b/src/commands/profile/view.ts index a8594a1e..276c8097 100644 --- a/src/commands/profile/view.ts +++ b/src/commands/profile/view.ts @@ -179,9 +179,13 @@ export const viewCommand = buildCommand({ target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); break; - default: - // Exhaustive check - should never reach here - throw new ContextError("Invalid target specification", USAGE_HINT); + default: { + const _exhaustiveCheck: never = parsed; + throw new ContextError( + `Unexpected target type: ${_exhaustiveCheck}`, + USAGE_HINT + ); + } } if (!target) {