diff --git a/apps/cli-docs/src/fragments/commands/alert.md b/apps/cli-docs/src/fragments/commands/alert.md index 302e1d80a..992ccce7c 100644 --- a/apps/cli-docs/src/fragments/commands/alert.md +++ b/apps/cli-docs/src/fragments/commands/alert.md @@ -6,3 +6,12 @@ Triggers take `alertThreshold`, an optional `label` (`critical` or `warning`), `resolveThreshold`, and `thresholdType` (`0` above, `1` below). Notification actions are not part of triggers; pass `--workflow` with the ID of an existing alert to notify. + +## Inspect metric alert notifications + +The detail view includes detection settings, threshold conditions, and linked +notification workflows with their filters, destinations, and enabled states. +Use `--json` to inspect native `config`, `conditionGroup`, `workflowIds`, and +complete `workflows` documents. Linked workflows that return 403 or 404 appear +in `workflowErrors`; their notifications could not be inspected. This is the +current configuration, not a record of alert firings or notification delivery. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index e5af865c7..67cdd330c 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -225,6 +225,9 @@ sentry alert metrics view my-org/12345 # View by name sentry alert metrics view my-org/'P95 latency alert' + +# Inspect native thresholds and notification workflows +sentry alert metrics view my-org/12345 --json ``` ### `sentry alert metrics create ` diff --git a/packages/cli/src/commands/alert/metrics/view.ts b/packages/cli/src/commands/alert/metrics/view.ts index 1c5fa1863..2f5da8155 100644 --- a/packages/cli/src/commands/alert/metrics/view.ts +++ b/packages/cli/src/commands/alert/metrics/view.ts @@ -1,16 +1,22 @@ import type { SentryContext } from "../../../context.js"; +import { + getIssueAlertWorkflowDocument, + getMetricAlertRule, +} from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { openInBrowser } from "../../../lib/browser.js"; import { buildCommand } from "../../../lib/command.js"; +import { ApiError } from "../../../lib/errors.js"; +import { + formatMetricAlertView, + type MetricAlertViewResult, +} from "../../../lib/formatters/alerts.js"; +import { filterFields } from "../../../lib/formatters/json.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveOrgOnlyFromArg } from "../../../lib/resolve-target.js"; import { buildMetricAlertsUrl } from "../../../lib/sentry-web-urls.js"; -import { - type MetricRuleResolution, - parseMetricRuleArg, - resolveMetricAlertRule, -} from "./rule-resolve.js"; -import { metricAlertStatusLabel } from "./status.js"; +import { isAllDigits } from "../../../lib/utils.js"; +import { parseMetricRuleArg, resolveMetricAlertRule } from "./rule-resolve.js"; const USAGE_HINT = "sentry alert metrics view /"; @@ -20,31 +26,15 @@ type ViewFlags = { readonly fields?: string[]; }; -type MetricAlertViewResult = MetricRuleResolution; - -function formatMetricAlertView(data: MetricRuleResolution): string { - const { orgSlug, rule } = data; - const status = metricAlertStatusLabel(rule.status); - return [ - `Metric alert rule in ${orgSlug}:`, - "", - `ID: ${rule.id}`, - `Name: ${rule.name}`, - `Status: ${status}`, - `Dataset: ${rule.dataset}`, - `Aggregate: ${rule.aggregate}`, - `Query: ${rule.query || "(none)"}`, - `Time Window: ${rule.timeWindow}m`, - `Projects: ${rule.projects.join(", ") || "(all)"}`, - `Environment: ${rule.environment ?? "all"}`, - `Owner: ${rule.owner ?? "none"}`, - ].join("\n"); -} - export const viewCommand = buildCommand({ docs: { brief: "View a metric alert rule", - fullDescription: "View a single metric alert rule by ID or name.", + fullDescription: + "View a metric alert rule by ID or name, including detection settings, " + + "threshold conditions, and linked notification workflows.\n\n" + + "JSON preserves native config, conditionGroup, workflowIds, and workflow " + + "documents. Workflows that cannot be read (403/404) are listed in " + + "workflowErrors. This shows current configuration, not delivery history.", examples: [ { description: @@ -59,14 +49,23 @@ export const viewCommand = buildCommand({ description: "View by name", command: "sentry alert metrics view my-org/'P95 latency alert'", }, + { + description: "Inspect native thresholds and notification workflows", + command: "sentry alert metrics view my-org/12345 --json", + }, ], }, output: { human: formatMetricAlertView, - jsonTransform: (data: MetricAlertViewResult) => ({ - ...data.rule, - org: data.orgSlug, - }), + jsonTransform: (data: MetricAlertViewResult, fields) => { + const result = { + ...data.rule, + org: data.orgSlug, + workflows: data.workflows, + workflowErrors: data.workflowErrors, + }; + return fields?.length ? filterFields(result, fields) : result; + }, }, parameters: { positional: { @@ -113,6 +112,35 @@ export const viewCommand = buildCommand({ } const result = await resolveMetricAlertRule([org], ref, USAGE_HINT); - yield new CommandOutput(result); + // Name resolution uses the compact list projection; inspect the detail + // document so it has the same conditions and links as an ID lookup. + const rule = isAllDigits(ref) + ? result.rule + : await getMetricAlertRule(result.orgSlug, result.rule.id); + const data: MetricAlertViewResult = { + orgSlug: result.orgSlug, + rule, + workflows: [], + workflowErrors: [], + }; + for (const id of new Set(rule.workflowIds ?? [])) { + try { + data.workflows.push( + await getIssueAlertWorkflowDocument(result.orgSlug, id), + ); + } catch (error) { + // A missing or inaccessible link must not hide the readable detector + // or be mistaken for an alert with no notification configuration. + if ( + error instanceof ApiError && + (error.status === 403 || error.status === 404) + ) { + data.workflowErrors.push({ id, status: error.status }); + } else { + throw error; + } + } + } + yield new CommandOutput(data); }, }); diff --git a/packages/cli/src/lib/api/alerts.ts b/packages/cli/src/lib/api/alerts.ts index cd7c61912..26591fc4b 100644 --- a/packages/cli/src/lib/api/alerts.ts +++ b/packages/cli/src/lib/api/alerts.ts @@ -11,7 +11,9 @@ import { createOrganizationWorkflow, deleteOrganizationDetector, getOrganizationDetector, + type GetOrganizationDetectorResponse, getOrganizationWorkflow, + type GetOrganizationWorkflowResponse, listOrganizationDetectors, updateOrganizationDetector, updateOrganizationWorkflow, @@ -64,7 +66,12 @@ export type IssueAlertRule = { }; /** A single metric alert rule (threshold-based, org-scoped) */ -export type MetricAlertRule = { +export type MetricAlertRule = Partial< + Pick< + GetOrganizationDetectorResponse, + "config" | "conditionGroup" | "workflowIds" + > +> & { id: string; name: string; /** 0/"0"/absent = active, 1/"1" = disabled */ @@ -102,6 +109,8 @@ type MetricDetector = { dataSources?: Record[] | null; /** Detection settings (`detectionType`, `comparisonDelta`, …), kept on edit. */ config?: Record | null; + conditionGroup?: GetOrganizationDetectorResponse["conditionGroup"]; + workflowIds?: GetOrganizationDetectorResponse["workflowIds"]; }; /** @@ -291,7 +300,7 @@ export async function listIssueAlertsPaginated( export async function getIssueAlertWorkflowDocument( orgSlug: string, workflowId: string, -): Promise> { +): Promise { const config = await getOrgSdkConfig(orgSlug); const result = await getOrganizationWorkflow({ ...config, @@ -300,7 +309,7 @@ export async function getIssueAlertWorkflowDocument( workflow_id: Number(workflowId), }, }); - return unwrapResult>( + return unwrapResult( result, "Failed to fetch issue alert rule", ); @@ -429,7 +438,8 @@ export async function listMetricAlertsPaginated( * Get a single metric alert rule by ID. * * Reads from the org-scoped `/detectors/{id}/` endpoint and maps the detector - * onto the flat `MetricAlertRule` shape. + * onto the flat `MetricAlertRule` shape, preserving native detection settings, + * conditions, and workflow links for inspection. List results stay compact. * * The `/detectors/{id}/` endpoint returns any detector kind (uptime, cron, * error, …), unlike the `type:metric_issue`-filtered list. A non-metric id is @@ -444,7 +454,13 @@ export async function getMetricAlertRule( orgSlug: string, ruleId: string, ): Promise { - return mapDetectorToMetricAlertRule(await getMetricDetector(orgSlug, ruleId)); + const detector = await getMetricDetector(orgSlug, ruleId); + return { + ...mapDetectorToMetricAlertRule(detector), + config: detector.config ?? undefined, + conditionGroup: detector.conditionGroup, + workflowIds: detector.workflowIds, + }; } /** Fetch a metric-issue detector, rejecting other detector kinds with a 404. */ diff --git a/packages/cli/src/lib/formatters/alerts.ts b/packages/cli/src/lib/formatters/alerts.ts new file mode 100644 index 000000000..53f98d3d0 --- /dev/null +++ b/packages/cli/src/lib/formatters/alerts.ts @@ -0,0 +1,207 @@ +/** Human-readable inspection of metric detectors and linked workflows. */ +import type { GetOrganizationWorkflowResponse } from "@sentry/api"; +import { METRIC_MONITOR_PRIORITY } from "@sentry/toolkit-core/metric-monitor"; +import { metricAlertStatusLabel } from "../../commands/alert/metrics/status.js"; +import type { MetricAlertRule } from "../api/alerts.js"; +import { escapeMarkdownInline, mdKvTable, renderMarkdown } from "./markdown.js"; + +export type MetricAlertViewResult = { + orgSlug: string; + rule: MetricAlertRule; + workflows: GetOrganizationWorkflowResponse[]; + workflowErrors: { id: string; status: number }[]; +}; + +type Row = [string, string]; + +const OPERATORS: Record = { + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", + eq: "=", + ne: "!=", +}; + +function asObject(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function valueText(value: unknown): string { + if (value === undefined) { + return "not returned"; + } + return typeof value === "string" ? value : JSON.stringify(value); +} + +function table(rows: Row[], heading?: string): string { + return mdKvTable( + rows.map(([label, value]) => [label, escapeMarkdownInline(value)]), + heading, + ); +} + +function conditionResult(value: unknown, detector: boolean): string { + if (detector) { + switch (value) { + case METRIC_MONITOR_PRIORITY.resolved: + return "OK / recovery (0)"; + case 25: + return "low (25)"; + case METRIC_MONITOR_PRIORITY.warning: + return "medium (50)"; + case METRIC_MONITOR_PRIORITY.critical: + return "high (75)"; + default: + break; + } + } + return valueText(value); +} + +function conditionText(value: unknown, detector: boolean): string { + const condition = asObject(value); + if (!condition || typeof condition.type !== "string") { + return valueText(value); + } + const operator = OPERATORS[condition.type] ?? condition.type; + const comparison = valueText(condition.comparison); + const result = conditionResult(condition.conditionResult, detector); + return `${operator} ${comparison} → ${result}`; +} + +function conditionRows(value: unknown, detector: boolean): Row[] { + const group = asObject(value); + if (!group) { + return [["Conditions", value === null ? "none" : valueText(value)]]; + } + const rows: Row[] = [["Logic", valueText(group.logicType)]]; + const conditions = group.conditions; + if (Array.isArray(conditions) && conditions.length > 0) { + conditions.forEach((condition, index) => { + rows.push([`Condition ${index + 1}`, conditionText(condition, detector)]); + }); + } else { + rows.push([ + "Conditions", + Array.isArray(conditions) ? "none" : valueText(conditions), + ]); + } + return rows; +} + +function actionSection(value: unknown, index: number): string { + const action = asObject(value); + if (!action) { + return table([["Action", valueText(value)]], `Action ${index + 1}`); + } + const config = asObject(action.config); + const target = + config?.targetDisplay || + config?.targetIdentifier || + (config?.targetType === "issue_owners" ? "Issue owners" : undefined); + const rows: Row[] = [ + ["Provider", valueText(action.type)], + ["Target", target ? valueText(target) : "not returned"], + ["Status", valueText(action.status)], + ]; + if (action.integrationId !== undefined && action.integrationId !== null) { + rows.push(["Integration ID", valueText(action.integrationId)]); + } + if (action.config !== undefined) { + rows.push(["Config", valueText(action.config)]); + } + if (action.data !== undefined) { + rows.push(["Data", valueText(action.data)]); + } + return table(rows, `Action ${index + 1}`); +} + +function workflowGroupSections(value: unknown, heading: string): string[] { + const sections = [table(conditionRows(value, false), heading)]; + const group = asObject(value); + const actions = group?.actions; + if (Array.isArray(actions) && actions.length > 0) { + sections.push(...actions.map(actionSection)); + } else if (group) { + sections.push( + table([ + ["Actions", Array.isArray(actions) ? "none" : valueText(actions)], + ]), + ); + } + return sections; +} + +function workflowSections(workflow: GetOrganizationWorkflowResponse): string[] { + const sections = [ + `## Workflow ${escapeMarkdownInline(workflow.id)}: ${escapeMarkdownInline(workflow.name)}`, + table([ + ["Status", workflow.enabled ? "enabled" : "disabled"], + ["Environment", workflow.environment ?? "all"], + ["Config", valueText(workflow.config)], + ]), + ...workflowGroupSections(workflow.triggers, "Triggers"), + ]; + if (workflow.actionFilters?.length) { + workflow.actionFilters.forEach((group, index) => { + sections.push( + ...workflowGroupSections(group, `Action filter ${index + 1}`), + ); + }); + } else { + sections.push(table([["Action filters", "none"]])); + } + return sections; +} + +/** Show current configuration without treating it as proof a notification fired. */ +export function formatMetricAlertView(data: MetricAlertViewResult): string { + const { orgSlug, rule, workflows, workflowErrors } = data; + const sections = [ + `## Metric alert rule in ${escapeMarkdownInline(orgSlug)}`, + table([ + ["ID", rule.id], + ["Name", rule.name], + ["Status", metricAlertStatusLabel(rule.status)], + ["Dataset", rule.dataset], + ["Aggregate", rule.aggregate], + ["Query", rule.query || "(none)"], + ["Time Window", `${rule.timeWindow}m`], + ["Projects", rule.projects.join(", ") || "(all)"], + ["Environment", rule.environment ?? "all"], + ["Owner", rule.owner ?? "none"], + ]), + table( + [ + ["Config", valueText(rule.config)], + ...conditionRows(rule.conditionGroup, true), + ], + "Detection", + ), + ]; + if (rule.workflowIds === undefined || rule.workflowIds === null) { + sections.push( + "Workflow links were not returned; notification configuration is unverified.", + ); + } else if (rule.workflowIds.length === 0) { + sections.push("No workflows connected."); + } else { + sections.push( + table([["Connected workflows", rule.workflowIds.join(", ")]]), + ); + } + for (const workflow of workflows) { + sections.push(...workflowSections(workflow)); + } + for (const error of workflowErrors) { + const reason = + error.status === 403 ? "access denied" : "not found or inaccessible"; + sections.push( + `Workflow ${escapeMarkdownInline(error.id)}: ${reason} (HTTP ${error.status}); notification configuration is unverified.`, + ); + } + return renderMarkdown(sections.join("\n\n")); +} diff --git a/packages/cli/test/commands/alert/metrics/view-integration.test.ts b/packages/cli/test/commands/alert/metrics/view-integration.test.ts new file mode 100644 index 000000000..c384ce0cc --- /dev/null +++ b/packages/cli/test/commands/alert/metrics/view-integration.test.ts @@ -0,0 +1,374 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { viewCommand } from "../../../../src/commands/alert/metrics/view.js"; +// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking +import * as browser from "../../../../src/lib/browser.js"; +import { DEFAULT_SENTRY_URL } from "../../../../src/lib/constants.js"; +import { setAuthToken } from "../../../../src/lib/db/auth.js"; +import { setOrgRegion } from "../../../../src/lib/db/regions.js"; +import { resetAuthenticatedFetch } from "../../../../src/lib/sentry-client.js"; +import { mockFetch, useTestConfigDir } from "../../../helpers.js"; + +const getConfigDir = useTestConfigDir("alert-metrics-inspection-", { + isolateProjectRoot: true, +}); + +const API_PATH = "/api/0/organizations/test-org"; +const CREATED_AT = "2026-01-01T00:00:00Z"; + +/** Native shapes from Sentry's detector/workflow serializer tests, sanitized. */ +function detector(overrides: Record = {}) { + return { + id: "9", + projectId: "1", + name: "Error volume", + description: "Detect elevated errors", + type: "metric_issue", + enabled: true, + owner: null, + createdBy: null, + dateCreated: CREATED_AT, + dateUpdated: CREATED_AT, + workflowIds: ["42"], + config: { + detectionType: "percent", + comparisonDelta: 3600, + thresholdPeriod: 1, + }, + dataSources: [ + { + id: "10", + organizationId: "1", + type: "snuba_query_subscription", + sourceId: "11", + queryObj: { + id: "11", + status: 1, + subscription: null, + snubaQuery: { + id: "12", + aggregate: "count()", + dataset: "events", + eventTypes: ["error"], + query: "level:error", + timeWindow: 300, + environment: "production", + extrapolationMode: "unknown", + }, + }, + }, + ], + conditionGroup: { + id: "13", + organizationId: "1", + logicType: "any", + conditions: [ + { id: "14", type: "gt", comparison: 100, conditionResult: 75 }, + { id: "15", type: "lte", comparison: 50, conditionResult: 0 }, + ], + actions: [], + }, + ...overrides, + }; +} + +function workflow(overrides: Record = {}) { + return { + id: "42", + name: "Notify on-call", + organizationId: "1", + createdBy: null, + dateCreated: CREATED_AT, + dateUpdated: CREATED_AT, + enabled: false, + environment: "production", + config: { frequency: 30 }, + owner: "team:1", + lastTriggered: null, + detectorIds: ["9"], + triggers: { + id: "21", + organizationId: "1", + logicType: "any-short", + conditions: [ + { + id: "22", + type: "first_seen_event", + comparison: true, + conditionResult: true, + }, + ], + actions: [], + }, + actionFilters: [ + { + id: "23", + organizationId: "1", + logicType: "all", + conditions: [ + { + id: "24", + type: "issue_priority_greater_or_equal", + comparison: 75, + conditionResult: true, + }, + ], + actions: [ + { + id: "25", + type: "slack", + integrationId: "2", + config: { + targetType: "specific", + targetDisplay: "#alerts", + targetIdentifier: "C123EXAMPLE", + }, + data: { tags: "environment", notes: "Investigate elevated errors" }, + status: "disabled", + }, + { + id: "26", + type: "pagerduty", + integrationId: "3", + config: { targetType: "specific", targetIdentifier: "service-1" }, + data: { priority: "critical" }, + status: "active", + }, + ], + }, + ], + ...overrides, + }; +} + +type ViewFunc = ( + this: unknown, + flags: { + readonly web: boolean; + readonly json: boolean; + readonly fields?: string[]; + }, + arg: string, +) => Promise; + +function createContext() { + const stdout = { + output: "", + write(text: string) { + stdout.output += text; + }, + }; + return { + context: { + process, + stdout, + stderr: { write: vi.fn() }, + cwd: getConfigDir(), + }, + stdout, + }; +} + +function mockEndpoints( + document: Record = detector(), + workflowReplies: Record = { "42": workflow() }, +) { + const requests: string[] = []; + globalThis.fetch = mockFetch(async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + requests.push(url.pathname); + expect(request.method).toBe("GET"); + + if (url.pathname === `${API_PATH}/detectors/`) { + expect(url.searchParams.get("query")).toBe("type:metric_issue"); + // A list result is not the detail response: resolving by name must still + // fetch the detector before reading its linked workflows and thresholds. + return Response.json([ + { id: "9", name: "Error volume", type: "metric_issue" }, + ]); + } + if (url.pathname === `${API_PATH}/detectors/9/`) { + return Response.json(document); + } + const workflowId = url.pathname.match(/\/workflows\/(\d+)\/$/)?.[1]; + if (workflowId && Object.hasOwn(workflowReplies, workflowId)) { + const reply = workflowReplies[workflowId]; + if (reply instanceof Error) { + throw reply; + } + if (typeof reply === "number") { + return Response.json( + { detail: "Workflow unavailable" }, + { status: reply }, + ); + } + return Response.json(reply); + } + throw new Error(`Unexpected request: ${url.pathname}`); + }); + return requests; +} + +describe("alert metrics view inspection through the API", () => { + let originalFetch: typeof fetch; + let func: ViewFunc; + + beforeEach(async () => { + originalFetch = globalThis.fetch; + resetAuthenticatedFetch(); + setAuthToken("test-token"); + setOrgRegion("test-org", DEFAULT_SENTRY_URL); + func = (await viewCommand.loader()) as unknown as ViewFunc; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + resetAuthenticatedFetch(); + vi.restoreAllMocks(); + }); + + test.each(["9", "Error volume"])( + "preserves detection and notification settings when viewing %s", + async (ref) => { + const document = detector(); + const linkedWorkflow = workflow(); + const requests = mockEndpoints(document, { "42": linkedWorkflow }); + const { context, stdout } = createContext(); + + await func.call(context, { web: false, json: true }, `test-org/${ref}`); + + const result = JSON.parse(stdout.output); + expect(result).toMatchObject({ + id: "9", + org: "test-org", + aggregate: "count()", + query: "level:error", + timeWindow: 5, + environment: "production", + workflowIds: ["42"], + config: document.config, + conditionGroup: document.conditionGroup, + }); + expect(result.workflows).toEqual([linkedWorkflow]); + expect(requests).toEqual([ + ...(ref === "9" ? [] : [`${API_PATH}/detectors/`]), + `${API_PATH}/detectors/9/`, + `${API_PATH}/workflows/42/`, + ]); + }, + ); + + test("--fields retains structured anomaly thresholds and complete workflow documents", async () => { + const document = detector({ + config: { detectionType: "dynamic" }, + conditionGroup: { + id: "13", + organizationId: "1", + logicType: "any", + conditions: [ + { + id: "14", + type: "anomaly_detection", + comparison: { + sensitivity: "medium", + thresholdType: 0, + seasonality: "auto", + }, + conditionResult: 75, + }, + ], + actions: [], + }, + }); + const linkedWorkflow = workflow(); + mockEndpoints(document, { "42": linkedWorkflow }); + const { context, stdout } = createContext(); + + await func.call( + context, + { + web: false, + json: true, + fields: ["id", "config", "conditionGroup", "workflows"], + }, + "test-org/9", + ); + + expect(JSON.parse(stdout.output)).toEqual({ + id: "9", + config: document.config, + conditionGroup: document.conditionGroup, + workflows: [linkedWorkflow], + }); + }); + + test("keeps readable workflows and reports inaccessible or missing linked workflows", async () => { + const linkedWorkflow = workflow(); + const requests = mockEndpoints( + detector({ workflowIds: ["42", "43", "44", "42"] }), + { "42": linkedWorkflow, "43": 403, "44": 404 }, + ); + const { context, stdout } = createContext(); + + await func.call(context, { web: false, json: true }, "test-org/9"); + + const result = JSON.parse(stdout.output); + expect(result.id).toBe("9"); + expect(result.workflows).toEqual([linkedWorkflow]); + expect(result.workflowErrors).toEqual([ + { id: "43", status: 403 }, + { id: "44", status: 404 }, + ]); + expect( + requests.filter((path) => path === `${API_PATH}/workflows/42/`), + ).toHaveLength(1); + }); + + test.each([[], null, undefined])( + "does not invent workflow connections when workflowIds is %j", + async (workflowIds) => { + const requests = mockEndpoints(detector({ workflowIds }), {}); + const { context, stdout } = createContext(); + + await func.call(context, { web: false, json: true }, "test-org/9"); + + const result = JSON.parse(stdout.output); + expect(result.workflowIds).toEqual(workflowIds); + expect(result.workflows).toEqual([]); + expect(requests).toEqual([`${API_PATH}/detectors/9/`]); + }, + ); + + test.each([ + { name: "authentication", reply: 401, status: 401 }, + { name: "server", reply: 500, status: 500 }, + { name: "network", reply: new Error("Connection reset"), status: 0 }, + ])( + "propagates $name failures instead of claiming inspection succeeded", + async ({ reply, status }) => { + mockEndpoints(detector(), { "42": reply }); + const { context, stdout } = createContext(); + + await expect( + func.call(context, { web: false, json: true }, "test-org/9"), + ).rejects.toMatchObject({ name: "ApiError", status }); + expect(stdout.output).toBe(""); + }, + ); + + test("--web opens the browser without fetching detector or workflow details", async () => { + const requests = mockEndpoints(); + const open = vi + .spyOn(browser, "openInBrowser") + .mockResolvedValue(undefined); + const { context } = createContext(); + + await func.call(context, { web: true, json: false }, "test-org/9"); + + expect(requests).toEqual([]); + expect(open).toHaveBeenCalledWith( + expect.stringContaining("test-org"), + "metric alert rules", + ); + }); +}); diff --git a/packages/cli/test/commands/alert/metrics/view.test.ts b/packages/cli/test/commands/alert/metrics/view.test.ts index 4e75390c2..bca47620a 100644 --- a/packages/cli/test/commands/alert/metrics/view.test.ts +++ b/packages/cli/test/commands/alert/metrics/view.test.ts @@ -7,8 +7,6 @@ import { viewCommand } from "../../../../src/commands/alert/metrics/view.js"; import type { MetricAlertRule } from "../../../../src/lib/api-client.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as apiClient from "../../../../src/lib/api-client.js"; -// oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking -import * as browser from "../../../../src/lib/browser.js"; import { ApiError, ValidationError } from "../../../../src/lib/errors.js"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as resolveTarget from "../../../../src/lib/resolve-target.js"; @@ -49,22 +47,17 @@ function createContext() { describe("alert metrics view", () => { let getRuleSpy: ReturnType; let listRulesSpy: ReturnType; - let openInBrowserSpy: ReturnType; let resolveSpy: ReturnType; beforeEach(() => { getRuleSpy = vi.spyOn(apiClient, "getMetricAlertRule"); listRulesSpy = vi.spyOn(apiClient, "listMetricAlertsPaginated"); - openInBrowserSpy = vi.spyOn(browser, "openInBrowser"); resolveSpy = vi.spyOn(resolveTarget, "resolveOrgOnlyFromArg"); - - openInBrowserSpy.mockResolvedValue(undefined); }); afterEach(() => { getRuleSpy.mockRestore(); listRulesSpy.mockRestore(); - openInBrowserSpy.mockRestore(); resolveSpy.mockRestore(); }); @@ -119,33 +112,14 @@ describe("alert metrics view", () => { await func.call(context, { web: false, json: false }, "test-org/9"); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - expect(output).toContain("Metric alert rule in test-org:"); - expect(output).toContain("ID: 9"); - expect(output).toContain("Name: All Errors"); - expect(output).toContain("Status: disabled"); - expect(output).toContain("Query: (none)"); - expect(output).toContain("Projects: (all)"); - expect(output).toContain("Environment: prod"); - expect(output).toContain("Owner: team:ops"); - }); - - test("--web opens the metric alerts page without fetching a rule", async () => { - const { context } = createContext(); - const func = (await viewCommand.loader()) as unknown as ( - this: unknown, - flags: ViewFlags, - arg: string, - ) => Promise; - - await func.call(context, { web: true, json: false }, "test-org/9"); - - expect(openInBrowserSpy).toHaveBeenCalledWith( - expect.stringContaining("test-org"), - "metric alert rules", - ); - expect(resolveSpy).not.toHaveBeenCalled(); - expect(getRuleSpy).not.toHaveBeenCalled(); - expect(listRulesSpy).not.toHaveBeenCalled(); + expect(output).toContain("Metric alert rule in test-org"); + expect(output).toMatch(/ID\s+│\s+9/); + expect(output).toMatch(/Name\s+│\s+All Errors/); + expect(output).toMatch(/Status\s+│\s+disabled/); + expect(output).toMatch(/Query\s+│\s+\(none\)/); + expect(output).toMatch(/Projects\s+│\s+\(all\)/); + expect(output).toMatch(/Environment\s+│\s+prod/); + expect(output).toMatch(/Owner\s+│\s+team:ops/); }); test("name: no exact match with suggestions returns ValidationError with Did you mean", async () => { diff --git a/packages/cli/test/lib/api/alerts.test.ts b/packages/cli/test/lib/api/alerts.test.ts index a546c2718..469ceccc5 100644 --- a/packages/cli/test/lib/api/alerts.test.ts +++ b/packages/cli/test/lib/api/alerts.test.ts @@ -150,7 +150,9 @@ describe("listMetricAlertsPaginated", () => { const url = new URL(new Request(input!, init).url); expect(url.pathname).toBe("/api/0/organizations/test-org/detectors/"); expect(url.searchParams.get("query")).toBe("type:metric_issue"); - return Response.json([metricDetector({ id: "9", name: "P95 latency" })]); + return Response.json([ + metricDetector({ id: "9", name: "P95 latency", workflowIds: ["42"] }), + ]); }); const { data } = await listMetricAlertsPaginated("test-org"); @@ -167,6 +169,9 @@ describe("listMetricAlertsPaginated", () => { environment: "prod", projects: ["backend"], }); + expect(data[0]).not.toHaveProperty("config"); + expect(data[0]).not.toHaveProperty("conditionGroup"); + expect(data[0]).not.toHaveProperty("workflowIds"); }); }); diff --git a/packages/cli/test/lib/formatters/alerts.test.ts b/packages/cli/test/lib/formatters/alerts.test.ts new file mode 100644 index 000000000..f9e6682db --- /dev/null +++ b/packages/cli/test/lib/formatters/alerts.test.ts @@ -0,0 +1,190 @@ +import type { GetOrganizationWorkflowResponse } from "@sentry/api"; +import { describe, expect, test } from "vitest"; +import type { MetricAlertRule } from "../../../src/lib/api/alerts.js"; +import { + formatMetricAlertView, + type MetricAlertViewResult, +} from "../../../src/lib/formatters/alerts.js"; + +const rule: MetricAlertRule = { + id: "9", + name: "P95 latency", + status: 0, + query: "environment:prod", + aggregate: "p95(span.duration)", + dataset: "spans", + timeWindow: 5, + environment: "prod", + owner: null, + projects: ["backend"], + dateCreated: "2026-10-20T00:00:00Z", + workflowIds: ["12"], + config: { detectionType: "static", thresholdPeriod: 3 }, + conditionGroup: { + logicType: "any", + conditions: [ + { type: "gte", comparison: 500, conditionResult: 75 }, + { type: "lte", comparison: 300, conditionResult: 0 }, + ], + }, +}; + +const workflow: GetOrganizationWorkflowResponse = { + id: "12", + name: "Notify on-call", + organizationId: "1", + createdBy: null, + dateCreated: "2026-10-20T00:00:00Z", + dateUpdated: "2026-10-20T00:00:00Z", + enabled: false, + environment: "prod", + config: { frequency: 30 }, + detectorIds: ["9"], + lastTriggered: null, + owner: null, + triggers: { + logicType: "any", + conditions: [ + { type: "first_seen_event", comparison: true, conditionResult: true }, + ], + actions: [], + }, + actionFilters: [ + { + logicType: "all", + conditions: [{ type: "eq", comparison: 75, conditionResult: true }], + actions: [ + { + id: "21", + type: "slack", + integrationId: "15", + status: "active", + config: { + targetType: "specific", + targetDisplay: "#backend_alerts", + targetIdentifier: "C123", + }, + data: { tags: "environment" }, + }, + { + id: "22", + type: "email", + status: "active", + config: { targetType: "issue_owners" }, + data: { fallthroughType: "ActiveMembers" }, + }, + ], + }, + ], +}; + +function view(overrides: Partial = {}): string { + return formatMetricAlertView({ + orgSlug: "test-org", + rule, + workflows: [workflow], + workflowErrors: [], + ...overrides, + }); +} + +describe("formatMetricAlertView", () => { + test("shows thresholds, recovery and notification gates with provider and target", () => { + const output = view(); + + expect(output).toContain("P95 latency"); + expect(output).toContain('"thresholdPeriod":3'); + expect(output).toContain(">= 500 → high (75)"); + expect(output).toContain("<= 300 → OK / recovery (0)"); + expect(output).toContain("Notify on-call"); + expect(output).toContain("disabled"); + expect(output).toContain('"frequency":30'); + expect(output).toContain("first_seen_event true → true"); + expect(output).toContain("= 75 → true"); + expect(output).toContain("slack"); + expect(output).toContain("#backend_alerts"); + expect(output).toContain("C123"); + expect(output).toContain("active"); + expect(output).toContain('"tags":"environment"'); + expect(output).toMatch(/Target\s+│\s+Issue owners/); + expect(output).toContain('"fallthroughType":"ActiveMembers"'); + }); + + test("preserves structured comparisons and trigger actions with an opaque target", () => { + const output = view({ + rule: { + ...rule, + config: { detectionType: "dynamic" }, + conditionGroup: { + logicType: "any", + conditions: [ + { + type: "anomaly_detection", + comparison: { sensitivity: "high" }, + conditionResult: 50, + }, + ], + }, + }, + workflows: [ + { + ...workflow, + triggers: { + logicType: "any", + conditions: [], + actions: [ + { + type: "pagerduty", + status: "disabled", + config: { targetIdentifier: "service-123" }, + data: { severity: "critical" }, + }, + ], + }, + actionFilters: [], + }, + ], + }); + + expect(output).toContain( + 'anomaly_detection {"sensitivity":"high"} → medium (50)', + ); + expect(output).toContain("pagerduty"); + expect(output).toContain("service-123"); + expect(output).toContain('"severity":"critical"'); + }); + + test("distinguishes disconnected workflows from unknown linkage", () => { + const disconnected = view({ + rule: { ...rule, workflowIds: [] }, + workflows: [], + }); + const unknown = view({ + rule: { ...rule, workflowIds: null }, + workflows: [], + }); + + expect(disconnected).toContain("No workflows connected."); + expect(unknown).toContain("Workflow links were not returned"); + expect(unknown).toContain("notification configuration is unverified"); + expect(unknown).not.toContain("No workflows connected."); + }); + + test("shows unreadable workflows as unverified without claiming no notifications", () => { + const output = view({ + rule: { ...rule, workflowIds: ["12", "13"] }, + workflows: [], + workflowErrors: [ + { id: "12", status: 403 }, + { id: "13", status: 404 }, + ], + }); + + expect(output).toContain("Workflow 12: access denied (HTTP 403)"); + expect(output).toContain( + "Workflow 13: not found or inaccessible (HTTP 404)", + ); + expect(output).toContain("notification configuration is unverified"); + expect(output).not.toContain("No workflows connected."); + }); +});