From 75e4468a4421687fa41b29b5f43d0e6ac01c6df9 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 23 Oct 2026 21:03:33 +0200 Subject: [PATCH] feat(cli): add issue tag distributions --- .../cli-docs/src/content/docs/contributing.md | 2 +- apps/cli-docs/src/fragments/commands/issue.md | 25 ++ .../sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/issue.md | 34 ++ packages/cli/src/commands/issue/index.ts | 5 +- packages/cli/src/commands/issue/tags.ts | 142 ++++++++ packages/cli/src/commands/issue/utils.ts | 74 +++- packages/cli/src/lib/api-client.ts | 2 + packages/cli/src/lib/api/issue-tags.ts | 42 +++ packages/cli/src/lib/formatters/issue-tags.ts | 70 ++++ packages/cli/test/commands/issue/tags.test.ts | 328 ++++++++++++++++++ packages/cli/test/lib/api/issue-tags.test.ts | 140 ++++++++ .../test/lib/formatters/issue-tags.test.ts | 67 ++++ packages/cli/test/lib/sdk-positionals.test.ts | 10 + 14 files changed, 923 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/commands/issue/tags.ts create mode 100644 packages/cli/src/lib/api/issue-tags.ts create mode 100644 packages/cli/src/lib/formatters/issue-tags.ts create mode 100644 packages/cli/test/commands/issue/tags.test.ts create mode 100644 packages/cli/test/lib/api/issue-tags.test.ts create mode 100644 packages/cli/test/lib/formatters/issue-tags.test.ts diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..feb171b5d 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -69,7 +69,7 @@ toolkit/ │ │ │ ├── event/ # list, send, view │ │ │ ├── feedback/ # list, resolve, spam, unresolve, view │ │ │ ├── integration/ # delete, list -│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, unlink, unresolve, view +│ │ │ ├── issue/ # archive, events, explain, link, list, merge, plan, resolve, tags, unlink, unresolve, view │ │ │ ├── local/ # close, create, list, reset, restart, retention, run, serve, start, status, stop, view │ │ │ ├── log/ # list, view │ │ │ ├── monitor/ # list, run diff --git a/apps/cli-docs/src/fragments/commands/issue.md b/apps/cli-docs/src/fragments/commands/issue.md index 7f7384b7f..559fbf249 100644 --- a/apps/cli-docs/src/fragments/commands/issue.md +++ b/apps/cli-docs/src/fragments/commands/issue.md @@ -23,6 +23,31 @@ Use `@latest` and `@most_frequent` to target issues without knowing their ID. - **`org/project` targets use slugs, not numeric IDs** — `sentry issue list my-org/6775615880` fails when `6775615880` is a project ID copied from a URL. Run `sentry project list my-org/` to find the slug (e.g. `frontend`). - **Prefer auto-detect over guessing slugs** — omit the target when possible; the CLI resolves org/project from DSNs, `.env` files, and your working directory. +## Inspect tag distributions + +After opening an issue, compare which releases, servers, or browsers account for +its events. `issue view` shows tags on the latest event; `issue tags` aggregates +across the issue's retained events. + +The result contains up to **9 top values**, ordered by count. Human output shows +each value's count and percentage of **all retained events**, including events +with a missing value. Any remainder appears as "Other values (not shown)"; +percentages are never calculated from just the displayed rows. Retention limits +apply, so this total can differ from the issue's lifetime event count. + +`--environment` selects one environment; omitting it includes all environments. +This aggregate does **not** support `--period`, `--query`, `--limit`, or cursor +pagination. Use `issue events` when you need filtered event records. For example, +after identifying a release, inspect recent events with +`sentry issue events FRONT-ABC --query 'release:1.2.3' --period 7d`. + +JSON preserves `key`, `name`, `totalValues`, and `topValues`, including counts and +first/last seen timestamps. Values and counts may be `null`; missing data is not +zero. `--fields` selects top-level fields. To discover tag keys, inspect the +latest event with `sentry issue view `; it may not contain every tag used +by earlier events. A tag with no retained values, or an unknown environment, +can return a not-found error. + ## Short IDs and organizations Full short IDs such as `FRONT-ABC` use your configured organization: `SENTRY_ORG`, diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 478eb2d67..00e86c076 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -431,6 +431,7 @@ Manage Sentry issues - `sentry issue list []` — List issues in a project - `sentry issue events ` — List events for a specific issue +- `sentry issue tags ` — Show the top values of an issue tag - `sentry issue explain ` — Analyze one or more issues using Seer AI - `sentry issue plan ` — Generate a solution plan using Seer AI - `sentry issue view ` — View details of one or more issues diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md index d2488db45..961e31016 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -139,6 +139,40 @@ sentry issue events FRONT-ABC -c next sentry issue events FRONT-ABC --json ``` +### `sentry issue tags ` + +Show the top values of an issue tag + +**Flags:** +- `-e, --environment - Filter to one environment (default: all environments)` +- `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` + +**JSON Fields** (use `--json --fields` to select specific fields): + +| Field | Type | Description | +|-------|------|-------------| +| `uniqueValues` | number \| null | | +| `totalValues` | number \| null | Total retained events, including missing tag values; not a lifetime count | +| `topValues` | array \| null | Up to 9 values ordered by count, with nullable counts and first/last seen timestamps | +| `key` | string | | +| `name` | string | | + +**Examples:** + +```bash +# Show which releases concentrate an issue's events +sentry issue tags my-org/FRONT-ABC release + +# Inspect server distribution for the latest issue in production +sentry issue tags @latest server_name --environment production + +# Get browser counts and timestamps as JSON +sentry issue tags FRONT-ABC browser.name --json + +# Select the aggregate fields for a release distribution +sentry issue tags FRONT-ABC release --json --fields key,totalValues,topValues +``` + ### `sentry issue explain ` Analyze one or more issues using Seer AI diff --git a/packages/cli/src/commands/issue/index.ts b/packages/cli/src/commands/issue/index.ts index 76b7c653a..e0084bac4 100644 --- a/packages/cli/src/commands/issue/index.ts +++ b/packages/cli/src/commands/issue/index.ts @@ -7,6 +7,7 @@ import { listCommand } from "./list.js"; import { mergeCommand } from "./merge.js"; import { planCommand } from "./plan.js"; import { resolveCommand } from "./resolve.js"; +import { tagsCommand } from "./tags.js"; import { unlinkCommand } from "./unlink.js"; import { unresolveCommand } from "./unresolve.js"; import { viewCommand } from "./view.js"; @@ -15,6 +16,7 @@ export const issueRoute = buildRouteMap({ routes: { list: listCommand, events: eventsCommand, + tags: tagsCommand, explain: explainCommand, plan: planCommand, view: viewCommand, @@ -35,6 +37,7 @@ export const issueRoute = buildRouteMap({ "Commands:\n" + " list List issues in a project\n" + " events List events for a specific issue\n" + + " tags Show the top values of an issue tag\n" + " view View details of one or more issues\n" + " explain Analyze one or more issues using Seer AI\n" + " plan Generate a solution plan using Seer AI\n" + @@ -44,7 +47,7 @@ export const issueRoute = buildRouteMap({ " merge Merge 2+ issues into a single group\n" + " link Link an existing external issue\n" + " unlink Remove an external issue link\n\n" + - "Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" + + "Magic selectors (available for view, events, tags, explain, plan, resolve, unresolve, archive):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + "Alias: `sentry issues` → `sentry issue list`", diff --git a/packages/cli/src/commands/issue/tags.ts b/packages/cli/src/commands/issue/tags.ts new file mode 100644 index 000000000..f5d6dd44c --- /dev/null +++ b/packages/cli/src/commands/issue/tags.ts @@ -0,0 +1,142 @@ +/** + * Inspect an issue's tag distribution across retained events. + * Uses the public tag details endpoint: a top-values aggregate, not a + * paginated list. Time and search filters are intentionally not exposed + * because that endpoint ignores them. + */ + +import { vGetOrganizationIssueTagResponse } from "@sentry/api/valibot"; +import { description, object, pipe } from "valibot"; +import type { SentryContext } from "../../context.js"; +import { getIssueTag } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError } from "../../lib/errors.js"; +import { formatIssueTags } from "../../lib/formatters/issue-tags.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { + applyFreshFlag, + FRESH_ALIASES, + FRESH_FLAG, +} from "../../lib/list-command.js"; +import { withProgress } from "../../lib/polling.js"; +import { buildCommandHint, issueIdPositional, resolveIssue } from "./utils.js"; + +type TagsFlags = { + /** Emit the API response as JSON instead of a human table; defaults to false. */ + readonly json: boolean; + /** Bypass cached API responses and project detection results; defaults to false. */ + readonly fresh: boolean; + /** One literal environment name; undefined includes all environments. */ + readonly environment?: string; +}; + +export const tagsCommand = buildCommand({ + docs: { + brief: "Show the top values of an issue tag", + fullDescription: + "Find which releases, servers, browsers, or other tag values concentrate an issue's events.\n\n" + + "Returns up to 9 top values across retained events, ordered by count.\n" + + "Percentages use the total retained event count, including missing values,\n" + + "not just the sum of the displayed rows. This is not a lifetime count.\n" + + "Use --environment to select one environment; the default includes all.\n" + + "Time ranges, search queries, and pagination are not supported.\n\n" + + "Use 'sentry issue view ' to inspect tag keys on the latest event.\n" + + "JSON preserves the API's key, name, totalValues, and topValues (including\n" + + "counts and first/last seen timestamps). --fields selects top-level fields.", + examples: [ + { + description: "Show which releases concentrate an issue's events", + command: "sentry issue tags my-org/FRONT-ABC release", + }, + { + description: + "Inspect server distribution for the latest issue in production", + command: + "sentry issue tags @latest server_name --environment production", + }, + { + description: "Get browser counts and timestamps as JSON", + command: "sentry issue tags FRONT-ABC browser.name --json", + }, + { + description: "Select the aggregate fields for a release distribution", + command: + "sentry issue tags FRONT-ABC release --json --fields key,totalValues,topValues", + }, + ], + }, + output: { + human: formatIssueTags, + schema: object({ + ...vGetOrganizationIssueTagResponse.entries, + totalValues: pipe( + vGetOrganizationIssueTagResponse.entries.totalValues, + description( + "Total retained events, including missing tag values; not a lifetime count", + ), + ), + topValues: pipe( + vGetOrganizationIssueTagResponse.entries.topValues, + description( + "Up to 9 values ordered by count, with nullable counts and first/last seen timestamps", + ), + ), + }), + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + ...issueIdPositional.parameters, + { + placeholder: "key", + brief: "Tag key, e.g. release, server_name, browser.name", + parse: String, + }, + ], + }, + flags: { + environment: { + kind: "parsed", + parse: String, + brief: "Filter to one environment (default: all environments)", + optional: true, + }, + fresh: FRESH_FLAG, + }, + aliases: { ...FRESH_ALIASES, e: "environment" }, + }, + async *func( + this: SentryContext, + flags: TagsFlags, + issueArg: string, + key: string, + ) { + applyFreshFlag(flags); + const trailingArgs = + flags.environment === undefined + ? [key] + : [key, "--environment", flags.environment]; + const { org, issue } = await resolveIssue({ + issueArg, + cwd: this.cwd, + command: "tags", + trailingArgs, + }); + if (!org) { + throw new ContextError( + "Organization", + buildCommandHint("tags", issueArg, undefined, trailingArgs), + ); + } + + const tag = await withProgress( + { message: "Fetching tag distribution...", json: flags.json }, + () => getIssueTag(org, issue.id, key, flags.environment), + ); + yield new CommandOutput(tag); + return { + hint: `Issue: ${issue.shortId}. Environment: ${flags.environment ?? "all environments"}. Scope: retained events.`, + }; + }, +}); diff --git a/packages/cli/src/commands/issue/utils.ts b/packages/cli/src/commands/issue/utils.ts index 0461d44b5..3ca9d297c 100644 --- a/packages/cli/src/commands/issue/utils.ts +++ b/packages/cli/src/commands/issue/utils.ts @@ -57,7 +57,7 @@ import { import { parseSentryUrl } from "../../lib/sentry-url-parser.js"; import { buildIssueUrl } from "../../lib/sentry-web-urls.js"; import { setOrgProjectContext } from "../../lib/telemetry.js"; -import { isAllDigits } from "../../lib/utils.js"; +import { isAllDigits, shellQuote } from "../../lib/utils.js"; import type { SentryIssue } from "../../types/index.js"; import { type AutofixState, isTerminalStatus } from "../../types/seer.js"; @@ -159,11 +159,33 @@ export async function mapIssueArgsConcurrently( * @param command - The issue subcommand (e.g., "view", "explain") * @param issueId - The user-provided issue ID * @param base - Base command prefix (default: "sentry issue") + * @param trailingArgs - Additional arguments preserved and shell-quoted for retries */ export function buildCommandHint( command: string, issueId: string, base = "sentry issue", + trailingArgs: readonly string[] = [], +): string { + return appendCommandArguments( + buildIssueTargetHint(command, issueId, base), + trailingArgs, + ); +} + +/** Preserve command-specific positionals and flags in issue recovery hints. */ +function appendCommandArguments( + command: string, + trailingArgs: readonly string[], +): string { + return [command, ...trailingArgs.map(shellQuote)].join(" "); +} + +/** Build the issue target portion without command-specific trailing arguments. */ +function buildIssueTargetHint( + command: string, + issueId: string, + base: string, ): string { // URLs are self-contained — no enrichment needed if (issueId.startsWith("http://") || issueId.startsWith("https://")) { @@ -241,6 +263,8 @@ type IssueCommandContext = { command: string; /** Command domain used for recovery suggestions. */ commandBase: string; + /** Remaining positional arguments and flags to preserve in retry commands. */ + trailingArgs: readonly string[]; }; /** @@ -285,7 +309,7 @@ async function resolveProjectSearchFallback( suffix: string, commandContext: IssueCommandContext, ): Promise { - const { command, commandBase, commandHint } = commandContext; + const { command, commandBase, commandHint, trailingArgs } = commandContext; const { projects } = await findProjectsBySlug(projectSlug.toLowerCase()); if (projects.length === 0) { @@ -297,7 +321,7 @@ async function resolveProjectSearchFallback( [ "No issue with this short ID found in any accessible organization", "Check the project prefix and suffix, or use a numeric issue ID", - `Specify the org: ${commandBase} ${command} /${fullShortId}`, + `Specify the org: ${appendCommandArguments(`${commandBase} ${command} /${fullShortId}`, trailingArgs)}`, ], ); } @@ -310,7 +334,7 @@ async function resolveProjectSearchFallback( commandHint, [ `Found in: ${orgList}`, - `Specify the org: ${commandBase} ${command} /${projectSlug}-${suffix}`, + `Specify the org: ${appendCommandArguments(`${commandBase} ${command} /${projectSlug}-${suffix}`, trailingArgs)}`, ], ); } @@ -395,7 +419,7 @@ async function resolveProjectSearch( cwd: string, commandContext: IssueCommandContext, ): Promise { - const { command, commandBase, commandHint } = commandContext; + const { command, commandBase, commandHint, trailingArgs } = commandContext; // 1. Try alias cache first (fast, local lookup) const aliasResult = await tryResolveFromAlias( projectSlug.toLowerCase(), @@ -473,7 +497,7 @@ async function resolveProjectSearch( commandHint, [ `Found in: ${orgList}`, - `Specify the org: ${commandBase} ${command} /${projectSlug}-${suffix}`, + `Specify the org: ${appendCommandArguments(`${commandBase} ${command} /${projectSlug}-${suffix}`, trailingArgs)}`, ], ); } @@ -531,14 +555,14 @@ function resolveExplicitOrgSuffix( suffix: string, commandContext: IssueCommandContext, ): never { - const { command, commandBase, commandHint } = commandContext; + const { command, commandBase, commandHint, trailingArgs } = commandContext; throw new ResolutionError( `Issue suffix '${suffix}'`, "could not be resolved without project context", commandHint, [ `The format '${org}/${suffix}' requires a project to build the full issue ID.`, - `Use: ${commandBase} ${command} ${org}/-${suffix}`, + `Use: ${appendCommandArguments(`${commandBase} ${command} ${org}/-${suffix}`, trailingArgs)}`, ], ); } @@ -664,6 +688,8 @@ export type ResolveIssueOptions = { command: string; /** Base command prefix for error hints (default: "sentry issue") */ commandBase?: string; + /** Remaining positional arguments and flags to preserve in retry commands. */ + trailingArgs?: readonly string[]; }; /** @@ -758,9 +784,9 @@ async function fetchIssueByNumericId( async function resolveNumericIssue( id: string, cwd: string, - command: string, - commandBase = "sentry issue", + commandContext: IssueCommandContext, ): Promise { + const { command, commandBase, trailingArgs } = commandContext; const resolvedOrg = await resolveOrg({ cwd }); // Prefer explicit context over the cache — `resolveOrg()` already factors // in env vars and config defaults that may point at a different org. @@ -809,10 +835,13 @@ async function resolveNumericIssue( // suggesting the old slug in the hint would mislead the user. Only // explicit `resolvedOrg` is worth preserving. const orgHint = resolvedOrg?.org ?? ""; - const hint = `${commandBase} ${command} ${orgHint}/${id}`; + const hint = appendCommandArguments( + `${commandBase} ${command} ${orgHint}/${id}`, + trailingArgs, + ); throw new ResolutionError(`Issue ${id}`, "not found", hint, [ `No issue with numeric ID ${id} found — you may not have access, or it may have been deleted.`, - `If this is a short ID suffix, try: ${commandBase} ${command} -${id}`, + `If this is a short ID suffix, try: ${appendCommandArguments(`${commandBase} ${command} -${id}`, trailingArgs)}`, ]); } throw err; @@ -839,21 +868,27 @@ async function resolveNumericIssue( export async function resolveIssue( options: ResolveIssueOptions, ): Promise { - const { issueArg, cwd, command, commandBase } = options; + const { issueArg, cwd, command, commandBase, trailingArgs = [] } = options; const effectiveCommandBase = commandBase ?? "sentry issue"; const parsed = parseIssueArg(issueArg); - const commandHint = buildCommandHint(command, issueArg, effectiveCommandBase); + const commandHint = buildCommandHint( + command, + issueArg, + effectiveCommandBase, + trailingArgs, + ); const commandContext: IssueCommandContext = { command, commandBase: effectiveCommandBase, commandHint, + trailingArgs, }; let result: ResolvedIssueResult; switch (parsed.type) { case "numeric": - result = await resolveNumericIssue(parsed.id, cwd, command, commandBase); + result = await resolveNumericIssue(parsed.id, cwd, commandContext); break; case "explicit": { @@ -883,7 +918,7 @@ export async function resolveIssue( commandHint, [ `No issue with numeric ID ${parsed.numericId} found in org '${org}' — you may not have access, or it may have been deleted.`, - `If this is a short ID suffix, try: ${effectiveCommandBase} ${command} -${parsed.numericId}`, + `If this is a short ID suffix, try: ${appendCommandArguments(`${effectiveCommandBase} ${command} -${parsed.numericId}`, trailingArgs)}`, ], ); } @@ -963,7 +998,12 @@ export async function resolveOrgAndIssueId( ): Promise<{ org: string; issueId: string; projectId?: string }> { const result = await resolveIssue(options); if (!result.org) { - const commandHint = buildCommandHint(options.command, options.issueArg); + const commandHint = buildCommandHint( + options.command, + options.issueArg, + options.commandBase, + options.trailingArgs, + ); throw new ContextError("Organization", commandHint); } return { diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 3a4efd603..058006a83 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -13,6 +13,7 @@ * - repositories: repository listing * - integrations: installed integration listing and removal * - issues: issue listing, lookup, status updates + * - issue-tags: issue tag distributions * - events: event retrieval and resolution * - replays: replay listing and detail lookup * - traces: trace details and transactions @@ -86,6 +87,7 @@ export { parseLinkHeader, rawApiRequest, } from "./api/infrastructure.js"; +export { getIssueTag } from "./api/issue-tags.js"; export { buildIssueListCollapse, getIssue, diff --git a/packages/cli/src/lib/api/issue-tags.ts b/packages/cli/src/lib/api/issue-tags.ts new file mode 100644 index 000000000..8f95b1c05 --- /dev/null +++ b/packages/cli/src/lib/api/issue-tags.ts @@ -0,0 +1,42 @@ +/** + * Issue tag distributions across retained events. + */ + +import { + type GetOrganizationIssueTagResponse, + getOrganizationIssueTag, +} from "@sentry/api"; +import { getOrgSdkConfig, unwrapResult } from "./infrastructure.js"; + +/** + * Retrieve a tag's aggregate counts and top values for one issue. + * + * The public endpoint returns up to nine values across retained events. It + * supports one environment, but no time range, query, or pagination. Although + * the SDK types environment as an array, the backend reads only one value. + * Counts and timestamps are preserved as returned, including nulls. + */ +export async function getIssueTag( + orgSlug: string, + issueId: string, + key: string, + environment?: string, +): Promise { + const config = await getOrgSdkConfig(orgSlug); + const result = await getOrganizationIssueTag({ + ...config, + path: { + organization_id_or_slug: orgSlug, + issue_id: issueId, + key, + }, + query: { + environment: environment === undefined ? undefined : [environment], + }, + }); + + return unwrapResult( + result, + "Failed to retrieve issue tag distribution", + ); +} diff --git a/packages/cli/src/lib/formatters/issue-tags.ts b/packages/cli/src/lib/formatters/issue-tags.ts new file mode 100644 index 000000000..42458d369 --- /dev/null +++ b/packages/cli/src/lib/formatters/issue-tags.ts @@ -0,0 +1,70 @@ +/** + * Render the API's top-values aggregate without treating the returned subset + * as the whole distribution. JSON retains the original API response. + */ + +import type { GetOrganizationIssueTagResponse } from "@sentry/api"; +import { sanitize } from "./local.js"; +import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { type Column, formatTable } from "./table.js"; + +type TagValue = NonNullable< + GetOrganizationIssueTagResponse["topValues"] +>[number]; + +function percentage( + count: number | null, + total: number | null | undefined, +): string { + return count !== null && typeof total === "number" && total > 0 + ? `${((count / total) * 100).toFixed(1)}%` + : "—"; +} + +/** Show counts and shares of retained events, with any undisplayed remainder. */ +export function formatIssueTags(tag: GetOrganizationIssueTagResponse): string { + const values = tag.topValues ?? []; + const total = tag.totalValues; + const heading = renderMarkdown( + `### ${escapeMarkdownInline(sanitize(tag.key))} — top values\n\n` + + `Total retained events: ${total ?? "unknown"}\n`, + ); + if (values.length === 0) { + return `${heading}\nNo top values returned.\n`; + } + + const columns: Column[] = [ + { + header: "VALUE", + value: (row) => + escapeMarkdownInline( + sanitize( + row.value === "" + ? "(empty or missing)" + : (row.value ?? "(unknown)"), + ), + ), + }, + { + header: "EVENTS", + align: "right", + value: (row) => String(row.count ?? "—"), + }, + { + header: "SHARE", + align: "right", + value: (row) => percentage(row.count, total), + }, + ]; + let output = `${heading}\n${formatTable(values, columns)}\n`; + + // Null counts are unknown, not zero. Only subtract a fully known subset. + if (typeof total === "number" && values.every((row) => row.count !== null)) { + const displayed = values.reduce((sum, row) => sum + (row.count ?? 0), 0); + const other = total - displayed; + if (other > 0) { + output += `\nOther values (not shown): ${other} events (${percentage(other, total)}).\n`; + } + } + return output; +} diff --git a/packages/cli/test/commands/issue/tags.test.ts b/packages/cli/test/commands/issue/tags.test.ts new file mode 100644 index 000000000..0d8e62cae --- /dev/null +++ b/packages/cli/test/commands/issue/tags.test.ts @@ -0,0 +1,328 @@ +/** + * Issue tag distribution command integration tests. + * + * Exercise routing, issue resolution, the HTTP client, and output together. + * Only the external HTTP boundary is mocked. + */ + +import type { GetOrganizationIssueTagResponse } from "@sentry/api"; +import { run } from "@stricli/core"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { app } from "../../../src/app.js"; +import { tagsCommand } from "../../../src/commands/issue/tags.js"; +import type { SentryContext } from "../../../src/context.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { ApiError } from "../../../src/lib/errors.js"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +const getConfigDir = useTestConfigDir("issue-tags-", { + isolateProjectRoot: true, +}); +useEnvSandbox(["SENTRY_AUTH_TOKEN", "SENTRY_TOKEN", "SENTRY_PLAIN_OUTPUT"]); + +const REGION_URL = "https://de.sentry.io"; +const ISSUE_PATH = "/api/0/organizations/test-org/issues/12345/"; +const ISSUE = { + id: "12345", + shortId: "FRONT-ABC", + title: "Example application error", + count: "1000", + userCount: 20, + status: "unresolved", + project: { id: "42", slug: "frontend", name: "Frontend" }, + permalink: "https://test-org.sentry.io/issues/12345/", +}; + +// The detail endpoint returns a denominator for retained issue events, while +// topValues contains only a subset. It is deliberately smaller than count on +// the issue above, which is not a valid denominator for this distribution. +const TAG: GetOrganizationIssueTagResponse = { + key: "release", + name: "Release", + totalValues: 100, + uniqueValues: 4, + topValues: [ + { + key: "release", + name: "app@1.2.3", + value: "app@1.2.3", + count: 70, + firstSeen: "2026-10-20T00:00:00Z", + lastSeen: "2026-10-21T12:00:00Z", + query: 'release:"app@1.2.3"', + }, + { + key: "release", + name: "app@1.2.2", + value: "app@1.2.2", + count: 20, + firstSeen: "2026-10-19T00:00:00Z", + lastSeen: "2026-10-20T12:00:00Z", + query: 'release:"app@1.2.2"', + }, + ], +}; + +let originalFetch: typeof globalThis.fetch; +let requests: Request[]; +let tagResponse: GetOrganizationIssueTagResponse; +let tagStatus: number; + +beforeEach(() => { + originalFetch = globalThis.fetch; + requests = []; + tagResponse = TAG; + tagStatus = 200; + setAuthToken("test-token"); + setOrgRegion("test-org", REGION_URL); + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const { pathname } = new URL(request.url); + if (pathname === ISSUE_PATH) { + return Response.json(ISSUE); + } + if (pathname === "/api/0/organizations/test-org/shortids/FRONT-ABC/") { + return Response.json({ group: ISSUE }); + } + if (pathname.startsWith(`${ISSUE_PATH}tags/`)) { + return tagStatus === 200 + ? Response.json(tagResponse) + : Response.json({ detail: "Tag access denied" }, { status: tagStatus }); + } + throw new Error(`Unexpected request: ${request.url}`); + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function captureContext() { + const stdout: string[] = []; + const stderr: string[] = []; + const stdoutWriter = { + write(value: string | Uint8Array) { + stdout.push(String(value)); + return true; + }, + }; + const stderrWriter = { + write(value: string | Uint8Array) { + stderr.push(String(value)); + return true; + }, + }; + const context: SentryContext = { + process: { + ...process, + stdout: stdoutWriter, + stderr: stderrWriter, + exitCode: undefined, + } as unknown as typeof process, + env: process.env, + cwd: getConfigDir(), + homeDir: getConfigDir(), + configDir: getConfigDir(), + stdin: process.stdin, + stdout: stdoutWriter, + stderr: stderrWriter, + }; + return { context, stdout, stderr }; +} + +async function runCli(args: string[]) { + const { context, stdout, stderr } = captureContext(); + await run(app, args, context); + return { + stdout: stdout.join(""), + stderr: stderr.join(""), + exitCode: context.process.exitCode ?? 0, + }; +} + +describe("sentry issue tags", () => { + test.each(["test-org/12345", "test-org/FRONT-ABC"])( + "resolves %s and preserves the full tag detail in JSON", + async (issue) => { + const result = await runCli([ + "issue", + "tags", + issue, + "release", + "--json", + "-e", + "production", + ]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(TAG); + expect(requests).toHaveLength(2); + expect( + requests.every((request) => new URL(request.url).origin === REGION_URL), + ).toBe(true); + const tagRequest = requests[1]; + expect(tagRequest?.headers.get("Authorization")).toBe( + "Bearer test-token", + ); + expect(tagRequest?.url).toBe( + `${REGION_URL}${ISSUE_PATH}tags/release/?environment=production`, + ); + }, + ); + + test("projects JSON fields without flattening the top values", async () => { + const result = await runCli([ + "issue", + "tags", + "test-org/12345", + "release", + "--json", + "--fields", + "key,totalValues,topValues", + ]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + key: TAG.key, + totalValues: TAG.totalValues, + topValues: TAG.topValues, + }); + expect(new URL(requests[1]?.url ?? "").search).toBe(""); + }); + + test("shows shares of retained events and the undisplayed remainder", async () => { + const result = await runCli(["issue", "tags", "test-org/12345", "release"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("FRONT-ABC"); + expect(result.stdout).toContain("app@1.2.3"); + expect(result.stdout).toMatch(/70(?:\.0)?%/); + expect(result.stdout).toMatch(/20(?:\.0)?%/); + expect(result.stdout).toMatch(/10(?:\.0)?%/); + expect(result.stdout).not.toMatch(/77\.8%|22\.2%/); + }); + + test("removes terminal controls from human output while preserving JSON values", async () => { + process.env.SENTRY_PLAIN_OUTPUT = "0"; + const value = "\u001b]52;c;dGVzdA==\u0007\u001b[2J1.2.3\u0008\u202e"; + tagResponse = { + ...TAG, + key: "\u001b[2Jrelease\u202e", + topValues: [{ ...TAG.topValues![0]!, value }], + }; + const args = ["issue", "tags", "test-org/12345", "release"]; + const human = await runCli(args); + + expect(human.exitCode).toBe(0); + expect(human.stdout).toContain("1.2.3"); + expect(human.stdout).not.toContain("\u001b]52;"); + expect(human.stdout).not.toContain("dGVzdA=="); + expect(human.stdout).not.toContain("\u001b[2J"); + expect(human.stdout).not.toContain("\u0008"); + expect(human.stdout).not.toContain("\u202e"); + + const json = await runCli([...args, "--json"]); + expect(json.exitCode).toBe(0); + expect(JSON.parse(json.stdout)).toEqual(tagResponse); + }); + + test("handles a tag without values without inventing percentages", async () => { + tagResponse = { + key: "release", + name: "Release", + totalValues: 0, + uniqueValues: 0, + topValues: [], + }; + const result = await runCli(["issue", "tags", "test-org/12345", "release"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/no .*values/i); + expect(result.stdout).not.toMatch(/NaN|Infinity/); + }); + + test("propagates API failures instead of returning an empty distribution", async () => { + tagStatus = 403; + const { context, stdout } = captureContext(); + const func = await tagsCommand.loader(); + + await expect( + func.call( + context, + { json: true, fresh: false }, + "test-org/12345", + "release", + ), + ).rejects.toMatchObject({ + status: 403, + name: ApiError.name, + }); + expect(stdout).toEqual([]); + }); + + test("documents the response and supported filters in structured help", async () => { + const result = await runCli(["help", "issue", "tags", "--json"]); + const help = JSON.parse(result.stdout); + + expect(help.path).toBe("sentry issue tags"); + const flagNames = help.flags.map((flag: { name: string }) => flag.name); + expect(flagNames).toEqual( + expect.arrayContaining(["environment", "json", "fields", "fresh"]), + ); + for (const unsupported of ["period", "query", "cursor", "limit"]) { + expect(flagNames).not.toContain(unsupported); + } + expect( + help.jsonFields.map((field: { name: string }) => field.name), + ).toEqual( + expect.arrayContaining([ + "key", + "totalValues", + "uniqueValues", + "topValues", + ]), + ); + expect(requests).toEqual([]); + }); + + test.each([ + ["test-org/ABC", "test-org/-ABC"], + ["@latest", "/@latest"], + ])( + "keeps the tag and environment in recovery commands for %s", + async (issue, correctedIssue) => { + const result = await runCli([ + "issue", + "tags", + issue, + "release", + "--environment", + "team's production", + "--json", + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain( + `sentry issue tags ${correctedIssue} 'release' '--environment' 'team'"'"'s production'`, + ); + expect(requests).toEqual([]); + }, + ); + + test.each([ + ["test-org/12345"], + ["test-org/12345", "release", "--period", "7d"], + ["test-org/12345", "release", "-e", "production", "-e", "staging"], + ])( + "rejects unsupported arguments before requesting data: %j", + async (...args) => { + const result = await runCli(["issue", "tags", ...args]); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + expect(requests).toEqual([]); + }, + ); +}); diff --git a/packages/cli/test/lib/api/issue-tags.test.ts b/packages/cli/test/lib/api/issue-tags.test.ts new file mode 100644 index 000000000..ce31277b4 --- /dev/null +++ b/packages/cli/test/lib/api/issue-tags.test.ts @@ -0,0 +1,140 @@ +/** + * Issue tag API integration tests using the real SDK and intercepted fetch. + * Fixtures follow Sentry's GroupTagDetailsTest and TagKeySerializer contract. + */ + +import type { GetOrganizationIssueTagResponse } from "@sentry/api"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { getIssueTag } from "../../../src/lib/api/issue-tags.js"; +import { setAuthToken } from "../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; +import { mockFetch, useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +const TAG_DISTRIBUTION: GetOrganizationIssueTagResponse = { + key: "release", + name: "Release", + uniqueValues: 2, + totalValues: 3, + topValues: [ + { + key: "release", + name: "1.2.0", + value: "1.2.0", + count: 2, + firstSeen: "2026-10-15T00:00:00Z", + lastSeen: "2026-10-21T00:00:00Z", + }, + { + key: "release", + name: "", + value: "", + count: 1, + firstSeen: "2026-10-16T00:00:00Z", + lastSeen: "2026-10-20T00:00:00Z", + }, + ], +}; + +describe("getIssueTag", () => { + useTestConfigDir("api-issue-tags-"); + useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", + "SENTRY_CUSTOM_HEADERS", + ]); + + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + setAuthToken("test-token"); + setOrgRegion("test-org", "https://de.sentry.io"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("retrieves the full distribution from the org's region with an encoded key", async () => { + let request: Request | undefined; + globalThis.fetch = mockFetch(async (input, init) => { + request = new Request(input, init); + return Response.json(TAG_DISTRIBUTION); + }); + + const result = await getIssueTag("test-org", "12345", "sentry:release"); + + expect(result).toEqual(TAG_DISTRIBUTION); + expect(request?.url).toBe( + "https://de.sentry.io/api/0/organizations/test-org/issues/12345/tags/sentry%3Arelease/", + ); + expect(request?.method).toBe("GET"); + expect(request?.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + test("sends exactly one literal environment filter", async () => { + let request: Request | undefined; + globalThis.fetch = mockFetch(async (input, init) => { + request = new Request(input, init); + return Response.json(TAG_DISTRIBUTION); + }); + + await getIssueTag("test-org", "12345", "release", "qa/blue&green"); + + expect(new URL(request!.url).searchParams.getAll("environment")).toEqual([ + "qa/blue&green", + ]); + }); + + test.each([ + { key: "release", name: "Release" }, + { + key: "release", + name: "Release", + uniqueValues: null, + totalValues: null, + topValues: null, + }, + { + key: "user", + name: "User", + totalValues: 3, + topValues: [ + { + key: "user", + name: "user-123", + value: "id:user-123", + count: null, + firstSeen: null, + lastSeen: null, + query: 'user.id:"user-123"', + }, + ], + }, + ])( + "preserves optional fields, nulls, and backend user queries: %j", + async (body) => { + globalThis.fetch = mockFetch(async () => Response.json(body)); + + expect(await getIssueTag("test-org", "12345", body.key)).toEqual(body); + }, + ); + + test("preserves a missing issue, tag, or environment response as an API error", async () => { + globalThis.fetch = mockFetch(async () => + Response.json( + { detail: "The requested resource does not exist" }, + { status: 404 }, + ), + ); + + await expect( + getIssueTag("test-org", "12345", "missing"), + ).rejects.toMatchObject({ + name: "ApiError", + status: 404, + }); + }); +}); diff --git a/packages/cli/test/lib/formatters/issue-tags.test.ts b/packages/cli/test/lib/formatters/issue-tags.test.ts new file mode 100644 index 000000000..f6891b255 --- /dev/null +++ b/packages/cli/test/lib/formatters/issue-tags.test.ts @@ -0,0 +1,67 @@ +import type { GetOrganizationIssueTagResponse } from "@sentry/api"; +import { describe, expect, test } from "vitest"; +import { formatIssueTags } from "../../../src/lib/formatters/issue-tags.js"; + +/** A distribution with undisplayed events, rather than an exhaustive list. */ +function distribution( + count: number | null = 30, +): GetOrganizationIssueTagResponse { + return { + key: "release", + name: "Release", + totalValues: 100, + topValues: [ + { + key: "release", + name: "1.2.3", + value: "1.2.3", + count, + firstSeen: null, + lastSeen: null, + }, + ], + }; +} + +describe("formatIssueTags", () => { + test("does not turn an unknown count into zero or a fabricated remainder", () => { + const output = formatIssueTags(distribution(null)); + expect(output).toContain("—"); + expect(output).not.toContain("0.0%"); + expect(output).not.toContain("Other values"); + }); + + test.each([undefined, null, 0])( + "does not divide by an unknown or zero total (%s)", + (total) => { + const output = formatIssueTags({ ...distribution(), totalValues: total }); + expect(output).not.toContain("%"); + expect(output).not.toContain("NaN"); + expect(output).not.toContain("Infinity"); + }, + ); + + test("distinguishes empty values from unknown values and preserves literal tag text", () => { + const tag = distribution(); + const value = tag.topValues![0]!; + tag.topValues = [ + { ...value, value: "" }, + { ...value, value: null }, + { ...value, value: "build__id__|*x*[link]" }, + ]; + const output = formatIssueTags(tag); + expect(output).toContain("(empty or missing)"); + expect(output).toContain("(unknown)"); + expect(output).toContain("build__id__|*x*[link]"); + }); + + test("does not describe absent top-values data as zero events", () => { + const output = formatIssueTags({ + key: "release", + name: "Release", + topValues: null, + }); + expect(output).toContain("Total retained events: unknown"); + expect(output).toContain("No top values returned"); + }); +}); diff --git a/packages/cli/test/lib/sdk-positionals.test.ts b/packages/cli/test/lib/sdk-positionals.test.ts index ea4b5f119..cff49f017 100644 --- a/packages/cli/test/lib/sdk-positionals.test.ts +++ b/packages/cli/test/lib/sdk-positionals.test.ts @@ -29,6 +29,16 @@ function createRecordingSDK(): { } describe("generated SDK positional arguments", () => { + test("issue tags passes the issue and tag key as separate tokens", async () => { + const { calls, sdk } = createRecordingSDK(); + + await sdk.issue.tags({ issue: "my-org/FRONT-ABC", key: "release" }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.path).toEqual(["issue", "tags"]); + expect(calls[0]?.positional).toEqual(["my-org/FRONT-ABC", "release"]); + }); + test("issue link forwards repeated form fields as an array", async () => { const { calls, sdk } = createRecordingSDK(); await sdk.issue.link({