diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 42028f71..3101f5a2 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -500,6 +500,31 @@ 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` + +#### `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` + ### Issues List issues in a project diff --git a/src/app.ts b/src/app.ts index 7119cedb..10dcdaf1 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 { CLI_VERSION } from "./lib/constants.js"; @@ -34,6 +35,7 @@ export const routes = buildRouteMap({ issue: issueRoute, event: eventRoute, log: logRoute, + profile: profileRoute, api: apiCommand, issues: issueListCommand, orgs: orgListCommand, diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index be8b273e..5addb8bd 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getEvent } from "../../lib/api-client.js"; +import { getEvent } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -15,7 +15,11 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + type ResolvedTarget, + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -95,57 +99,10 @@ export function parsePositionalArgs(args: string[]): { /** * Resolved target type for event commands. + * Uses ResolvedTarget from resolve-target.ts. * @internal Exported for testing */ -export type ResolvedEventTarget = { - org: string; - project: string; - orgDisplay: string; - projectDisplay: string; - detectedFrom?: string; -}; - -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param eventId - Event ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - eventId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry event view /${projectSlug} ${eventId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - orgDisplay: foundProject.orgSlug, - projectDisplay: foundProject.slug, - }; -} +export type ResolvedEventTarget = ResolvedTarget; export const viewCommand = buildCommand({ docs: { @@ -206,7 +163,10 @@ export const viewCommand = buildCommand({ break; case ProjectSpecificationType.ProjectSearch: - target = await resolveFromProjectSearch(parsed.projectSlug, eventId); + target = await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + contextValue: eventId, + }); break; case ProjectSpecificationType.OrgAll: diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 3720a804..58b6ae11 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -6,12 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getLog } from "../../lib/api-client.js"; +import { getLog } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import type { DetailedSentryLog, Writer } from "../../types/index.js"; @@ -68,46 +71,6 @@ export type ResolvedLogTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param logId - Log ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - logId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry log view /${projectSlug} ${logId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - }; -} - /** * Write human-readable log output to stdout. * @@ -187,7 +150,10 @@ export const viewCommand = buildCommand({ break; case "project-search": - target = await resolveFromProjectSearch(parsed.projectSlug, logId); + target = await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + contextValue: logId, + }); break; case "org-all": diff --git a/src/commands/profile/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..dbfbecd5 --- /dev/null +++ b/src/commands/profile/list.ts @@ -0,0 +1,264 @@ +/** + * 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, + formatProfileListFooter, + formatProfileListHeader, + formatProfileListRow, + formatProfileListTableHeader, + 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": { + const resolved = await resolveOrgAndProject({ + org: parsed.org, + project: parsed.project, + cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + return resolved; + } + + case "project-search": + return await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + }); + + 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; + stdout.write(`${formatProfileListHeader(orgProject, flags.period)}\n\n`); + stdout.write(`${formatProfileListTableHeader(hasAliases)}\n`); + stdout.write(`${divider(82)}\n`); + + for (const row of response.data) { + const alias = row.transaction ? aliasMap.get(row.transaction) : undefined; + stdout.write(`${formatProfileListRow(row, alias)}\n`); + } + + stdout.write(formatProfileListFooter(hasAliases)); + + 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..4d3815e1 --- /dev/null +++ b/src/commands/profile/view.ts @@ -0,0 +1,262 @@ +/** + * 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 { + type ResolvedTarget, + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; +import { resolveTransaction } from "../../lib/resolve-transaction.js"; +import { buildProfileUrl } from "../../lib/sentry-urls.js"; +import { parsePeriod } from "./shared.js"; + +type ViewFlags = { + readonly period: string; + 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 internal use */ +type ResolvedProfileTarget = ResolvedTarget; + +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, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }; + break; + + case ProjectSpecificationType.ProjectSearch: + target = await resolveProjectBySlug(parsed.projectSlug, { + usageHint: USAGE_HINT, + contextValue: transactionRef, + }); + break; + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "A specific project is required for profile view", + USAGE_HINT + ); + + case ProjectSpecificationType.AutoDetect: + target = await resolveOrgAndProject({ cwd, usageHint: USAGE_HINT }); + break; + + default: + // Exhaustive check - should never reach here + throw new ContextError("Invalid target specification", USAGE_HINT); + } + + if (!target) { + throw new ContextError("Organization and project", 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)) { + stdout.write( + `No profiling data found for transaction "${transactionName}".\n\n` + ); + stdout.write( + "Make sure:\n" + + " 1. Profiling is enabled for your project\n" + + " 2. The transaction name is correct\n" + + " 3. Profile data has been collected in the specified period\n" + ); + return; + } + + // Clamp limit to valid range + const limit = Math.min(Math.max(flags.limit, 1), 20); + + // Analyze the flamegraph + const analysis = analyzeFlamegraph(flamegraph, { + transactionName, + period: flags.period, + limit, + userCodeOnly: !flags.allFrames, + }); + + // JSON output + if (flags.json) { + writeJson(stdout, analysis); + return; + } + + // Human-readable output + const lines = formatProfileAnalysis(analysis); + stdout.write(`${lines.join("\n")}\n`); + + if (target.detectedFrom) { + stdout.write(`\n${muted(`Detected from ${target.detectedFrom}`)}\n`); + } + }, +}); diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e21a94da..1a6f138e 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -11,8 +11,12 @@ import { type DetailedLogsResponse, DetailedLogsResponseSchema, type DetailedSentryLog, + type Flamegraph, + FlamegraphSchema, type LogsResponse, LogsResponseSchema, + type ProfileFunctionsResponse, + ProfileFunctionsResponseSchema, type ProjectKey, ProjectKeySchema, type Region, @@ -1004,6 +1008,82 @@ export function getCurrentUser(): Promise { }); } +// Profiling API + +/** + * Get flamegraph data for a transaction. + * Returns aggregated profiling data across all samples for the given transaction. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @param projectId - Project ID (numeric) + * @param transactionName - Transaction name to analyze + * @param statsPeriod - Time period to aggregate (e.g., "7d", "24h") + * @returns Flamegraph data with frames, samples, and statistics + */ +export function getFlamegraph( + orgSlug: string, + projectId: string | number, + transactionName: string, + statsPeriod = "24h" +): Promise { + // Escape special characters in transaction name for query + const escapedTransaction = transactionName + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + + return orgScopedRequest( + `/organizations/${orgSlug}/profiling/flamegraph/`, + { + params: { + project: projectId, + query: `event.type:transaction transaction:"${escapedTransaction}"`, + statsPeriod, + }, + schema: FlamegraphSchema, + } + ); +} + +/** + * List transactions with profiling data using the Explore Events API. + * Queries the profile_functions dataset to find transactions with profiles. + * Uses region-aware routing for multi-region support. + * + * @param orgSlug - Organization slug + * @param projectId - Project ID (numeric) + * @param options - Query options + * @returns List of transactions with profile counts and timing data + */ +export function listProfiledTransactions( + orgSlug: string, + projectId: string | number, + options: { + statsPeriod?: string; + limit?: number; + } = {} +): Promise { + const { statsPeriod = "24h", limit = 20 } = options; + + return orgScopedRequest( + `/organizations/${orgSlug}/events/`, + { + params: { + dataset: "profile_functions", + field: ["transaction", "count()", "p75(function.duration)"], + statsPeriod, + per_page: limit, + project: projectId, + // Sort by count descending to show most active transactions first + sort: "-count()", + }, + schema: ProfileFunctionsResponseSchema, + } + ); +} + +// Logs API + /** Fields to request from the logs API */ const LOG_FIELDS = [ "sentry.item_id", diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 8f72a0ab..34dce0a4 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 = 4; +export const CURRENT_SCHEMA_VERSION = 5; /** Environment variable to disable auto-repair */ const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR"; @@ -193,6 +193,21 @@ 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)", + }, + }, + }, }; /** Generate CREATE TABLE DDL from column definitions */ @@ -357,8 +372,33 @@ export type RepairResult = { failed: string[]; }; +/** Tables that require custom DDL (not auto-generated from TABLE_SCHEMAS) */ +const CUSTOM_DDL_TABLES = new Set(["transaction_aliases"]); + +function repairTransactionAliasesTable( + db: Database, + result: RepairResult +): void { + if (tableExists(db, "transaction_aliases")) { + return; + } + try { + db.exec(TRANSACTION_ALIASES_DDL); + db.exec(TRANSACTION_ALIASES_INDEX); + result.fixed.push("Created table transaction_aliases"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result.failed.push(`Failed to create table transaction_aliases: ${msg}`); + } +} + function repairMissingTables(db: Database, result: RepairResult): void { for (const [tableName, ddl] of Object.entries(EXPECTED_TABLES)) { + // Skip tables that need custom DDL + if (CUSTOM_DDL_TABLES.has(tableName)) { + continue; + } + if (tableExists(db, tableName)) { continue; } @@ -370,6 +410,9 @@ function repairMissingTables(db: Database, result: RepairResult): void { result.failed.push(`Failed to create table ${tableName}: ${msg}`); } } + + // Handle tables with custom DDL + repairTransactionAliasesTable(db, result); } function repairMissingColumns(db: Database, result: RepairResult): void { @@ -508,11 +551,40 @@ export function tryRepairAndRetry( return { attempted: false }; } +/** + * Custom DDL for transaction_aliases table with composite primary key. + * TABLE_SCHEMAS doesn't support composite primary keys, so we handle this specially. + */ +const TRANSACTION_ALIASES_DDL = ` + CREATE TABLE IF NOT EXISTS transaction_aliases ( + idx INTEGER NOT NULL, + alias TEXT NOT NULL, + transaction_name TEXT NOT NULL, + org_slug TEXT NOT NULL, + project_slug TEXT NOT NULL, + fingerprint TEXT NOT NULL, + cached_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), + PRIMARY KEY (fingerprint, idx) + ) +`; + +const TRANSACTION_ALIASES_INDEX = ` + CREATE INDEX IF NOT EXISTS idx_txn_alias_lookup + ON transaction_aliases(alias, fingerprint) +`; + export function initSchema(db: Database): void { - // Generate combined DDL from all table schemas - const ddlStatements = Object.values(EXPECTED_TABLES).join(";\n\n"); + // Generate combined DDL from all table schemas (except transaction_aliases which has custom DDL) + const ddlStatements = Object.entries(EXPECTED_TABLES) + .filter(([name]) => name !== "transaction_aliases") + .map(([, ddl]) => ddl) + .join(";\n\n"); db.exec(ddlStatements); + // Add transaction_aliases with composite primary key + db.exec(TRANSACTION_ALIASES_DDL); + db.exec(TRANSACTION_ALIASES_INDEX); + const versionRow = db .query("SELECT version FROM schema_version LIMIT 1") .get() as { version: number } | null; @@ -569,6 +641,13 @@ export function runMigrations(db: Database): void { db.exec(EXPECTED_TABLES.project_root_cache as string); } + // Migration 4 -> 5: Add transaction_aliases table for profile commands + // Note: Uses custom DDL because TABLE_SCHEMAS doesn't support composite primary keys + if (currentVersion < 5) { + db.exec(TRANSACTION_ALIASES_DDL); + 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..c5a35b85 --- /dev/null +++ b/src/lib/db/transaction-aliases.ts @@ -0,0 +1,192 @@ +/** + * Transaction aliases storage for profile commands. + * Enables short references like "1" or "i" for transactions from `profile list`. + */ + +import type { TransactionAliasEntry } from "../../types/index.js"; +import { getDatabase } from "./index.js"; + +type TransactionAliasRow = { + idx: number; + alias: string; + transaction_name: string; + org_slug: string; + project_slug: string; + fingerprint: string; + cached_at: number; +}; + +/** + * Build a fingerprint for cache validation. + * Format: "orgSlug:projectSlug:period" or "orgSlug:*:period" for multi-project. + */ +export function buildTransactionFingerprint( + orgSlug: string, + projectSlug: string | null, + period: string +): string { + return `${orgSlug}:${projectSlug ?? "*"}:${period}`; +} + +/** + * Store transaction aliases from a profile list command. + * Replaces any existing aliases for the same fingerprint. + */ +export function setTransactionAliases( + aliases: TransactionAliasEntry[], + fingerprint: string +): void { + const db = getDatabase(); + const now = Date.now(); + + db.exec("BEGIN TRANSACTION"); + + try { + // Delete only aliases with the same fingerprint + db.query("DELETE FROM transaction_aliases WHERE fingerprint = ?").run( + fingerprint + ); + + const insertStmt = db.query(` + INSERT INTO transaction_aliases + (idx, alias, transaction_name, org_slug, project_slug, fingerprint, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + for (const entry of aliases) { + insertStmt.run( + entry.idx, + entry.alias.toLowerCase(), + entry.transaction, + entry.orgSlug, + entry.projectSlug, + fingerprint, + now + ); + } + + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +/** + * Look up transaction by numeric index. + * Returns null if not found or fingerprint doesn't match. + */ +export function getTransactionByIndex( + idx: number, + fingerprint: string +): TransactionAliasEntry | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT * FROM transaction_aliases WHERE idx = ? AND fingerprint = ?" + ) + .get(idx, fingerprint) as TransactionAliasRow | undefined; + + if (!row) { + return null; + } + + return { + idx: row.idx, + alias: row.alias, + transaction: row.transaction_name, + orgSlug: row.org_slug, + projectSlug: row.project_slug, + }; +} + +/** + * Look up transaction by alias. + * Returns null if not found or fingerprint doesn't match. + */ +export function getTransactionByAlias( + alias: string, + fingerprint: string +): TransactionAliasEntry | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT * FROM transaction_aliases WHERE alias = ? AND fingerprint = ?" + ) + .get(alias.toLowerCase(), fingerprint) as TransactionAliasRow | undefined; + + if (!row) { + return null; + } + + return { + idx: row.idx, + alias: row.alias, + transaction: row.transaction_name, + orgSlug: row.org_slug, + projectSlug: row.project_slug, + }; +} + +/** + * Get all cached aliases for a fingerprint. + */ +export function getTransactionAliases( + fingerprint: string +): TransactionAliasEntry[] { + const db = getDatabase(); + + const rows = db + .query( + "SELECT * FROM transaction_aliases WHERE fingerprint = ? ORDER BY idx" + ) + .all(fingerprint) as TransactionAliasRow[]; + + return rows.map((row) => ({ + idx: row.idx, + alias: row.alias, + transaction: row.transaction_name, + orgSlug: row.org_slug, + projectSlug: row.project_slug, + })); +} + +/** + * Check if an alias exists for a different fingerprint (stale check). + * Returns the stale fingerprint if found, null otherwise. + */ +export function getStaleFingerprint(alias: string): string | null { + const db = getDatabase(); + + const row = db + .query( + "SELECT fingerprint FROM transaction_aliases WHERE alias = ? LIMIT 1" + ) + .get(alias.toLowerCase()) as { fingerprint: string } | undefined; + + return row?.fingerprint ?? null; +} + +/** + * Check if an index exists for a different fingerprint (stale check). + * Returns the stale fingerprint if found, null otherwise. + */ +export function getStaleIndexFingerprint(idx: number): string | null { + const db = getDatabase(); + + const row = db + .query("SELECT fingerprint FROM transaction_aliases WHERE idx = ? LIMIT 1") + .get(idx) as { fingerprint: string } | undefined; + + return row?.fingerprint ?? null; +} + +/** + * Clear all transaction aliases. + */ +export function clearTransactionAliases(): void { + const db = getDatabase(); + db.query("DELETE FROM transaction_aliases").run(); +} diff --git a/src/lib/formatters/index.ts b/src/lib/formatters/index.ts index f9fec70d..42e9e286 100644 --- a/src/lib/formatters/index.ts +++ b/src/lib/formatters/index.ts @@ -10,4 +10,5 @@ export * from "./human.js"; export * from "./json.js"; export * from "./log.js"; export * from "./output.js"; +export * from "./profile.js"; export * from "./seer.js"; diff --git a/src/lib/formatters/profile.ts b/src/lib/formatters/profile.ts new file mode 100644 index 00000000..75300a1a --- /dev/null +++ b/src/lib/formatters/profile.ts @@ -0,0 +1,236 @@ +/** + * 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; + +/** + * 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 = frame.name.slice(0, 40).padEnd(40); + const location = `${frame.file}:${frame.line}`.slice(0, 20).padEnd(20); + const pct = `${percentage.toFixed(1)}%`.padStart(7); + + return ` ${num} ${funcName} ${location} ${pct}`; +} + +/** + * Format the hot paths table. + */ +function formatHotPaths(analysis: ProfileAnalysis): string[] { + const { hotPaths, userCodeOnly } = analysis; + const lines: string[] = []; + + lines.push(""); + const title = userCodeOnly + ? `Hot Paths (Top ${hotPaths.length} by CPU time, user code only)` + : `Hot Paths (Top ${hotPaths.length} by CPU time)`; + lines.push(...formatSectionHeader(title)); + + // Table header + lines.push( + muted( + " # Function File:Line % Time" + ) + ); + + if (hotPaths.length === 0) { + lines.push(muted(" No profile data available.")); + return lines; + } + + // Table rows + for (let i = 0; i < hotPaths.length; i++) { + const hotPath = hotPaths[i]; + if (!hotPath) { + continue; + } + const frame = hotPath.frames[0]; + if (!frame) { + continue; + } + lines.push( + formatHotPathRow( + i, + { name: frame.name, file: frame.file, line: frame.line }, + hotPath.percentage + ) + ); + } + + return lines; +} + +/** + * Format recommendations based on hot paths. + */ +function formatRecommendations(analysis: ProfileAnalysis): string[] { + const { hotPaths } = analysis; + const lines: string[] = []; + + if (hotPaths.length === 0) { + return lines; + } + + const topHotPath = hotPaths[0]; + if (!topHotPath || topHotPath.percentage < 10) { + return lines; + } + + const topFrame = topHotPath.frames[0]; + if (!topFrame) { + return lines; + } + + lines.push(""); + lines.push(...formatSectionHeader("Recommendations")); + lines.push( + ` ${yellow("⚠")} ${topFrame.name} is consuming ${topHotPath.percentage.toFixed(1)}% of CPU time` + ); + lines.push(" Consider optimizing this function or caching its results."); + + return lines; +} + +/** + * Format a complete profile analysis for human-readable output. + * + * @param analysis - The analyzed profile data + * @returns Array of formatted lines + */ +export function formatProfileAnalysis(analysis: ProfileAnalysis): string[] { + const lines: string[] = []; + + lines.push(...formatProfileHeader(analysis)); + lines.push(...formatPercentiles(analysis)); + lines.push(...formatHotPaths(analysis)); + lines.push(...formatRecommendations(analysis)); + + return lines; +} + +/** + * Format the transaction list header for profile list command. + * + * @param orgProject - Organization/project display string + * @param period - Time period being displayed + * @returns Formatted header string + */ +export function formatProfileListHeader( + orgProject: string, + period: string +): string { + return `Transactions with Profiles in ${orgProject} (last ${period}):`; +} + +/** + * Format the column headers for the transaction list table. + * + * @param hasAliases - Whether to include # and ALIAS columns + */ +export function formatProfileListTableHeader(hasAliases = false): string { + if (hasAliases) { + return muted( + " # ALIAS TRANSACTION PROFILES p75" + ); + } + return muted( + " TRANSACTION PROFILES p75" + ); +} + +/** + * Format a single transaction row for the list. + * + * @param row - Profile function row data + * @param alias - Optional alias entry for this transaction + * @returns Formatted row string + */ +export function formatProfileListRow( + row: ProfileFunctionRow, + alias?: TransactionAliasEntry +): string { + const count = `${row["count()"] ?? 0}`.padStart(10); + const p75Ms = row["p75(function.duration)"] + ? formatDurationMs(row["p75(function.duration)"] / 1_000_000) // ns to ms + : "-"; + const p75 = p75Ms.padStart(10); + + if (alias) { + const idx = `${alias.idx}`.padStart(3); + const aliasStr = alias.alias.padEnd(6); + const transaction = (row.transaction ?? "unknown").slice(0, 42).padEnd(42); + return ` ${idx} ${aliasStr} ${transaction} ${count} ${p75}`; + } + + const transaction = (row.transaction ?? "unknown").slice(0, 48).padEnd(48); + return ` ${transaction} ${count} ${p75}`; +} + +/** + * Format the footer tip for profile list command. + * + * @param hasAliases - Whether aliases are available for quick access + */ +export function formatProfileListFooter(hasAliases = false): string { + if (hasAliases) { + return "\nTip: Use 'sentry profile view 1' or 'sentry profile view ' to analyze."; + } + return "\nTip: Use 'sentry profile view \"\"' to analyze."; +} diff --git a/src/lib/profile/analyzer.ts b/src/lib/profile/analyzer.ts new file mode 100644 index 00000000..294a73a9 --- /dev/null +++ b/src/lib/profile/analyzer.ts @@ -0,0 +1,216 @@ +/** + * 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) { + return `${Math.round(ms)}ms`; + } + if (ms >= 10) { + return `${ms.toFixed(1)}ms`; + } + if (ms >= 1) { + return `${ms.toFixed(2)}ms`; + } + // Sub-millisecond + const us = ms * 1000; + if (us >= 1) { + return `${us.toFixed(0)}µs`; + } + return `${(us * 1000).toFixed(0)}ns`; +} + +/** + * Check if a flamegraph has valid profile data. + */ +export function hasProfileData(flamegraph: Flamegraph): boolean { + return ( + flamegraph.profiles.length > 0 && + flamegraph.shared.frames.length > 0 && + flamegraph.shared.frame_infos.length > 0 + ); +} + +/** + * Get the total 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-target.ts b/src/lib/resolve-target.ts index 3c503f3f..3ff598c8 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -15,6 +15,7 @@ import { basename } from "node:path"; import { findProjectByDsnKey, findProjectsByPattern, + findProjectsBySlug, getProject, } from "./api-client.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; @@ -33,7 +34,7 @@ import { formatMultipleProjectsFooter, getDsnSourceDescription, } from "./dsn/index.js"; -import { AuthError, ContextError } from "./errors.js"; +import { AuthError, ContextError, ValidationError } from "./errors.js"; /** * Resolved organization and project target for API calls. @@ -680,3 +681,67 @@ export async function resolveOrg( return null; } } + +/** + * Options for resolving a project by slug. + */ +export type ResolveProjectBySlugOptions = { + /** Usage hint shown in error messages */ + usageHint: string; + /** Additional context for error messages (e.g., event ID, log ID) */ + contextValue?: string; +}; + +/** + * Resolve a project by slug across all accessible organizations. + * + * Searches for a project by slug. Throws if no project found or if + * multiple projects with the same slug exist in different organizations. + * + * @param projectSlug - Project slug to search for + * @param options - Resolution options with usage hint and optional context + * @returns Resolved target with org and project info + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + * + * @example + * ```typescript + * const target = await resolveProjectBySlug("my-project", { + * usageHint: "sentry event view / ", + * contextValue: eventId, + * }); + * ``` + */ +export async function resolveProjectBySlug( + projectSlug: string, + options: ResolveProjectBySlugOptions +): Promise { + const { usageHint, contextValue } = options; + + const found = await findProjectsBySlug(projectSlug); + + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, usageHint, [ + "Check that you have access to a project with this slug", + ]); + } + + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + const contextSuffix = contextValue ? ` ${contextValue}` : ""; + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}\n\n` + + `Example: sentry /${projectSlug}${contextSuffix}` + ); + } + + // Safe assertion: length is exactly 1 after the checks above + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + orgDisplay: foundProject.orgSlug, + projectDisplay: foundProject.slug, + }; +} diff --git a/src/lib/resolve-transaction.ts b/src/lib/resolve-transaction.ts new file mode 100644 index 00000000..bd49ff1a --- /dev/null +++ b/src/lib/resolve-transaction.ts @@ -0,0 +1,187 @@ +/** + * Transaction resolver for profile commands. + * + * Resolves transaction references (numbers, aliases, or full names) to full transaction names. + * Works with the cached transaction aliases from `profile list`. + */ + +import { + buildTransactionFingerprint, + getStaleFingerprint, + getStaleIndexFingerprint, + getTransactionByAlias, + getTransactionByIndex, +} from "./db/transaction-aliases.js"; +import { ConfigError } from "./errors.js"; + +/** Resolved transaction with full name and context */ +export type ResolvedTransaction = { + /** Full transaction name */ + transaction: string; + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; +}; + +/** Options for transaction resolution */ +export type ResolveTransactionOptions = { + /** Organization slug (required for fingerprint) */ + org: string; + /** Project slug (null for multi-project lists) */ + project: string | null; + /** Time period (required for fingerprint validation) */ + period: string; +}; + +/** Pattern to detect numeric-only input */ +const NUMERIC_PATTERN = /^\d+$/; + +/** + * Check if input is a full transaction name (contains / or .). + * Full names are passed through without alias lookup. + */ +function isFullTransactionName(input: string): boolean { + return input.includes("/") || input.includes("."); +} + +/** + * Parse the stale fingerprint to extract period for error messages. + * Fingerprint format: "orgSlug:projectSlug:period" + */ +function parseFingerprint(fingerprint: string): { + org: string; + project: string | null; + period: string; +} { + const parts = fingerprint.split(":"); + return { + org: parts[0] ?? "", + project: parts[1] === "*" ? null : (parts[1] ?? null), + period: parts[2] ?? "", + }; +} + +/** + * Build a helpful error message for stale alias references. + */ +function buildStaleAliasError( + ref: string, + staleFingerprint: string, + currentFingerprint: string +): ConfigError { + const stale = parseFingerprint(staleFingerprint); + const current = parseFingerprint(currentFingerprint); + + let reason = ""; + if (stale.period !== current.period) { + reason = `different time period (cached: ${stale.period}, requested: ${current.period})`; + } else if (stale.project !== current.project) { + reason = `different project (cached: ${stale.project ?? "all"}, requested: ${current.project ?? "all"})`; + } else if (stale.org !== current.org) { + reason = `different organization (cached: ${stale.org}, requested: ${current.org})`; + } else { + reason = "different context"; + } + + const isNumeric = NUMERIC_PATTERN.test(ref); + const refType = isNumeric ? "index" : "alias"; + const listCmd = current.project + ? `sentry profile list ${current.org}/${current.project} --period ${current.period}` + : `sentry profile list --org ${current.org} --period ${current.period}`; + + return new ConfigError( + `Transaction ${refType} '${ref}' is from a ${reason}.`, + `Run '${listCmd}' to refresh aliases.` + ); +} + +/** + * Build error for unknown alias/index. + */ +function buildUnknownRefError( + ref: string, + options: ResolveTransactionOptions +): ConfigError { + const isNumeric = NUMERIC_PATTERN.test(ref); + const refType = isNumeric ? "index" : "alias"; + const listCmd = options.project + ? `sentry profile list ${options.org}/${options.project} --period ${options.period}` + : `sentry profile list --org ${options.org} --period ${options.period}`; + + return new ConfigError( + `Unknown transaction ${refType} '${ref}'.`, + `Run '${listCmd}' to see available transactions.` + ); +} + +/** + * Resolve a transaction reference to its full name. + * + * Accepts: + * - Numeric index: "1", "2", "10" → looks up by cached index + * - Alias: "i", "e", "iu" → looks up by cached alias + * - Full transaction name: "/api/0/..." or "tasks.process" → passed through + * + * @throws ConfigError if alias/index not found or stale + */ +export function resolveTransaction( + input: string, + options: ResolveTransactionOptions +): ResolvedTransaction { + // Full transaction names pass through directly + if (isFullTransactionName(input)) { + return { + transaction: input, + orgSlug: options.org, + projectSlug: options.project ?? "", + }; + } + + const currentFingerprint = buildTransactionFingerprint( + options.org, + options.project, + options.period + ); + + // Numeric input → look up by index + if (NUMERIC_PATTERN.test(input)) { + const idx = Number.parseInt(input, 10); + const entry = getTransactionByIndex(idx, currentFingerprint); + + if (entry) { + return { + transaction: entry.transaction, + orgSlug: entry.orgSlug, + projectSlug: entry.projectSlug, + }; + } + + // Check if there's a stale entry for this index + const staleFingerprint = getStaleIndexFingerprint(idx); + if (staleFingerprint) { + throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + } + + throw buildUnknownRefError(input, options); + } + + // Non-numeric input → look up by alias + const entry = getTransactionByAlias(input, currentFingerprint); + + if (entry) { + return { + transaction: entry.transaction, + orgSlug: entry.orgSlug, + projectSlug: entry.projectSlug, + }; + } + + // Check if there's a stale entry for this alias + const staleFingerprint = getStaleFingerprint(input); + if (staleFingerprint) { + throw buildStaleAliasError(input, staleFingerprint, currentFingerprint); + } + + throw buildUnknownRefError(input, options); +} diff --git a/src/lib/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..3b3dad14 --- /dev/null +++ b/src/lib/transaction-alias.ts @@ -0,0 +1,176 @@ +/** + * Transaction alias generation utilities. + * + * Generates short, unique aliases from transaction names for use in profile commands. + * Similar to project aliases but for transaction names like "/api/0/organizations/{org}/issues/". + */ + +import type { TransactionAliasEntry } from "../types/index.js"; +import { findShortestUniquePrefixes } from "./alias.js"; + +/** Characters that separate segments in transaction names */ +const SEGMENT_SEPARATORS = /[/.]/; + +/** Pattern for URL parameter placeholders like {org}, {project_id}, etc. */ +const PLACEHOLDER_PATTERN = /^\{[^}]+\}$/; + +/** Numeric-only segments to filter out (like "0" in "/api/0/...") */ +const NUMERIC_PATTERN = /^\d+$/; + +/** + * Extract the last meaningful segment from a transaction name. + * Filters out parameter placeholders like {org}, {project_id}, and numeric segments. + * + * @example + * extractTransactionSegment("/api/0/organizations/{org}/issues/") + * // => "issues" + * + * @example + * extractTransactionSegment("/extensions/jira/issue-updated/") + * // => "issueupdated" + * + * @example + * extractTransactionSegment("tasks.sentry.process_event") + * // => "processevent" + */ +export function extractTransactionSegment(transaction: string): string { + // Split on / and . to handle both URL paths and dotted task names + const segments = transaction + .split(SEGMENT_SEPARATORS) + .filter((s) => s.length > 0); + + // Find the last meaningful segment (not a placeholder, not numeric) + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; + if (!segment) { + continue; + } + + // Skip placeholders like {org}, {project_id} + if (PLACEHOLDER_PATTERN.test(segment)) { + continue; + } + + // Skip pure numeric segments like "0" in "/api/0/..." + if (NUMERIC_PATTERN.test(segment)) { + continue; + } + + // Normalize: remove hyphens/underscores, lowercase + return segment.replace(/[-_]/g, "").toLowerCase(); + } + + // Fallback: use first non-empty, non-numeric segment if no meaningful one found + const firstSegment = segments.find( + (s) => + s.length > 0 && !NUMERIC_PATTERN.test(s) && !PLACEHOLDER_PATTERN.test(s) + ); + return firstSegment?.replace(/[-_]/g, "").toLowerCase() ?? "txn"; +} + +/** Input for alias generation */ +type TransactionInput = { + /** Full transaction name */ + transaction: string; + /** Organization slug */ + orgSlug: string; + /** Project slug */ + projectSlug: string; +}; + +/** + * Disambiguate duplicate segments by appending numeric suffixes. + * e.g., ["issues", "events", "issues"] → ["issues", "events", "issues2"] + * + * Handles edge case where a suffixed name collides with an existing raw segment: + * e.g., ["issues", "issues2", "issues"] → ["issues", "issues2", "issues3"] + * + * @param segments - Array of extracted segments (may contain duplicates) + * @returns Array of unique segments with numeric suffixes for duplicates + */ +function disambiguateSegments(segments: string[]): string[] { + const result: string[] = []; + const resultSet = new Set(); + + for (const segment of segments) { + if (resultSet.has(segment)) { + // Need a suffixed version - find next available + let suffix = 2; + let candidate = `${segment}${suffix}`; + while (resultSet.has(candidate)) { + suffix += 1; + candidate = `${segment}${suffix}`; + } + result.push(candidate); + resultSet.add(candidate); + } else { + // 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 numeric suffixes + * buildTransactionAliases([ + * { transaction: "/api/v1/issues/", ... }, + * { transaction: "/api/v2/issues/", ... }, + * ]) + * // => [ + * // { idx: 1, alias: "i", ... }, // from "issues" + * // { idx: 2, alias: "is", ... }, // from "issues2" (disambiguated) + * // ] + */ +export function buildTransactionAliases( + transactions: TransactionInput[] +): 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 fc6e4f04..cb215d17 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,6 +31,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, @@ -81,7 +103,6 @@ export type { UserGeo, UserRegionsResponse, } from "./sentry.js"; - export { BreadcrumbSchema, BreadcrumbsEntrySchema, diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 00000000..8a7424d8 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,238 @@ +/** + * 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 */ + transaction: z.string().optional(), + /** Number of profiles/samples */ + "count()": z.number().optional(), + /** 75th percentile duration */ + "p75(function.duration)": z.number().optional(), + /** 95th percentile duration */ + "p95(function.duration)": z.number().optional(), + }) + .passthrough(); + +export type ProfileFunctionRow = z.infer; + +/** + * Response from the Explore Events API for profile_functions dataset. + */ +export const ProfileFunctionsResponseSchema = z.object({ + data: z.array(ProfileFunctionRowSchema), + meta: z + .object({ + fields: z.record(z.string()).optional(), + }) + .passthrough() + .optional(), +}); + +export type ProfileFunctionsResponse = z.infer< + typeof ProfileFunctionsResponseSchema +>; + +// Analyzed Profile Types (for CLI output) + +/** + * A hot path (call stack) identified from profile analysis. + */ +export type HotPath = { + /** Frames in the call stack (leaf to root) */ + frames: FlamegraphFrame[]; + /** Frame info for the leaf frame */ + frameInfo: FlamegraphFrameInfo; + /** Percentage of total CPU time */ + percentage: number; +}; + +/** + * 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/event/view.test.ts b/test/commands/event/view.test.ts index 2ce6a362..89f620aa 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/event/view.js"; +import { parsePositionalArgs } from "../../../src/commands/event/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (event ID only)", () => { @@ -93,9 +91,11 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { let findProjectsBySlugSpy: ReturnType; + const USAGE_HINT = "sentry event view / "; + beforeEach(() => { findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); @@ -109,7 +109,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); await expect( - resolveFromProjectSearch("my-project", "event-123") + resolveProjectBySlug("my-project", { + usageHint: USAGE_HINT, + contextValue: "event-123", + }) ).rejects.toThrow(ContextError); }); @@ -117,7 +120,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "event-123"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "event-123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -137,7 +143,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); await expect( - resolveFromProjectSearch("frontend", "event-123") + resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "event-123", + }) ).rejects.toThrow(ValidationError); }); @@ -148,7 +157,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "event-456"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "event-456", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -156,7 +168,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("event-456"); // Event ID in example + expect(message).toContain("event-456"); // Context value in example } }); @@ -168,14 +180,16 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug("api", { + usageHint: USAGE_HINT, + contextValue: "abc123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); const message = (error as ValidationError).message; - expect(message).toContain( - "Example: sentry event view /api abc123" - ); + // The shared function uses a generic example format + expect(message).toContain("Example: sentry /api abc123"); } }); }); @@ -186,7 +200,10 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "event-xyz"); + const result = await resolveProjectBySlug("backend", { + usageHint: USAGE_HINT, + contextValue: "event-xyz", + }); expect(result).toEqual({ org: "my-company", @@ -206,7 +223,10 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "evt-001"); + const result = await resolveProjectBySlug("mobile-app", { + usageHint: USAGE_HINT, + contextValue: "evt-001", + }); expect(result.org).toBe("acme-industries"); expect(result.orgDisplay).toBe("acme-industries"); @@ -217,7 +237,10 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "e123"); + const result = await resolveProjectBySlug("web-frontend", { + usageHint: USAGE_HINT, + contextValue: "e123", + }); expect(result.project).toBe("web-frontend"); expect(result.projectDisplay).toBe("web-frontend"); diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index 92d4ac4d..de47498e 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/log/view.js"; +import { parsePositionalArgs } from "../../../src/commands/log/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (log ID only)", () => { @@ -91,9 +89,11 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug (log context)", () => { let findProjectsBySlugSpy: ReturnType; + const USAGE_HINT = "sentry log view / "; + beforeEach(() => { findProjectsBySlugSpy = spyOn(apiClient, "findProjectsBySlug"); }); @@ -107,7 +107,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); await expect( - resolveFromProjectSearch("my-project", "log-123") + resolveProjectBySlug("my-project", { + usageHint: USAGE_HINT, + contextValue: "log-123", + }) ).rejects.toThrow(ContextError); }); @@ -115,7 +118,10 @@ describe("resolveFromProjectSearch", () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "log-123"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "log-123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -135,7 +141,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); await expect( - resolveFromProjectSearch("frontend", "log-123") + resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "log-123", + }) ).rejects.toThrow(ValidationError); }); @@ -146,7 +155,10 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "log-456"); + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "log-456", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -154,7 +166,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("log-456"); // Log ID in example + expect(message).toContain("log-456"); // Context value in example } }); @@ -166,12 +178,16 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug("api", { + usageHint: USAGE_HINT, + contextValue: "abc123", + }); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); const message = (error as ValidationError).message; - expect(message).toContain("Example: sentry log view /api abc123"); + // The shared function uses a generic example format + expect(message).toContain("Example: sentry /api abc123"); } }); }); @@ -182,12 +198,16 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "log-xyz"); - - expect(result).toEqual({ - org: "my-company", - project: "backend", + const result = await resolveProjectBySlug("backend", { + usageHint: USAGE_HINT, + contextValue: "log-xyz", }); + + // resolveProjectBySlug returns full ResolvedTarget + expect(result.org).toBe("my-company"); + expect(result.project).toBe("backend"); + expect(result.orgDisplay).toBe("my-company"); + expect(result.projectDisplay).toBe("backend"); }); test("uses orgSlug from project result", async () => { @@ -200,7 +220,10 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "log-001"); + const result = await resolveProjectBySlug("mobile-app", { + usageHint: USAGE_HINT, + contextValue: "log-001", + }); expect(result.org).toBe("acme-industries"); }); @@ -210,7 +233,10 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "log123"); + const result = await resolveProjectBySlug("web-frontend", { + usageHint: USAGE_HINT, + contextValue: "log123", + }); expect(result.project).toBe("web-frontend"); }); diff --git a/test/commands/profile/list.test.ts b/test/commands/profile/list.test.ts new file mode 100644 index 00000000..5c5bcede --- /dev/null +++ b/test/commands/profile/list.test.ts @@ -0,0 +1,432 @@ +/** + * 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", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + expect(resolveOrgAndProjectSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: "my-org", project: "backend" }) + ); + }); + + test("resolves project-only target 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()": 50 }, + { transaction: "/api/events", "count()": 30 }, + ]; + listProfiledTransactionsSpy.mockResolvedValue({ data: mockData }); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, json: true }, "my-org/backend"); + + const output = getOutput(ctx); + const parsed = JSON.parse(output); + expect(parsed).toEqual(mockData); + }); + }); + + describe("empty state", () => { + test("shows empty state message when no data", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).toContain("No profiling data found"); + expect(output).toContain("my-org/backend"); + }); + }); + + describe("human-readable output", () => { + test("renders table with header, rows, and footer", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [ + { + transaction: "/api/users", + "count()": 150, + "p75(function.duration)": 8_000_000, + }, + { + transaction: "/api/events", + "count()": 75, + "p75(function.duration)": 15_000_000, + }, + ], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).toContain("Transactions with Profiles"); + expect(output).toContain("my-org/backend"); + expect(output).toContain("last 24h"); + expect(output).toContain("/api/users"); + expect(output).toContain("/api/events"); + expect(output).toContain("sentry profile view"); + }); + + test("passes period flag to API", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, period: "7d" }, "my-org/backend"); + + expect(listProfiledTransactionsSpy).toHaveBeenCalledWith( + "my-org", + "12345", + expect.objectContaining({ statsPeriod: "7d" }) + ); + }); + + test("passes limit flag to API", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ data: [] }); + const func = await loadListFunc(); + + await func.call(ctx, { ...defaultFlags, limit: 5 }, "my-org/backend"); + + expect(listProfiledTransactionsSpy).toHaveBeenCalledWith( + "my-org", + "12345", + expect.objectContaining({ limit: 5 }) + ); + }); + + test("shows detectedFrom hint when present", async () => { + const ctx = createMockContext(); + setupResolvedTarget({ detectedFrom: ".env file" }); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [{ transaction: "/api/users", "count()": 10 }], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).toContain("Detected from .env file"); + }); + + test("does not show detectedFrom when absent", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [{ transaction: "/api/users", "count()": 10 }], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + const output = getOutput(ctx); + expect(output).not.toContain("Detected from"); + }); + }); + + describe("alias building", () => { + test("stores transaction aliases in DB", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [ + { transaction: "/api/users", "count()": 50 }, + { transaction: "/api/events", "count()": 30 }, + ], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + expect(setTransactionAliasesSpy).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ transaction: "/api/users" }), + expect.objectContaining({ transaction: "/api/events" }), + ]), + expect.any(String) // fingerprint + ); + }); + + test("filters out rows with no transaction name", async () => { + const ctx = createMockContext(); + setupResolvedTarget(); + listProfiledTransactionsSpy.mockResolvedValue({ + data: [ + { transaction: "/api/users", "count()": 50 }, + { "count()": 30 }, // no transaction name + ], + }); + const func = await loadListFunc(); + + await func.call(ctx, defaultFlags, "my-org/backend"); + + // Aliases should only include the row with a transaction name + const aliasCall = setTransactionAliasesSpy.mock.calls[0]; + expect(aliasCall).toBeDefined(); + const aliases = aliasCall[0]; + expect(aliases.length).toBe(1); + expect(aliases[0].transaction).toBe("/api/users"); + }); + }); +}); diff --git a/test/commands/profile/view.test.ts b/test/commands/profile/view.test.ts new file mode 100644 index 00000000..4dcdbf27 --- /dev/null +++ b/test/commands/profile/view.test.ts @@ -0,0 +1,577 @@ +/** + * 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 USAGE_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", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }) + ).rejects.toThrow(ContextError); + }); + + test("includes project name in error message", async () => { + findProjectsBySlugSpy.mockResolvedValue([]); + + try { + await resolveProjectBySlug("frontend", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain('Project "frontend"'); + } + }); + }); + + describe("multiple projects found", () => { + test("throws 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", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }) + ).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", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }); + 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", { + usageHint: USAGE_HINT, + contextValue: "/api/users", + }); + + expect(result.org).toBe("my-company"); + expect(result.project).toBe("backend"); + expect(result.orgDisplay).toBe("my-company"); + expect(result.projectDisplay).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..58e1b2c2 --- /dev/null +++ b/test/lib/db/transaction-aliases.test.ts @@ -0,0 +1,338 @@ +/** + * Transaction Aliases Database Layer Tests + * + * Tests for SQLite storage of transaction aliases from profile list commands. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + buildTransactionFingerprint, + clearTransactionAliases, + getStaleFingerprint, + getStaleIndexFingerprint, + getTransactionAliases, + getTransactionByAlias, + getTransactionByIndex, + setTransactionAliases, +} from "../../../src/lib/db/transaction-aliases.js"; +import type { TransactionAliasEntry } from "../../../src/types/index.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; + +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-transaction-aliases-"); + process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; +}); + +afterEach(async () => { + delete process.env.SENTRY_CLI_CONFIG_DIR; + await cleanupTestDir(testConfigDir); +}); + +// ============================================================================= +// buildTransactionFingerprint +// ============================================================================= + +describe("buildTransactionFingerprint", () => { + test("builds fingerprint with org, project, and period", () => { + const fp = buildTransactionFingerprint("my-org", "my-project", "7d"); + expect(fp).toBe("my-org:my-project:7d"); + }); + + test("uses * for null project (multi-project)", () => { + const fp = buildTransactionFingerprint("my-org", null, "24h"); + expect(fp).toBe("my-org:*:24h"); + }); + + test("handles various period formats", () => { + expect(buildTransactionFingerprint("o", "p", "1h")).toBe("o:p:1h"); + expect(buildTransactionFingerprint("o", "p", "24h")).toBe("o:p:24h"); + expect(buildTransactionFingerprint("o", "p", "7d")).toBe("o:p:7d"); + expect(buildTransactionFingerprint("o", "p", "30d")).toBe("o:p:30d"); + }); +}); + +// ============================================================================= +// setTransactionAliases / getTransactionAliases +// ============================================================================= + +describe("setTransactionAliases", () => { + const fingerprint = "test-org:test-project:7d"; + + const createEntry = (idx: number, alias: string): TransactionAliasEntry => ({ + idx, + alias, + transaction: `/api/0/${alias}/`, + orgSlug: "test-org", + projectSlug: "test-project", + }); + + test("stores and retrieves aliases", () => { + const aliases: TransactionAliasEntry[] = [ + createEntry(1, "issues"), + createEntry(2, "events"), + createEntry(3, "releases"), + ]; + + setTransactionAliases(aliases, fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result).toHaveLength(3); + expect(result[0]?.alias).toBe("issues"); + expect(result[1]?.alias).toBe("events"); + expect(result[2]?.alias).toBe("releases"); + }); + + test("replaces existing aliases with same fingerprint", () => { + setTransactionAliases([createEntry(1, "old")], fingerprint); + setTransactionAliases([createEntry(1, "new")], fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result).toHaveLength(1); + expect(result[0]?.alias).toBe("new"); + }); + + test("keeps aliases with different fingerprints separate", () => { + const fp1 = "org1:proj1:7d"; + const fp2 = "org2:proj2:7d"; + + setTransactionAliases([createEntry(1, "first")], fp1); + setTransactionAliases([createEntry(1, "second")], fp2); + + const result1 = getTransactionAliases(fp1); + const result2 = getTransactionAliases(fp2); + + expect(result1).toHaveLength(1); + expect(result1[0]?.alias).toBe("first"); + expect(result2).toHaveLength(1); + expect(result2[0]?.alias).toBe("second"); + }); + + test("stores empty array", () => { + setTransactionAliases([], fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result).toHaveLength(0); + }); + + test("normalizes aliases to lowercase", () => { + const entry: TransactionAliasEntry = { + idx: 1, + alias: "UPPERCASE", + transaction: "/api/test/", + orgSlug: "org", + projectSlug: "proj", + }; + + setTransactionAliases([entry], fingerprint); + + const result = getTransactionAliases(fingerprint); + expect(result[0]?.alias).toBe("uppercase"); + }); +}); + +// ============================================================================= +// getTransactionByIndex +// ============================================================================= + +describe("getTransactionByIndex", () => { + const fingerprint = "test-org:test-project:7d"; + + beforeEach(() => { + const aliases: TransactionAliasEntry[] = [ + { + idx: 1, + alias: "i", + transaction: "/api/0/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 2, + alias: "e", + transaction: "/api/0/events/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ]; + setTransactionAliases(aliases, fingerprint); + }); + + test("returns entry for valid index", () => { + const result = getTransactionByIndex(1, fingerprint); + expect(result).toBeDefined(); + expect(result?.transaction).toBe("/api/0/issues/"); + expect(result?.alias).toBe("i"); + }); + + test("returns null for non-existent index", () => { + const result = getTransactionByIndex(99, fingerprint); + expect(result).toBeNull(); + }); + + test("returns null for wrong fingerprint", () => { + const result = getTransactionByIndex(1, "different:fingerprint:7d"); + expect(result).toBeNull(); + }); + + test("returns null for index 0", () => { + const result = getTransactionByIndex(0, fingerprint); + expect(result).toBeNull(); + }); +}); + +// ============================================================================= +// getTransactionByAlias +// ============================================================================= + +describe("getTransactionByAlias", () => { + const fingerprint = "test-org:test-project:7d"; + + beforeEach(() => { + const aliases: TransactionAliasEntry[] = [ + { + idx: 1, + alias: "issues", + transaction: "/api/0/organizations/{org}/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 2, + alias: "events", + transaction: "/api/0/projects/{org}/{proj}/events/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ]; + setTransactionAliases(aliases, fingerprint); + }); + + test("returns entry for valid alias", () => { + const result = getTransactionByAlias("issues", fingerprint); + expect(result).toBeDefined(); + expect(result?.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(result?.idx).toBe(1); + }); + + test("returns null for non-existent alias", () => { + const result = getTransactionByAlias("unknown", fingerprint); + expect(result).toBeNull(); + }); + + test("returns null for wrong fingerprint", () => { + const result = getTransactionByAlias("issues", "different:fingerprint:7d"); + expect(result).toBeNull(); + }); + + test("alias lookup is case-insensitive", () => { + const lower = getTransactionByAlias("issues", fingerprint); + const upper = getTransactionByAlias("ISSUES", fingerprint); + const mixed = getTransactionByAlias("Issues", fingerprint); + + expect(lower?.transaction).toBe(upper?.transaction); + expect(lower?.transaction).toBe(mixed?.transaction); + }); +}); + +// ============================================================================= +// getStaleFingerprint / getStaleIndexFingerprint +// ============================================================================= + +describe("stale detection", () => { + test("getStaleFingerprint returns fingerprint when alias exists elsewhere", () => { + const oldFp = "old-org:old-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "old-org", + projectSlug: "old-project", + }, + ], + oldFp + ); + + const stale = getStaleFingerprint("issues"); + expect(stale).toBe(oldFp); + }); + + test("getStaleFingerprint returns null when alias doesn't exist", () => { + const stale = getStaleFingerprint("nonexistent"); + expect(stale).toBeNull(); + }); + + test("getStaleIndexFingerprint returns fingerprint when index exists elsewhere", () => { + const oldFp = "old-org:old-project:7d"; + setTransactionAliases( + [ + { + idx: 5, + alias: "test", + transaction: "/api/test/", + orgSlug: "old-org", + projectSlug: "old-project", + }, + ], + oldFp + ); + + const stale = getStaleIndexFingerprint(5); + expect(stale).toBe(oldFp); + }); + + test("getStaleIndexFingerprint returns null when index doesn't exist", () => { + const stale = getStaleIndexFingerprint(999); + expect(stale).toBeNull(); + }); +}); + +// ============================================================================= +// clearTransactionAliases +// ============================================================================= + +describe("clearTransactionAliases", () => { + test("removes all transaction aliases", () => { + const fp1 = "org1:proj1:7d"; + const fp2 = "org2:proj2:7d"; + + setTransactionAliases( + [ + { + idx: 1, + alias: "a", + transaction: "/a/", + orgSlug: "org1", + projectSlug: "proj1", + }, + ], + fp1 + ); + setTransactionAliases( + [ + { + idx: 1, + alias: "b", + transaction: "/b/", + orgSlug: "org2", + projectSlug: "proj2", + }, + ], + fp2 + ); + + clearTransactionAliases(); + + expect(getTransactionAliases(fp1)).toHaveLength(0); + expect(getTransactionAliases(fp2)).toHaveLength(0); + }); + + test("safe to call when no aliases exist", () => { + // Should not throw + clearTransactionAliases(); + expect(getTransactionAliases("any:fingerprint:7d")).toHaveLength(0); + }); +}); diff --git a/test/lib/formatters/profile.test.ts b/test/lib/formatters/profile.test.ts new file mode 100644 index 00000000..80cd6738 --- /dev/null +++ b/test/lib/formatters/profile.test.ts @@ -0,0 +1,307 @@ +/** + * Profile Formatter Tests + * + * Tests for profiling output formatters in src/lib/formatters/profile.ts. + */ + +import { describe, expect, test } from "bun:test"; +import { + formatProfileAnalysis, + formatProfileListFooter, + formatProfileListHeader, + formatProfileListRow, + formatProfileListTableHeader, +} from "../../../src/lib/formatters/profile.js"; +import type { + HotPath, + ProfileAnalysis, + ProfileFunctionRow, + TransactionAliasEntry, +} from "../../../src/types/index.js"; + +/** Strip ANSI color codes for easier testing */ +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI stripping + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function createHotPath(overrides: Partial = {}): HotPath { + return { + frames: [ + { + name: "processRequest", + file: "src/app.ts", + line: 42, + is_application: true, + fingerprint: 1, + }, + ], + frameInfo: { + count: 100, + weight: 5000, + sumDuration: 10_000_000, + sumSelfTime: 5_000_000, + p75Duration: 8_000_000, + p95Duration: 12_000_000, + p99Duration: 15_000_000, + }, + percentage: 45.2, + ...overrides, + }; +} + +function createAnalysis( + overrides: Partial = {} +): ProfileAnalysis { + return { + transactionName: "/api/users", + platform: "node", + period: "24h", + percentiles: { p75: 8, p95: 12, p99: 20 }, + hotPaths: [createHotPath()], + totalSamples: 500, + userCodeOnly: true, + ...overrides, + }; +} + +// formatProfileAnalysis + +describe("formatProfileAnalysis", () => { + test("includes transaction name and period in header", () => { + const analysis = createAnalysis(); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("/api/users"); + expect(output).toContain("last 24h"); + }); + + test("includes performance percentiles section", () => { + const analysis = createAnalysis({ + percentiles: { p75: 5, p95: 15, p99: 25 }, + }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Performance Percentiles"); + expect(output).toContain("p75:"); + expect(output).toContain("p95:"); + expect(output).toContain("p99:"); + }); + + test("includes hot paths section with user code only label", () => { + const analysis = createAnalysis({ userCodeOnly: true }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Hot Paths"); + expect(output).toContain("user code only"); + }); + + test("includes hot paths section without user code label when all frames", () => { + const analysis = createAnalysis({ userCodeOnly: false }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Hot Paths"); + expect(output).not.toContain("user code only"); + }); + + test("includes function name, file, and percentage in hot path rows", () => { + const analysis = createAnalysis(); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("processRequest"); + expect(output).toContain("src/app.ts:42"); + expect(output).toContain("45.2%"); + }); + + test("shows recommendation when top hot path exceeds 10%", () => { + const analysis = createAnalysis({ + hotPaths: [createHotPath({ percentage: 35.5 })], + }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("Recommendations"); + expect(output).toContain("processRequest"); + expect(output).toContain("35.5%"); + expect(output).toContain("Consider optimizing"); + }); + + test("does not show recommendation when top hot path is below 10%", () => { + const analysis = createAnalysis({ + hotPaths: [createHotPath({ percentage: 5.0 })], + }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).not.toContain("Recommendations"); + }); + + test("handles empty hot paths", () => { + const analysis = createAnalysis({ hotPaths: [] }); + const lines = formatProfileAnalysis(analysis); + const output = stripAnsi(lines.join("\n")); + + expect(output).toContain("No profile data available"); + expect(output).not.toContain("Recommendations"); + }); + + test("returns array of strings", () => { + const analysis = createAnalysis(); + const lines = formatProfileAnalysis(analysis); + + expect(Array.isArray(lines)).toBe(true); + for (const line of lines) { + expect(typeof line).toBe("string"); + } + }); +}); + +// formatProfileListHeader + +describe("formatProfileListHeader", () => { + test("includes org/project and period", () => { + const result = formatProfileListHeader("my-org/backend", "7d"); + expect(result).toContain("my-org/backend"); + expect(result).toContain("last 7d"); + }); + + test("includes 'Transactions with Profiles' label", () => { + const result = formatProfileListHeader("org/proj", "24h"); + expect(result).toContain("Transactions with Profiles"); + }); +}); + +// formatProfileListTableHeader + +describe("formatProfileListTableHeader", () => { + test("includes ALIAS column when hasAliases is true", () => { + const result = stripAnsi(formatProfileListTableHeader(true)); + expect(result).toContain("ALIAS"); + expect(result).toContain("#"); + expect(result).toContain("TRANSACTION"); + expect(result).toContain("PROFILES"); + expect(result).toContain("p75"); + }); + + test("does not include ALIAS or # columns when hasAliases is false", () => { + const result = stripAnsi(formatProfileListTableHeader(false)); + expect(result).not.toContain("ALIAS"); + expect(result).toContain("TRANSACTION"); + expect(result).toContain("PROFILES"); + expect(result).toContain("p75"); + }); + + test("defaults to no aliases", () => { + const result = stripAnsi(formatProfileListTableHeader()); + expect(result).not.toContain("ALIAS"); + }); +}); + +// formatProfileListRow + +describe("formatProfileListRow", () => { + test("formats row with transaction, count, and p75", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count()": 150, + "p75(function.duration)": 8_000_000, // 8ms in nanoseconds + }; + + const result = stripAnsi(formatProfileListRow(row)); + + expect(result).toContain("/api/users"); + expect(result).toContain("150"); + expect(result).toContain("8.00ms"); + }); + + test("formats row with alias when provided", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count()": 150, + "p75(function.duration)": 8_000_000, + }; + + const alias: TransactionAliasEntry = { + idx: 1, + alias: "users", + transaction: "/api/users", + orgSlug: "my-org", + projectSlug: "backend", + }; + + const result = stripAnsi(formatProfileListRow(row, alias)); + + expect(result).toContain("1"); + expect(result).toContain("users"); + expect(result).toContain("/api/users"); + }); + + test("handles missing count", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + }; + + const result = stripAnsi(formatProfileListRow(row)); + expect(result).toContain("0"); + }); + + test("handles missing p75 duration", () => { + const row: ProfileFunctionRow = { + transaction: "/api/users", + "count()": 10, + }; + + const result = stripAnsi(formatProfileListRow(row)); + expect(result).toContain("-"); + }); + + test("handles missing transaction name", () => { + const row: ProfileFunctionRow = { + "count()": 10, + "p75(function.duration)": 5_000_000, + }; + + const result = stripAnsi(formatProfileListRow(row)); + expect(result).toContain("unknown"); + }); + + test("truncates long transaction names", () => { + const longTransaction = + "/api/v2/organizations/{org}/projects/{project}/events/{event_id}/attachments/"; + const row: ProfileFunctionRow = { + transaction: longTransaction, + "count()": 1, + "p75(function.duration)": 1_000_000, + }; + + const result = formatProfileListRow(row); + // Without alias: truncated to 48 chars + expect(result.length).toBeLessThan(longTransaction.length + 30); + }); +}); + +// formatProfileListFooter + +describe("formatProfileListFooter", () => { + test("shows alias tip when aliases are available", () => { + const result = formatProfileListFooter(true); + expect(result).toContain("sentry profile view 1"); + expect(result).toContain(""); + }); + + test("shows transaction name tip when no aliases", () => { + const result = formatProfileListFooter(false); + expect(result).toContain(""); + expect(result).not.toContain(""); + }); + + test("defaults to no aliases", () => { + const result = formatProfileListFooter(); + expect(result).toContain(""); + }); +}); diff --git a/test/lib/profile/analyzer.test.ts b/test/lib/profile/analyzer.test.ts new file mode 100644 index 00000000..15d5cfd3 --- /dev/null +++ b/test/lib/profile/analyzer.test.ts @@ -0,0 +1,465 @@ +/** + * Profile Analyzer Tests + * + * Tests for flamegraph analysis utilities in src/lib/profile/analyzer.ts. + * Combines property-based tests (for pure functions) with unit tests (for analysis). + */ + +import { describe, expect, test } from "bun:test"; +import { double, assert as fcAssert, integer, nat, property } from "fast-check"; +import { + analyzeFlamegraph, + analyzeHotPaths, + calculatePercentiles, + 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("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..95d84a12 --- /dev/null +++ b/test/lib/resolve-transaction.test.ts @@ -0,0 +1,355 @@ +/** + * Transaction Resolver Tests + * + * Tests for resolving transaction references (numbers, aliases, full names) + * to full transaction names for profile commands. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + clearTransactionAliases, + setTransactionAliases, +} from "../../src/lib/db/transaction-aliases.js"; +import { ConfigError } from "../../src/lib/errors.js"; +import { resolveTransaction } from "../../src/lib/resolve-transaction.js"; +import type { TransactionAliasEntry } from "../../src/types/index.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; + +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-resolve-transaction-"); + process.env.SENTRY_CLI_CONFIG_DIR = testConfigDir; +}); + +afterEach(async () => { + delete process.env.SENTRY_CLI_CONFIG_DIR; + await cleanupTestDir(testConfigDir); +}); + +const defaultOptions = { + org: "test-org", + project: "test-project", + period: "7d", +}; + +const setupAliases = () => { + const fingerprint = "test-org:test-project:7d"; + const aliases: TransactionAliasEntry[] = [ + { + idx: 1, + alias: "i", + transaction: "/api/0/organizations/{org}/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 2, + alias: "e", + transaction: "/api/0/projects/{org}/{proj}/events/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + { + idx: 3, + alias: "iu", + transaction: "/extensions/jira/issue-updated/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ]; + setTransactionAliases(aliases, fingerprint); +}; + +// ============================================================================= +// Full Transaction Name Pass-Through +// ============================================================================= + +describe("full transaction name pass-through", () => { + test("URL paths pass through unchanged", () => { + const result = resolveTransaction( + "/api/0/organizations/{org}/issues/", + defaultOptions + ); + + expect(result.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(result.orgSlug).toBe("test-org"); + expect(result.projectSlug).toBe("test-project"); + }); + + test("dotted task names pass through unchanged", () => { + const result = resolveTransaction( + "tasks.sentry.process_event", + defaultOptions + ); + + expect(result.transaction).toBe("tasks.sentry.process_event"); + expect(result.orgSlug).toBe("test-org"); + expect(result.projectSlug).toBe("test-project"); + }); + + test("uses empty string for project when null", () => { + const result = resolveTransaction("/api/test/", { + ...defaultOptions, + project: null, + }); + + expect(result.projectSlug).toBe(""); + }); +}); + +// ============================================================================= +// Numeric Index Resolution +// ============================================================================= + +describe("numeric index resolution", () => { + beforeEach(() => { + setupAliases(); + }); + + test("resolves valid index to transaction", () => { + const result = resolveTransaction("1", defaultOptions); + + expect(result.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(result.orgSlug).toBe("test-org"); + expect(result.projectSlug).toBe("test-project"); + }); + + test("resolves different indices", () => { + const r1 = resolveTransaction("1", defaultOptions); + const r2 = resolveTransaction("2", defaultOptions); + const r3 = resolveTransaction("3", defaultOptions); + + expect(r1.transaction).toBe("/api/0/organizations/{org}/issues/"); + expect(r2.transaction).toBe("/api/0/projects/{org}/{proj}/events/"); + expect(r3.transaction).toBe("/extensions/jira/issue-updated/"); + }); + + test("throws ConfigError for unknown index", () => { + expect(() => resolveTransaction("99", defaultOptions)).toThrow(ConfigError); + }); + + test("error message includes index and suggestion", () => { + try { + resolveTransaction("99", defaultOptions); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("99"); + expect(configError.message).toContain("index"); + expect(configError.suggestion).toContain("sentry profile list"); + } + }); +}); + +// ============================================================================= +// Alias Resolution +// ============================================================================= + +describe("alias resolution", () => { + beforeEach(() => { + setupAliases(); + }); + + test("resolves valid alias to transaction", () => { + const result = resolveTransaction("i", defaultOptions); + + expect(result.transaction).toBe("/api/0/organizations/{org}/issues/"); + }); + + test("resolves multi-character alias", () => { + const result = resolveTransaction("iu", defaultOptions); + + expect(result.transaction).toBe("/extensions/jira/issue-updated/"); + }); + + test("alias lookup is case-insensitive", () => { + const lower = resolveTransaction("i", defaultOptions); + const upper = resolveTransaction("I", defaultOptions); + + expect(lower.transaction).toBe(upper.transaction); + }); + + test("throws ConfigError for unknown alias", () => { + expect(() => resolveTransaction("xyz", defaultOptions)).toThrow( + ConfigError + ); + }); + + test("error message includes alias and suggestion", () => { + try { + resolveTransaction("xyz", defaultOptions); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("xyz"); + expect(configError.message).toContain("alias"); + expect(configError.suggestion).toContain("sentry profile list"); + } + }); +}); + +// ============================================================================= +// Stale Alias Detection +// ============================================================================= + +describe("stale alias detection", () => { + test("detects stale index from different period", () => { + // Store aliases with 7d period + const oldFingerprint = "test-org:test-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "i", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ], + oldFingerprint + ); + + // Try to resolve with 24h period + try { + resolveTransaction("1", { ...defaultOptions, period: "24h" }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("different time period"); + expect(configError.message).toContain("7d"); + expect(configError.message).toContain("24h"); + } + }); + + test("detects stale alias from different project", () => { + const oldFingerprint = "test-org:old-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "old-project", + }, + ], + oldFingerprint + ); + + try { + resolveTransaction("issues", { + ...defaultOptions, + project: "new-project", + }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("different project"); + } + }); + + test("detects stale alias from different org", () => { + const oldFingerprint = "old-org:test-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "issues", + transaction: "/api/issues/", + orgSlug: "old-org", + projectSlug: "test-project", + }, + ], + oldFingerprint + ); + + try { + resolveTransaction("issues", { ...defaultOptions, org: "new-org" }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.message).toContain("different organization"); + } + }); + + test("stale error includes refresh command suggestion", () => { + const oldFingerprint = "test-org:test-project:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "i", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "test-project", + }, + ], + oldFingerprint + ); + + try { + resolveTransaction("1", { ...defaultOptions, period: "24h" }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ConfigError); + const configError = error as ConfigError; + expect(configError.suggestion).toContain("sentry profile list"); + expect(configError.suggestion).toContain("--period 24h"); + } + }); +}); + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe("edge cases", () => { + test("handles empty alias cache gracefully", () => { + clearTransactionAliases(); + + expect(() => resolveTransaction("1", defaultOptions)).toThrow(ConfigError); + expect(() => resolveTransaction("i", defaultOptions)).toThrow(ConfigError); + }); + + test("multi-project fingerprint (null project)", () => { + const multiProjectFp = "test-org:*:7d"; + setTransactionAliases( + [ + { + idx: 1, + alias: "i", + transaction: "/api/issues/", + orgSlug: "test-org", + projectSlug: "backend", + }, + ], + multiProjectFp + ); + + const result = resolveTransaction("1", { + org: "test-org", + project: null, + period: "7d", + }); + + expect(result.transaction).toBe("/api/issues/"); + expect(result.projectSlug).toBe("backend"); + }); + + test("numeric-looking full paths still pass through", () => { + // Transaction name contains numbers but also has path separators + const result = resolveTransaction("/api/0/test/", defaultOptions); + expect(result.transaction).toBe("/api/0/test/"); + }); + + test("dotted names with numbers pass through", () => { + const result = resolveTransaction("celery.task.v2.run", defaultOptions); + expect(result.transaction).toBe("celery.task.v2.run"); + }); +}); diff --git a/test/lib/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..2f593390 --- /dev/null +++ b/test/lib/transaction-alias.property.test.ts @@ -0,0 +1,428 @@ +/** + * 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("handles suffixed name colliding with raw segment", () => { + // ["issues", "issues2", "issues"] would produce collision if not handled: + // - "issues" → "issues" + // - "issues2" → "issues2" (raw) + // - "issues" (2nd) → would try "issues2" but it's taken → should use "issues3" + const inputs = [ + { transaction: "/api/issues/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/api/issues2/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/v2/issues/", orgSlug: "org", projectSlug: "proj" }, + ]; + + const aliases = buildTransactionAliases(inputs); + const aliasValues = aliases.map((a) => a.alias); + const uniqueAliases = new Set(aliasValues); + + // All aliases must be unique + expect(uniqueAliases.size).toBe(aliasValues.length); + }); + + test("handles multiple collision levels", () => { + // Multiple segments that would collide: issues, issues2, issues3, issues + const inputs = [ + { transaction: "/a/issues/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/b/issues2/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/c/issues3/", orgSlug: "org", projectSlug: "proj" }, + { transaction: "/d/issues/", orgSlug: "org", projectSlug: "proj" }, + ]; + + const aliases = buildTransactionAliases(inputs); + const aliasValues = aliases.map((a) => a.alias); + const uniqueAliases = new Set(aliasValues); + + // All aliases must be unique + expect(uniqueAliases.size).toBe(aliasValues.length); + }); + + test("property: disambiguated segments are always unique", () => { + fcAssert( + property( + array(transactionInputArb, { minLength: 1, maxLength: 15 }), + (inputs) => { + const aliases = buildTransactionAliases(inputs); + // The internal disambiguateSegments should produce unique segments + // which means aliases should be unique (by the uniqueness of prefixes) + // Note: aliases could still collide if two different segments share a prefix, + // but the segments themselves should all be unique + const aliasSet = new Set(aliases.map((a) => a.alias)); + // With unique segments, findShortestUniquePrefixes guarantees unique aliases + expect(aliasSet.size).toBe(aliases.length); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +// Edge cases for extractTransactionSegment + +describe("extractTransactionSegment edge cases", () => { + test("returns 'txn' fallback for empty string", () => { + expect(extractTransactionSegment("")).toBe("txn"); + }); + + test("returns 'txn' fallback for placeholder-only transaction", () => { + expect(extractTransactionSegment("/{org}/{project}/")).toBe("txn"); + }); + + test("returns 'txn' fallback for purely numeric transaction", () => { + expect(extractTransactionSegment("/0/1/2/")).toBe("txn"); + }); + + test("returns 'txn' fallback for mixed placeholders and numerics", () => { + expect(extractTransactionSegment("/{org}/0/{project}/1/")).toBe("txn"); + }); + + test("handles single slash", () => { + expect(extractTransactionSegment("/")).toBe("txn"); + }); + + test("handles single dot", () => { + expect(extractTransactionSegment(".")).toBe("txn"); + }); +}); + +// Integration properties + +describe("property: alias lookup invariants", () => { + test("alias is a prefix of the extracted segment (unique transactions)", () => { + // Use uniqueArray to avoid duplicate transactions, since disambiguateSegments + // appends numeric suffixes to duplicates which breaks the prefix relationship + // with the raw extracted segment. + fcAssert( + property( + 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 } + ); + }); +});