66 * - Metric alerts: threshold-based rules that trigger on metric queries (org-wide)
77 */
88
9+ import {
10+ getOrganizationDetector ,
11+ listOrganizationDetectors ,
12+ } from "@sentry/api" ;
913import { ApiError } from "../errors.js" ;
1014import { resolveOrgRegion } from "../region.js" ;
1115import {
1216 apiRequestToRegion ,
1317 apiRequestToRegionNoContent ,
18+ getOrgSdkConfig ,
1419 type PaginatedResponse ,
1520 parseLinkHeader ,
21+ unwrapPaginatedResult ,
22+ unwrapResult ,
1623} from "./infrastructure.js" ;
1724
1825// Types
@@ -55,6 +62,152 @@ export type MetricAlertRule = {
5562 dateCreated : string ;
5663} ;
5764
65+ /**
66+ * A metric-issue detector as returned by the org-scoped `/detectors/` endpoint.
67+ *
68+ * The new detectors API replaces the legacy `/alert-rules/` endpoint. The
69+ * threshold query fields (aggregate, dataset, query, timeWindow) live inside
70+ * the first entry of `dataSources`, and the active/disabled state is the
71+ * top-level `enabled` boolean. Only the fields the CLI reads are typed; the
72+ * nested `dataSources`/`config` objects are otherwise opaque.
73+ */
74+ type MetricDetector = {
75+ id : string | number ;
76+ name : string ;
77+ /** Detector kind, e.g. `metric_issue`. Other kinds (uptime, cron) are rejected on read. */
78+ type ?: string ;
79+ enabled ?: boolean ;
80+ environment ?: string | null ;
81+ projectSlug ?: string | null ;
82+ projects ?: string [ ] | null ;
83+ owner ?: { type : string ; name ?: string ; id ?: string } | string | null ;
84+ dateCreated ?: string ;
85+ dataSources ?: Record < string , unknown > [ ] | null ;
86+ } ;
87+
88+ /**
89+ * Map a metric-issue detector onto the flat `MetricAlertRule` shape the CLI
90+ * commands (list/view/resolve) already consume.
91+ *
92+ * The threshold query fields live in the first data source, possibly nested
93+ * under `queryObj`, `snubaQuery`, or `queryObj.snubaQuery` — `resolveThresholdSource`
94+ * picks the right container. `enabled === false` maps to the legacy disabled
95+ * status (1); an enabled or absent flag maps to active (0), matching
96+ * `metricAlertStatusLabel`.
97+ */
98+ function pickDetectorString ( value : unknown ) : string {
99+ return typeof value === "string" ? value : "" ;
100+ }
101+
102+ function pickDetectorNumber ( value : unknown ) : number {
103+ if ( typeof value === "number" ) {
104+ return Number . isFinite ( value ) ? value : 0 ;
105+ }
106+ if ( typeof value === "string" && value . trim ( ) !== "" ) {
107+ const parsed = Number ( value ) ;
108+ return Number . isFinite ( parsed ) ? parsed : 0 ;
109+ }
110+ return 0 ;
111+ }
112+
113+ /**
114+ * Resolve the project slug list for a detector.
115+ *
116+ * Detectors are project-scoped and expose a single `projectSlug`, not the
117+ * legacy `projects` slug array; fall back to `projects` for deployments that
118+ * still return it so metric alert view doesn't incorrectly show `(all)`.
119+ */
120+ function pickDetectorProjects ( detector : MetricDetector ) : string [ ] {
121+ if ( detector . projectSlug ) {
122+ return [ detector . projectSlug ] ;
123+ }
124+ return detector . projects ?? [ ] ;
125+ }
126+
127+ /**
128+ * Reduce a detector owner (object or bare string) to the flat actor identifier.
129+ *
130+ * Legacy `/alert-rules/` returned `owner` as an actor string like `user:123`
131+ * or `team:456`, and the CLI formatters/JSON still treat it that way. Detectors
132+ * expose the owner as `{ type, id, name }`, so reconstruct `type:id` to preserve
133+ * that shape; fall back to a bare `id`/`name` when `type` is absent.
134+ */
135+ function pickDetectorOwner ( owner : MetricDetector [ "owner" ] ) : string | null {
136+ if ( typeof owner === "string" ) {
137+ return owner ;
138+ }
139+ if ( owner && typeof owner === "object" ) {
140+ if ( owner . type && owner . id ) {
141+ return `${ owner . type } :${ owner . id } ` ;
142+ }
143+ return owner . id ?? owner . name ?? null ;
144+ }
145+ return null ;
146+ }
147+
148+ /**
149+ * Locate the object holding the threshold query fields for a detector.
150+ *
151+ * Depending on deployment the `SnubaQuery` fields (aggregate/dataset/query/
152+ * timeWindow) sit directly on `dataSources[0]`, under a `queryObj`, under a
153+ * `snubaQuery`, or nested at `queryObj.snubaQuery`. Walk those candidates
154+ * breadth-first and return the first that actually exposes an `aggregate` or
155+ * `query`, so a wrapper object (e.g. a `queryObj` whose real fields live in a
156+ * nested `snubaQuery`) doesn't flatten everything to empty.
157+ */
158+ function resolveThresholdSource (
159+ source : Record < string , unknown >
160+ ) : Record < string , unknown > {
161+ const asRecord = ( value : unknown ) : Record < string , unknown > | undefined =>
162+ value && typeof value === "object" && ! Array . isArray ( value )
163+ ? ( value as Record < string , unknown > )
164+ : undefined ;
165+
166+ const queryObj = asRecord ( source . queryObj ) ;
167+ const snubaQuery = asRecord ( source . snubaQuery ) ;
168+ const candidates = [
169+ queryObj && asRecord ( queryObj . snubaQuery ) ,
170+ queryObj ,
171+ snubaQuery ,
172+ source ,
173+ ] ;
174+
175+ for ( const candidate of candidates ) {
176+ if ( candidate && ( "aggregate" in candidate || "query" in candidate ) ) {
177+ return candidate ;
178+ }
179+ }
180+ return source ;
181+ }
182+
183+ function mapDetectorToMetricAlertRule (
184+ detector : MetricDetector
185+ ) : MetricAlertRule {
186+ const source = detector . dataSources ?. [ 0 ] ?? { } ;
187+ const nested = resolveThresholdSource ( source ) ;
188+
189+ const environment =
190+ pickDetectorString ( nested . environment ) || detector . environment || null ;
191+
192+ // The detector payload exposes timeWindow in seconds, but MetricAlertRule
193+ // (and every CLI formatter, which appends "m") treats it as whole minutes.
194+ const timeWindowSeconds = pickDetectorNumber ( nested . timeWindow ) ;
195+
196+ return {
197+ id : String ( detector . id ) ,
198+ name : detector . name ,
199+ status : detector . enabled === false ? 1 : 0 ,
200+ query : pickDetectorString ( nested . query ) ,
201+ aggregate : pickDetectorString ( nested . aggregate ) ,
202+ dataset : pickDetectorString ( nested . dataset ) ,
203+ timeWindow : timeWindowSeconds > 0 ? Math . round ( timeWindowSeconds / 60 ) : 0 ,
204+ environment,
205+ owner : pickDetectorOwner ( detector . owner ) ,
206+ projects : pickDetectorProjects ( detector ) ,
207+ dateCreated : detector . dateCreated ?? "" ,
208+ } ;
209+ }
210+
58211// Issue alerts
59212//
60213// Issue alert rules are read from the org-scoped `/organizations/{org}/workflows/`
@@ -164,10 +317,26 @@ export async function getIssueAlertRule(
164317}
165318
166319// Metric alerts
320+ //
321+ // Metric alert rules are read from the org-scoped
322+ // `/organizations/{org}/detectors/` endpoint (filtered to `type:metric_issue`).
323+ // The legacy `/organizations/{org}/alert-rules/` endpoint is being retired on
324+ // 2026-08-17 (getsentry/cli#1274, #1182). Detectors return a nested shape, so
325+ // `mapDetectorToMetricAlertRule` flattens each detector into the existing
326+ // `MetricAlertRule` shape the commands already consume. Mutations
327+ // (create/update/delete) still use the legacy endpoint pending a follow-up
328+ // migration to the detectors/monitors + workflows write APIs.
329+
330+ /** Search query that limits the detectors endpoint to metric alert rules. */
331+ const METRIC_DETECTOR_QUERY = "type:metric_issue" ;
167332
168333/**
169334 * List metric alert rules for an organization with cursor-based pagination.
170335 *
336+ * Reads from the org-scoped `/detectors/` endpoint filtered to
337+ * `type:metric_issue` and maps each detector onto the flat `MetricAlertRule`
338+ * shape.
339+ *
171340 * @param orgSlug - Organization slug
172341 * @param options - Pagination parameters (perPage, cursor)
173342 * @returns Paginated response with metric alert rules and optional next cursor
@@ -176,14 +345,27 @@ export async function listMetricAlertsPaginated(
176345 orgSlug : string ,
177346 options : { perPage ?: number ; cursor ?: string } = { }
178347) : Promise < PaginatedResponse < MetricAlertRule [ ] > > {
179- const regionUrl = await resolveOrgRegion ( orgSlug ) ;
180- const { data, headers } = await apiRequestToRegion < MetricAlertRule [ ] > (
181- regionUrl ,
182- `/organizations/${ orgSlug } /alert-rules/` ,
183- { params : { per_page : options . perPage , cursor : options . cursor } }
348+ const config = await getOrgSdkConfig ( orgSlug ) ;
349+ const result = await listOrganizationDetectors ( {
350+ ...config ,
351+ path : { organization_id_or_slug : orgSlug } ,
352+ query : {
353+ query : METRIC_DETECTOR_QUERY ,
354+ sortBy : "-id" ,
355+ cursor : options . cursor ,
356+ per_page : options . perPage ,
357+ } as {
358+ query ?: string ;
359+ sortBy ?: string ;
360+ cursor ?: string ;
361+ per_page ?: number ;
362+ } ,
363+ } ) ;
364+ const { data, nextCursor } = unwrapPaginatedResult < MetricDetector [ ] > (
365+ result ,
366+ "Failed to list metric alert rules"
184367 ) ;
185- const { nextCursor } = parseLinkHeader ( headers . get ( "link" ) ?? null ) ;
186- return { data, nextCursor } ;
368+ return { data : data . map ( mapDetectorToMetricAlertRule ) , nextCursor } ;
187369}
188370
189371/** Single GET for org metric alert rule (typed or full JSON for PUT). */
@@ -202,16 +384,48 @@ async function fetchMetricAlertRuleJson(
202384/**
203385 * Get a single metric alert rule by ID.
204386 *
387+ * Reads from the org-scoped `/detectors/{id}/` endpoint and maps the detector
388+ * onto the flat `MetricAlertRule` shape.
389+ *
390+ * The `/detectors/{id}/` endpoint returns any detector kind (uptime, cron,
391+ * error, …), unlike the `type:metric_issue`-filtered list. A non-metric id is
392+ * rejected with a 404 so view/edit/delete don't silently render an unrelated
393+ * detector as a metric alert with empty threshold fields.
394+ *
205395 * @param orgSlug - Organization slug
206- * @param ruleId - Alert rule ID
396+ * @param ruleId - Detector (alert rule) ID
207397 * @returns The metric alert rule
208398 */
209399export async function getMetricAlertRule (
210400 orgSlug : string ,
211401 ruleId : string
212402) : Promise < MetricAlertRule > {
213- const data = await fetchMetricAlertRuleJson ( orgSlug , ruleId ) ;
214- return data as MetricAlertRule ;
403+ const config = await getOrgSdkConfig ( orgSlug ) ;
404+ const result = await getOrganizationDetector ( {
405+ ...config ,
406+ // The SDK types detector_id as number, but detector IDs are opaque strings
407+ // the API accepts verbatim; pass the string through the string-keyed path.
408+ path : {
409+ organization_id_or_slug : orgSlug ,
410+ detector_id : ruleId ,
411+ } as unknown as {
412+ organization_id_or_slug : string ;
413+ detector_id : number ;
414+ } ,
415+ } ) ;
416+ const data = unwrapResult < MetricDetector > (
417+ result ,
418+ `Failed to get metric alert rule '${ ruleId } '`
419+ ) ;
420+ if ( data . type !== undefined && data . type !== "metric_issue" ) {
421+ throw new ApiError (
422+ `Metric alert rule '${ ruleId } ' not found` ,
423+ 404 ,
424+ `Detector '${ ruleId } ' is of type '${ data . type } ', not a metric alert.` ,
425+ `/organizations/${ orgSlug } /detectors/${ encodeURIComponent ( ruleId ) } /`
426+ ) ;
427+ }
428+ return mapDetectorToMetricAlertRule ( data ) ;
215429}
216430
217431// Issue alert write operations
0 commit comments