Skip to content

Commit cce4d63

Browse files
fix(alerts): read metric alert rules from org-scoped /detectors/ (#1275)
Migrates the metric-alert **read** path (`list` / `view` / rule resolution) off the legacy `/organizations/{org}/alert-rules/` endpoint — which is being retired on **2026-08-17** and 410s during brownouts — to the new org-scoped `/organizations/{org}/detectors/` endpoint filtered to `type:metric_issue`. A `mapDetectorToMetricAlertRule` helper flattens the nested detector payload into the existing `MetricAlertRule` shape, so command formatters and resolvers work unchanged. This mirrors the proven issue-alert read migration (#1215/#1216). Metric-alert **mutations** (`create`/`edit`/`delete`) still use the legacy endpoint pending the follow-up mutation migration tracked in #1277 — same transitional state as issue alerts today. ## Testing - `pnpm run typecheck` — clean - `pnpm run lint` — clean - `npx vitest run test/lib/api/alerts.test.ts test/commands/alert` — 150 passing (updated metric-list mocks to `/detectors/`; added list/get + nested-`snubaQuery` mapping tests) Refs #1274, #1182 <!-- ## Plan Scope: incremental slice of #1274, matching the established #1215/#1216 pattern (migrate read path first, defer mutations). Root cause: metric-alert list/view/get still hit the deprecated /organizations/{org}/alert-rules/ endpoint, retired 2026-08-17. Changes (src/lib/api/alerts.ts): - Add MetricDetector type (only CLI-read fields typed; dataSources/config opaque). - Add mapDetectorToMetricAlertRule: id=String(detector.id), status=enabled===false?1:0, threshold fields from dataSources[0] with queryObj/snubaQuery fallback, owner flattened, projects/dateCreated defaulted. - listMetricAlertsPaginated -> GET /organizations/{org}/detectors/?query=type:metric_issue&sortBy=-id then map. - getMetricAlertRule -> GET /organizations/{org}/detectors/{id}/ then map. - Leave fetchMetricAlertRuleJson/getMetricAlertRuleDocument/put*/delete*/create* on legacy /alert-rules/. Tests updated: alerts.test.ts (fixture + list/get/snubaQuery mapping), metrics/list.test.ts (detector() builder; repoint mocks/regexes/endpoint assertions to /detectors/). Known transitional caveat (matches issue-alert precedent): edit resolves via the migrated detector read but still PUTs against legacy /alert-rules/{id}/; resolved by the deferred mutation migration. --> --------- Co-authored-by: jared-outpost[bot] <jared-outpost[bot]@users.noreply.github.com>
1 parent 06632f1 commit cce4d63

3 files changed

Lines changed: 471 additions & 98 deletions

File tree

src/lib/api/alerts.ts

Lines changed: 224 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@
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";
913
import { ApiError } from "../errors.js";
1014
import { resolveOrgRegion } from "../region.js";
1115
import {
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
*/
209399
export 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

Comments
 (0)