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..21a2e212 --- /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/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index a2ba5469..3165678f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -548,6 +548,59 @@ sentry log view my-org/backend 968c763c740cfda8b6728f27fb9e9b01 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` +- `-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 + +**Flags:** +- `--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` +- `-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 diff --git a/src/app.ts b/src/app.ts index 91d2a302..994e1918 100644 --- a/src/app.ts +++ b/src/app.ts @@ -17,6 +17,7 @@ import { logRoute } from "./commands/log/index.js"; import { listCommand as logListCommand } from "./commands/log/list.js"; import { orgRoute } from "./commands/org/index.js"; import { listCommand as orgListCommand } from "./commands/org/list.js"; +import { profileRoute } from "./commands/profile/index.js"; import { projectRoute } from "./commands/project/index.js"; import { listCommand as projectListCommand } from "./commands/project/list.js"; import { repoRoute } from "./commands/repo/index.js"; @@ -42,6 +43,7 @@ export const routes = buildRouteMap({ issue: issueRoute, event: eventRoute, log: logRoute, + profile: profileRoute, trace: traceRoute, api: apiCommand, issues: issueListCommand, diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index faa72a43..4a24b06e 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -129,6 +129,7 @@ export function parsePositionalArgs(args: string[]): { /** * Resolved target type for event commands. + * Uses ResolvedTarget from resolve-target.ts. * @internal Exported for testing */ export type ResolvedEventTarget = { 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..da0c40ba --- /dev/null +++ b/src/commands/profile/list.ts @@ -0,0 +1,267 @@ +/** + * 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 { openInBrowser } from "../../lib/browser.js"; +import { + buildTransactionFingerprint, + setTransactionAliases, +} from "../../lib/db/transaction-aliases.js"; +import { ContextError } from "../../lib/errors.js"; +import { + divider, + findCommonPrefix, + formatProfileListFooter, + formatProfileListHeader, + formatProfileListRow, + formatProfileListTableHeader, + profileListDividerWidth, + writeJson, +} from "../../lib/formatters/index.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"; +import { parsePeriod } from "./shared.js"; + +type ListFlags = { + readonly period: string; + readonly limit: number; + readonly json: boolean; + readonly web: boolean; +}; + +/** 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; +}; + +/** + * Resolve org/project from parsed argument or auto-detection. + * + * @throws {ContextError} When target cannot be resolved + */ +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": + return { org: parsed.org, project: parsed.project }; + + case "project-search": + return await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry profile list /${parsed.projectSlug}` + ); + + 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 + ); + } + } +} + +/** + * 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, + }, + web: { + kind: "boolean", + brief: "Open in browser", + default: false, + }, + }, + aliases: { n: "limit", w: "web" }, + }, + async func( + this: SentryContext, + flags: ListFlags, + target?: string + ): Promise { + const { stdout, cwd, setContext } = this; + + // Resolve org and project from positional arg or auto-detection + const resolvedTarget = await resolveListTarget(target, cwd); + + // 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, project.id), + "profiling" + ); + return; + } + + // Fetch profiled transactions + const response = await listProfiledTransactions( + resolvedTarget.org, + project.id, + { + statsPeriod: flags.period, + limit: flags.limit, + } + ); + + 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); + return; + } + + // Empty state + if (response.data.length === 0) { + writeEmptyState(stdout, orgProject); + return; + } + + // 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(profileListDividerWidth(hasAliases))}\n`); + + for (const row of response.data) { + const alias = row.transaction ? aliasMap.get(row.transaction) : undefined; + stdout.write( + `${formatProfileListRow(row, { alias, commonPrefix, hasAliases })}\n` + ); + } + + stdout.write(formatProfileListFooter(hasAliases, commonPrefix)); + + if (resolvedTarget.detectedFrom) { + stdout.write(`\n\nDetected from ${resolvedTarget.detectedFrom}\n`); + } + }, +}); 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 new file mode 100644 index 00000000..276c8097 --- /dev/null +++ b/src/commands/profile/view.ts @@ -0,0 +1,268 @@ +/** + * 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 { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../lib/arg-parsing.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, + 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; + readonly limit: number; + readonly allFrames: boolean; + readonly json: boolean; + readonly web: boolean; +}; + +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry profile view / "; + +/** + * 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 profile view command */ +type ResolvedProfileTarget = { + org: string; + project: string; + detectedFrom?: string; +}; + +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" + + "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: "array", + parameter: { + placeholder: "args", + brief: + '[/] - Target (optional) and transaction (required). Transaction can be index (1), alias (i), or full name ("/api/users")', + parse: String, + }, + }, + flags: { + period: { + kind: "parsed", + parse: parsePeriod, + brief: "Stats period: 1h, 24h, 7d, 14d, 30d", + default: "24h", + }, + 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, + ...args: string[] + ): Promise { + const { stdout, cwd, setContext } = this; + + // 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, + }; + break; + + case ProjectSpecificationType.ProjectSearch: { + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry profile view /${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: { + const _exhaustiveCheck: never = parsed; + throw new ContextError( + `Unexpected target type: ${_exhaustiveCheck}`, + USAGE_HINT + ); + } + } + + if (!target) { + throw new ContextError("Organization and project", USAGE_HINT); + } + + // 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]); + + // 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)) { + const listCmd = `sentry profile list ${target.org}/${target.project} --period ${flags.period}`; + stdout.write( + `No profiling data found for transaction "${transactionName}" ` + + `in the last ${flags.period}.\n\n` + ); + stdout.write( + `Run '${listCmd}' to see transactions with available profile data.\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 3e095fca..e498e1bd 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -27,7 +27,11 @@ import type { z } from "zod"; import { DetailedLogsResponseSchema, type DetailedSentryLog, + type Flamegraph, + FlamegraphSchema, LogsResponseSchema, + type ProfileFunctionsResponse, + ProfileFunctionsResponseSchema, type ProjectKey, type Region, type SentryEvent, @@ -1248,6 +1252,86 @@ export async function getCurrentUser(): Promise { return data; } +// 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 async function getFlamegraph( + orgSlug: string, + projectId: string | number, + transactionName: string, + statsPeriod = "24h" +): Promise { + // Escape special characters in transaction name for query + const escapedTransaction = transactionName + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + + const response = await orgScopedRequestPaginated( + `/organizations/${orgSlug}/profiling/flamegraph/`, + { + params: { + project: projectId, + query: `event.type:transaction transaction:"${escapedTransaction}"`, + statsPeriod, + }, + schema: FlamegraphSchema, + } + ); + return response.data; +} + +/** + * 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 async function listProfiledTransactions( + orgSlug: string, + projectId: string | number, + options: { + statsPeriod?: string; + limit?: number; + } = {} +): Promise { + const { statsPeriod = "24h", limit = 20 } = options; + + const response = await orgScopedRequestPaginated( + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "profile_functions", + field: [ + "transaction", + "count_unique(timestamp)", + "p75(function.duration)", + "p95(function.duration)", + ], + statsPeriod, + per_page: limit, + project: projectId, + sort: "-p75(function.duration)", + }, + schema: ProfileFunctionsResponseSchema, + } + ); + return response.data; +} + // Log functions /** Fields to request from the logs API */ diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 32d5000a..c8993b7e 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"; @@ -209,6 +209,22 @@ export const TABLE_SCHEMAS: Record = { ttl_expires_at: { type: "INTEGER", notNull: true }, }, }, + transaction_aliases: { + columns: { + idx: { type: "INTEGER", notNull: true }, + alias: { type: "TEXT", notNull: true }, + transaction_name: { type: "TEXT", notNull: true }, + org_slug: { type: "TEXT", notNull: true }, + project_slug: { type: "TEXT", notNull: true }, + fingerprint: { type: "TEXT", notNull: true }, + cached_at: { + type: "INTEGER", + notNull: true, + default: "(unixepoch() * 1000)", + }, + }, + compositePrimaryKey: ["fingerprint", "idx"], + }, }; /** Generate CREATE TABLE DDL from column definitions */ @@ -382,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)) { @@ -395,6 +417,24 @@ function repairMissingTables(db: Database, result: RepairResult): void { 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 { @@ -552,6 +592,7 @@ export function tryRepairAndRetry( export function initSchema(db: Database): void { const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); db.exec(ddlStatements); + db.exec(TRANSACTION_ALIASES_INDEX); const versionRow = db .query("SELECT version FROM schema_version LIMIT 1") @@ -609,11 +650,17 @@ export function runMigrations(db: Database): void { db.exec(EXPECTED_TABLES.project_root_cache as string); } - // Migration 4 -> 5: Add pagination_cursors table for --cursor last support + // Migration 4 -> 5: Add pagination_cursors table if (currentVersion < 5) { 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 diff --git a/src/lib/db/transaction-aliases.ts b/src/lib/db/transaction-aliases.ts new file mode 100644 index 00000000..fd590337 --- /dev/null +++ b/src/lib/db/transaction-aliases.ts @@ -0,0 +1,210 @@ +/** + * 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). + * 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, + currentFingerprint: string +): string | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT fingerprint FROM transaction_aliases WHERE alias = ? AND fingerprint != ? LIMIT 1" + ) + .get(alias.toLowerCase(), currentFingerprint) as + | { fingerprint: string } + | undefined; + + return row?.fingerprint ?? null; +} + +/** + * Check if an index exists for a different fingerprint (stale check). + * 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, + currentFingerprint: string +): string | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT fingerprint FROM transaction_aliases WHERE idx = ? AND fingerprint != ? LIMIT 1" + ) + .get(idx, currentFingerprint) 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/index.ts b/src/lib/formatters/index.ts index 2a52393b..61a367f3 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -10,5 +10,6 @@ export * from "./human.js"; export * from "./json.js"; export * from "./log.js"; export * from "./output.js"; +export * from "./profile.js"; export * from "./seer.js"; export * from "./trace.js"; diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts new file mode 100644 index 00000000..8d25d883 --- /dev/null +++ b/src/lib/formatters/profile.ts @@ -0,0 +1,353 @@ +/** + * Profile Formatters + * + * Human-readable output formatters for profiling data. + * Formats flamegraph analysis, hot paths, and transaction lists. + */ + +import type { + ProfileAnalysis, + ProfileFunctionRow, + TransactionAliasEntry, +} from "../../types/index.js"; +import { formatDurationMs } from "../profile/analyzer.js"; +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. + */ +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: ${formatDurationMs(percentiles.p75)} ` + + `p95: ${formatDurationMs(percentiles.p95)} ` + + `p99: ${formatDurationMs(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 = 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}`; +} + +/** + * 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 + const locationHeader = "Location".padEnd(LOCATION_COL_WIDTH); + lines.push( + muted(` # ${"Function".padEnd(40)} ${locationHeader} % 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. + * + * @param hasAliases - Whether to include # and ALIAS columns + */ +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) { + // 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}`); +} + +/** + * 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 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, + 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)"]; + const p75 = ( + rawP75 !== null && rawP75 !== undefined + ? formatDurationMs(rawP75 / 1_000_000) // ns to ms + : "-" + ).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. + // 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.startsWith(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); + 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}`; +} + +/** + * Compute the table divider width based on whether aliases are shown. + */ +export function profileListDividerWidth(hasAliases: boolean): number { + // 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; +} + +/** + * 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, + commonPrefix?: string +): string { + const lines: string[] = []; + + if (commonPrefix) { + lines.push(`\n${muted(`Common prefix stripped: ${commonPrefix}`)}`); + } + + if (hasAliases) { + 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 lines.join(""); +} diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts new file mode 100644 index 00000000..9f628517 --- /dev/null +++ b/src/lib/profile/analyzer.ts @@ -0,0 +1,236 @@ +/** + * 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 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 formatDurationMs(ms: number): string { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(1)}s`; + } + if (ms >= 100) { + 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) { + 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) { + 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`; +} + +/** + * 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 self time across all frames. + * This gives the total CPU time spent in all functions. + */ +function getTotalSelfTime(flamegraph: Flamegraph): number { + let total = 0; + for (const info of flamegraph.shared.frame_infos) { + total += info.sumSelfTime; + } + 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 totalSelfTime = getTotalSelfTime(flamegraph); + + if (totalSelfTime === 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 / totalSelfTime) * 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/resolve-transaction.ts b/src/lib/resolve-transaction.ts new file mode 100644 index 00000000..ef4f6ea0 --- /dev/null +++ b/src/lib/resolve-transaction.ts @@ -0,0 +1,237 @@ +/** + * 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+$/; + +/** + * 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. + */ +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). + * + * This mirrors how `buildTransactionAliases` generates aliases: + * segments are lowercased, stripped of hyphens/underscores, then + * `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]+)$/; + +/** + * Check if input looks like a cached alias rather than 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); +} + +/** + * 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 = buildListCommand( + current.org, + current.project, + current.period + ); + + return new ConfigError( + `Transaction ${refType} '${ref}' is from a ${reason}.`, + `Run '${listCmd}' to refresh aliases.` + ); +} + +/** + * 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. + */ +function buildUnknownRefError( + ref: string, + options: ResolveTransactionOptions +): ConfigError { + const isNumeric = NUMERIC_PATTERN.test(ref); + const refType = isNumeric ? "index" : "alias"; + const listCmd = buildListCommand( + options.org, + options.project, + options.period + ); + + return new ConfigError( + `Unknown transaction ${refType} '${ref}'.`, + `Run '${listCmd}' to see available transactions.` + ); +} + +/** + * Resolve a transaction reference to its full name. + * + * Resolution order: + * 1. Numeric index: "1", "2", "10" → looks up by cached index + * 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" + * + * @throws ConfigError if alias/index not found or stale + */ +export function resolveTransaction( + input: string, + options: ResolveTransactionOptions +): ResolvedTransaction { + // Numeric input → look up by index (checked first since "123" is unambiguous) + const currentFingerprint = buildTransactionFingerprint( + options.org, + options.project, + options.period + ); + + 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 in a different context + const staleFingerprint = getStaleIndexFingerprint(idx, currentFingerprint); + if (staleFingerprint) { + throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + } + + throw buildUnknownRefError(input, options); + } + + // 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, + }; + } + + // 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); + } + + 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/src/lib/sentry-urls.ts b/src/lib/sentry-urls.ts index de0e8b0e..e7c9eaf9 100644 --- a/src/lib/sentry-urls.ts +++ b/src/lib/sentry-urls.ts @@ -105,6 +105,39 @@ export function buildBillingUrl(orgSlug: string, product?: string): string { 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%22${encodedTransaction}%22`; +} + +/** + * Build URL to the profiling summary page for a project. + * + * @param orgSlug - Organization 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, + projectId: string | number +): string { + return `${getSentryBaseUrl()}/organizations/${orgSlug}/profiling/?project=${projectId}`; +} + // Logs URLs /** diff --git a/src/lib/transaction-alias.ts b/src/lib/transaction-alias.ts new file mode 100644 index 00000000..22f621b4 --- /dev/null +++ b/src/lib/transaction-alias.ts @@ -0,0 +1,187 @@ +/** + * 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 + 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 + const firstSegment = segments.find( + (s) => + s.length > 0 && !NUMERIC_PATTERN.test(s) && !PLACEHOLDER_PATTERN.test(s) + ); + const fallback = firstSegment?.replace(/[-_]/g, "").toLowerCase(); + return fallback && fallback.length > 0 ? fallback : "txn"; +} + +/** Input for alias generation */ +type TransactionInput = { + /** Full transaction name */ + transaction: string; + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; +}; + +/** + * Disambiguate duplicate segments by prepending "x" characters. + * e.g., ["issues", "events", "issues"] → ["issues", "events", "xissues"] + * + * 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 "x" prefixes for duplicates + */ +function disambiguateSegments(segments: string[]): string[] { + const result: string[] = []; + const resultSet = new Set(); + + for (const segment of segments) { + if (resultSet.has(segment)) { + // Need a prefixed version - find next available + let prefix = "x"; + let candidate = `${prefix}${segment}`; + while (resultSet.has(candidate)) { + prefix += "x"; + candidate = `${prefix}${segment}`; + } + result.push(candidate); + resultSet.add(candidate); + } else { + // Raw segment name is available + result.push(segment); + resultSet.add(segment); + } + } + + 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 + * + * @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/", ... }, + * // ] + * + * @example + * // 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: "x", ... }, // from "xissues" (disambiguated) + * // ] + */ +export function buildTransactionAliases( + transactions: TransactionInput[] +): TransactionAliasEntry[] { + if (transactions.length === 0) { + return []; + } + + // Extract segments from each transaction + const rawSegments = transactions.map((t) => + extractTransactionSegment(t.transaction) + ); + + // 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 + 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 a9bdd291..5128b675 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,6 +30,28 @@ export { TokenErrorResponseSchema, TokenResponseSchema, } from "./oauth.js"; +// Profile types +export type { + Flamegraph, + FlamegraphFrame, + FlamegraphFrameInfo, + FlamegraphProfile, + FlamegraphProfileMetadata, + HotPath, + ProfileAnalysis, + ProfileFunctionRow, + ProfileFunctionsResponse, + TransactionAliasEntry, +} from "./profile.js"; +export { + FlamegraphFrameInfoSchema, + FlamegraphFrameSchema, + FlamegraphProfileMetadataSchema, + FlamegraphProfileSchema, + FlamegraphSchema, + ProfileFunctionRowSchema, + ProfileFunctionsResponseSchema, +} from "./profile.js"; export type { AutofixResponse, AutofixState, @@ -79,7 +101,6 @@ export type { TransactionsResponse, UserRegionsResponse, } from "./sentry.js"; - export { DetailedLogsResponseSchema, DetailedSentryLogSchema, diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 00000000..4a538b09 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,240 @@ +/** + * 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()).nullish(), + /** Sample counts */ + sample_counts: z.array(z.number()).nullish(), + }) + .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 (may be absent when no profiles exist) */ + profiles: z.array(FlamegraphProfileMetadataSchema).optional(), + }), + /** 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 (null when transaction data is missing) */ + transaction: z.string().nullish(), + /** Number of unique profile samples */ + "count_unique(timestamp)": z.number().nullish(), + /** 75th percentile duration */ + "p75(function.duration)": z.number().nullish(), + /** 95th percentile duration */ + "p95(function.duration)": z.number().nullish(), + }) + .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(), + }) + .passthrough(); + +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; +}; + +/** + * 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. + */ +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; +}; diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts new file mode 100644 index 00000000..edb95ba8 --- /dev/null +++ b/test/commands/profile/list.test.ts @@ -0,0 +1,442 @@ +/** + * 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"; +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"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as transactionAliasesDb from "../../../src/lib/db/transaction-aliases.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"; + +/** 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 findProjectsBySlugSpy: ReturnType; +let openInBrowserSpy: ReturnType; +let setTransactionAliasesSpy: ReturnType; + +beforeEach(() => { + resolveOrgAndProjectSpy = spyOn(resolveTarget, "resolveOrgAndProject"); + getProjectSpy = spyOn(apiClient, "getProject"); + listProfiledTransactionsSpy = spyOn(apiClient, "listProfiledTransactions"); + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + openInBrowserSpy = spyOn(browser, "openInBrowser"); + setTransactionAliasesSpy = spyOn( + transactionAliasesDb, + "setTransactionAliases" + ); +}); + +afterEach(() => { + resolveOrgAndProjectSpy.mockRestore(); + getProjectSpy.mockRestore(); + listProfiledTransactionsSpy.mockRestore(); + findProjectsBySlugSpy.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 directly", async () => { + const ctx = createMockContext(); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/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 () => { + const ctx = createMockContext(); + 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(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 ValidationError 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( + ValidationError + ); + }); + + 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_unique(timestamp)": 50 }, + { transaction: "/api/events", "count_unique(timestamp)": 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_unique(timestamp)": 150, + "p75(function.duration)": 8_000_000, + }, + { + transaction: "/api/events", + "count_unique(timestamp)": 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"); + // 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"); + }); + + 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 auto-detected", async () => { + const ctx = createMockContext(); + setupResolvedTarget({ detectedFrom: ".env file" }); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [{ transaction: "/api/users", "count_unique(timestamp)": 10 }], + }); + const func = await loadListFunc(); + + // 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 for explicit target", async () => { + const ctx = createMockContext(); + getProjectSpy.mockResolvedValue({ + id: "12345", + slug: "backend", + name: "Backend", + }); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [{ transaction: "/api/users", "count_unique(timestamp)": 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_unique(timestamp)": 50 }, + { transaction: "/api/events", "count_unique(timestamp)": 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_unique(timestamp)": 50 }, + { "count_unique(timestamp)": 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 new file mode 100644 index 00000000..45d70d59 --- /dev/null +++ b/test/commands/profile/view.test.ts @@ -0,0 +1,560 @@ +/** + * Profile View Command Tests + * + * Tests for positional argument parsing, project resolution, + * and command execution in src/commands/profile/view.ts. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { + parsePositionalArgs, + 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, 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"; + +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(""); + }); + }); +}); + +// resolveProjectBySlug tests (profile context) + +describe("resolveProjectBySlug (profile context)", () => { + let findProjectsBySlugSpy: ReturnType; + + const HINT = "sentry profile view / "; + + beforeEach(() => { + findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); + }); + + afterEach(() => { + findProjectsBySlugSpy.mockRestore(); + }); + + describe("no projects found", () => { + test("throws ContextError when project not found", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); + }); + + test("includes project name in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + try { + await resolveProjectBySlug("frontend", HINT); + 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 ValidationError when project exists in multiple orgs", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "frontend", + id: "1", + name: "Frontend", + orgSlug: "org-a", + }, + { + slug: "frontend", + id: "2", + name: "Frontend", + orgSlug: "org-b", + }, + ] as ProjectWithOrg[]); + + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); + }); + + test("includes org alternatives in error", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "api", + id: "1", + name: "API", + orgSlug: "acme", + }, + { + slug: "api", + id: "2", + name: "API", + orgSlug: "beta", + }, + ] as ProjectWithOrg[]); + + try { + await resolveProjectBySlug("api", HINT); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const msg = (error as ValidationError).message; + expect(msg).toContain("multiple organizations"); + } + }); + }); + + describe("single project found", () => { + test("returns resolved target using orgSlug", async () => { + findProjectsBySlugSpy.mockResolvedValue([ + { + slug: "backend", + id: "42", + name: "Backend", + orgSlug: "my-company", + }, + ] as ProjectWithOrg[]); + + const result = await resolveProjectBySlug("backend", HINT); + + expect(result.org).toBe("my-company"); + expect(result.project).toBe("backend"); + }); + }); +}); + +// 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"); + }); + }); +}); diff --git a/test/lib/db/transaction-aliases.test.ts b/test/lib/db/transaction-aliases.test.ts new file mode 100644 index 00000000..9b9e6888 --- /dev/null +++ b/test/lib/db/transaction-aliases.test.ts @@ -0,0 +1,382 @@ +/** + * 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 in different context", () => { + const oldFp = "old-org:old-project:7d"; + const currentFp = "new-org:new-project:24h"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "old-org", + projectSlug: "old-project", + }, + ], + oldFp + ); + + 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", "any:fp:here"); + expect(stale).toBeNull(); + }); + + 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( + [ + { + idx: 5, + alias: "test", + transaction: "/api/test/", + orgSlug: "old-org", + projectSlug: "old-project", + }, + ], + oldFp + ); + + 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, "any:fp:here"); + 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/formatters/profile.test.ts b/test/lib/formatters/profile.test.ts new file mode 100644 index 00000000..b1cf6bc3 --- /dev/null +++ b/test/lib/formatters/profile.test.ts @@ -0,0 +1,403 @@ +/** + * Profile Formatter Tests + * + * Tests for profiling output formatters in src/lib/formatters/profile.ts. + */ + +import { describe, expect, test } from "bun:test"; +import { + findCommonPrefix, + formatProfileAnalysis, + formatProfileListFooter, + formatProfileListHeader, + formatProfileListRow, + formatProfileListTableHeader, + truncateMiddle, +} 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("SAMPLES"); + 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("SAMPLES"); + expect(result).toContain("p75"); + expect(result).toContain("p95"); + }); + + test("defaults to no aliases", () => { + const result = stripAnsi(formatProfileListTableHeader()); + expect(result).not.toContain("ALIAS"); + }); +}); + +// formatProfileListRow + +describe("formatProfileListRow", () => { + 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 + }; + + const result = stripAnsi(formatProfileListRow(row)); + + expect(result).toContain("/api/users"); + expect(result).toContain("42"); + expect(result).toContain("8.00ms"); + expect(result).toContain("15.0ms"); + }); + + test("formats row with alias when provided", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count_unique(timestamp)": 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 p75 and p95", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + }; + + const result = stripAnsi(formatProfileListRow(row)); + // Both p75 and p95 should show "-" when missing + const dashes = result.match(/-/g); + expect(dashes?.length).toBeGreaterThanOrEqual(2); + }); + + test("handles missing transaction name", () => { + const row: ProfileFunctionRow = { + "count_unique(timestamp)": 10, + "p75(function.duration)": 5_000_000, + }; + + const result = stripAnsi(formatProfileListRow(row)); + 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/"; + const row: ProfileFunctionRow = { + transaction: longTransaction, + "count_unique(timestamp)": 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(""); + }); +}); + +// 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."); + }); +}); diff --git a/test/lib/profile/analyzer.test.ts b/test/lib/profile/analyzer.test.ts new file mode 100644 index 00000000..9aea19d4 --- /dev/null +++ b/test/lib/profile/analyzer.test.ts @@ -0,0 +1,488 @@ +/** + * 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, + formatDurationMs, + 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("formatDurationMs", () => { + test("formats seconds for values >= 1000ms", () => { + 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(formatDurationMs(100)).toBe("100ms"); + expect(formatDurationMs(999)).toBe("999ms"); + expect(formatDurationMs(500)).toBe("500ms"); + }); + + test("formats 1 decimal place for values >= 10ms", () => { + 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(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(formatDurationMs(0.5)).toBe("500\u00B5s"); + expect(formatDurationMs(0.001)).toBe("1\u00B5s"); + }); + + test("formats nanoseconds for sub-microsecond values", () => { + expect(formatDurationMs(0.0001)).toBe("100ns"); + 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("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) => { + const result = formatDurationMs(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(formatDurationMs(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/resolve-transaction.test.ts b/test/lib/resolve-transaction.test.ts new file mode 100644 index 00000000..87faf83c --- /dev/null +++ b/test/lib/resolve-transaction.test.ts @@ -0,0 +1,392 @@ +/** + * 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(""); + }); + + 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"); + }); +}); + +// ============================================================================= +// 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", () => { + beforeEach(() => { + clearTransactionAliases(); + }); + + 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/sentry-urls.property.test.ts b/test/lib/sentry-urls.property.test.ts index ac645104..528e9ae7 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,109 @@ 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 quoted transaction query", async () => { + await fcAssert( + property( + tuple(slugArb, slugArb, transactionNameArb), + ([orgSlug, projectSlug, transaction]) => { + const result = buildProfileUrl(orgSlug, projectSlug, transaction); + expect(result).toContain("/flamegraph/"); + // 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 } + ); + }); +}); + +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 +589,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 new file mode 100644 index 00000000..9d42e518 --- /dev/null +++ b/test/lib/transaction-alias.property.test.ts @@ -0,0 +1,460 @@ +/** + * 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 } + ); + }); +}); + +// Edge cases for disambiguateSegments (internal function tested via buildTransactionAliases) + +describe("disambiguateSegments collision handling", () => { + 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" + // - "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/xissues/", 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, xissues, xxissues, issues + const inputs = [ + { transaction: "/a/issues/", 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" }, + ]; + + 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", () => { + 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"); + }); + + 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 + +describe("property: alias lookup invariants", () => { + test("alias is a prefix of the extracted segment (unique transactions)", () => { + // Use uniqueArray to avoid duplicate transactions, since disambiguateSegments + // prepends "x" prefixes to duplicates which changes the string relative to + // the raw extracted segment. + fcAssert( + property( + uniqueArray(transactionInputArb, { + minLength: 1, + maxLength: 10, + comparator: (a, b) => a.transaction === b.transaction, + }), + (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 } + ); + }); +});