diff --git a/apps/cli-docs/src/fragments/commands/alert.md b/apps/cli-docs/src/fragments/commands/alert.md index 1d47e382b..ee32b001f 100644 --- a/apps/cli-docs/src/fragments/commands/alert.md +++ b/apps/cli-docs/src/fragments/commands/alert.md @@ -53,8 +53,8 @@ sentry alert issues delete my-org/my-project/12345 --dry-run sentry alert metrics create my-org \ --name "P95 Latency" \ --query "environment:prod" \ - --aggregate "p95(transaction.duration)" \ - --dataset transactions \ + --aggregate "p95(span.duration)" \ + --dataset spans \ --time-window 5 \ --trigger '{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}' ``` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md index e7fa5ccd3..a9fff0868 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md @@ -18,7 +18,7 @@ List recent agent conversations **Flags:** - `-n, --limit - Number of conversations (1-1000) - (default: "25")` - `-q, --query - Search query` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` 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 72efe505e..60f3eabf6 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 @@ -154,8 +154,8 @@ Create a metric alert rule **Flags:** - `--name - Rule name` - `--query - Metric query filter string` -- `--aggregate - Aggregate expression (for example count(), p95(transaction.duration))` -- `--dataset - Dataset: errors (error-events), transactions (transaction-like), sessions, events, spans, metrics` +- `--aggregate - Aggregate expression (for example count(), p95(span.duration))` +- `--dataset - Dataset: errors (error-events), sessions, events, spans, metrics (transaction(s) routes to spans)` - `--time-window - Evaluation window in minutes` - `-t, --trigger ... - Trigger object JSON (repeatable, or pass one JSON array)` - `-p, --project ... - Project slug filter (repeatable or comma-separated)` @@ -170,8 +170,8 @@ Create a metric alert rule sentry alert metrics create my-org \ --name "P95 Latency" \ --query "environment:prod" \ - --aggregate "p95(transaction.duration)" \ - --dataset transactions \ + --aggregate "p95(span.duration)" \ + --dataset spans \ --time-window 5 \ --trigger '{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}' ``` @@ -201,7 +201,7 @@ Edit a metric alert rule - `--status - active or disabled` - `--query - Metric query filter` - `--aggregate - Aggregate expression` -- `--dataset - Dataset: errors (error-events), transactions (transaction-like), sessions, events, spans, metrics` +- `--dataset - Dataset: errors (error-events), sessions, events, spans, metrics (transaction(s) routes to spans)` - `--time-window - Evaluation window in minutes` - `-t, --trigger ... - Trigger object JSON (repeatable, or pass one JSON array)` - `-p, --project ... - Project slug filter (repeatable or comma-separated)` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 305f9c494..e83b705c9 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -42,7 +42,7 @@ View a dashboard - `-w, --web - Open in browser` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-r, --refresh - Auto-refresh interval in seconds (default: 60, min: 10)` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01"` - `--renderer - Graphics renderer (defaults to auto; falls back to auto when unavailable) - (default: "auto")` **Examples:** diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md index e831b344e..adce7c3e2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/event.md @@ -37,7 +37,7 @@ List events for an issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md index a0ab45d3f..9e2338cfc 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/explore.md @@ -24,7 +24,7 @@ Query aggregate event data (Explore) - `-s, --sort - Sort field (prefix with - for desc, e.g., "-count()")` - `-e, --environment ... - Environment filter (repeatable, comma-separated)` - `-n, --limit - Number of rows (1-1000) - (default: "25")` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "24h")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "24h")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md index ae6dd5281..0e5397ec8 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/feedback.md @@ -19,7 +19,7 @@ List and search User Feedback - `--status - Mailbox: unresolved, resolved, spam, or all - (default: "unresolved")` - `-n, --limit - Number of feedback items (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry issue search syntax)` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "14d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` 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 00386311d..d1c554d38 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 @@ -19,7 +19,7 @@ List issues in a project - `-q, --query - Search query (Sentry syntax, implicit AND, no OR operator)` - `-n, --limit - Maximum number of issues to list - (default: "25")` - `-s, --sort - Sort by: recommended, date, new, freq, user (default: recommended on sentry.io, else date)` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "90d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "90d")` - `-c, --cursor - Pagination cursor (use "next" for next page, "prev" for previous)` - `--compact - Single-line rows for compact output (auto-detects if omitted)` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` @@ -92,7 +92,7 @@ List events for a specific issue - `-n, --limit - Number of events (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `--full - Include full event body (stacktraces)` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md index 3cffc0655..28d0d0eab 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/log.md @@ -19,7 +19,7 @@ List logs from a project - `-n, --limit - Number of log entries (1-1000) - (default: "100")` - `-q, --query - Filter query (e.g., "severity:error", "project:backend", "project:[a,b]")` - `-f, --follow - Stream logs (optionally specify poll interval in seconds)` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01"` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01"` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` - `--fresh - Bypass cache, re-detect projects, and fetch fresh data` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md index 4b89e399f..c444ffaa2 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/replay.md @@ -20,7 +20,7 @@ List recent Session Replays - `-q, --query - Search query (Sentry replay search syntax)` - `-e, --environment ... - Filter by environment (repeatable, comma-separated)` - `-s, --sort - Sort by: date, oldest, duration, errors, activity, or a raw replay sort field - (default: "date")` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md index f9dc23309..ff4cb412a 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/span.md @@ -19,7 +19,7 @@ List spans in a project or trace - `-n, --limit - Number of spans (<=1000) - (default: "25")` - `-q, --query - Filter spans (e.g., "op:db", "project:backend", "project:[cli,api]")` - `-s, --sort - Sort order: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md index 007297777..53bb750bb 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/trace.md @@ -19,7 +19,7 @@ List recent traces in a project - `-n, --limit - Number of traces (1-1000) - (default: "25")` - `-q, --query - Search query (Sentry search syntax)` - `-s, --sort - Sort by: date, duration - (default: "date")` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "7d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "7d")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` - `-c, --cursor - Navigate pages: "next", "prev", "first" (or raw cursor string)` @@ -91,7 +91,7 @@ View logs associated with a trace **Flags:** - `-w, --web - Open trace in browser` -- `-t, --period - Time range: "7d", "2026-07-01..2026-08-01", ">=2026-07-01" - (default: "14d")` +- `-t, --period - Time range: "7d", "2026-08-01..2026-09-01", ">=2026-08-01" - (default: "14d")` - `-n, --limit - Number of log entries (<=1000) - (default: "100")` - `-q, --query - Filter query (e.g., "severity:error", "project:backend", "project:[a,b]")` - `-s, --sort - Sort order: "newest" (default) or "oldest" - (default: "newest")` diff --git a/packages/cli/src/commands/alert/metrics/create.ts b/packages/cli/src/commands/alert/metrics/create.ts index f46c65e88..b66404a21 100644 --- a/packages/cli/src/commands/alert/metrics/create.ts +++ b/packages/cli/src/commands/alert/metrics/create.ts @@ -9,13 +9,14 @@ import { createMetricAlertRule } from "../../../lib/api-client.js"; import { parseOrgProjectArg } from "../../../lib/arg-parsing.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { ContextError, ValidationError } from "../../../lib/errors.js"; +import { warning } from "../../../lib/formatters/colors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../../lib/mutate-command.js"; import { resolveOrg } from "../../../lib/resolve-target.js"; import { - normalizeMetricDataset, normalizeProjectList, parseJsonObjectList, + resolveMetricDataset, validateMetricDataset, validateMetricTimeWindow, validateMetricTriggers, @@ -85,8 +86,8 @@ export const createCommand = buildCommand({ " --environment, --owner\n\n" + "Examples:\n" + " sentry alert metrics create my-org --name 'P95 latency' \\\n" + - " --query 'environment:prod' --aggregate 'p95(transaction.duration)' \\\n" + - " --dataset transactions --time-window 5 \\\n" + + " --query 'environment:prod' --aggregate 'p95(span.duration)' \\\n" + + " --dataset spans --time-window 5 \\\n" + ' --trigger \'{"alertThreshold":500,"actions":[{"id":"sentry.mail.actions.NotifyEmailAction","targetType":"Team","targetIdentifier":1}]}\'\n\n' + " sentry alert metrics create my-org --name 'Error volume' \\\n" + " --query 'event.type:error' --aggregate 'count()' --dataset errors \\\n" + @@ -122,14 +123,13 @@ export const createCommand = buildCommand({ aggregate: { kind: "parsed", parse: String, - brief: - "Aggregate expression (for example count(), p95(transaction.duration))", + brief: "Aggregate expression (for example count(), p95(span.duration))", }, dataset: { kind: "parsed", parse: String, brief: - "Dataset: errors (error-events), transactions (transaction-like), sessions, events, spans, metrics", + "Dataset: errors (error-events), sessions, events, spans, metrics (transaction(s) routes to spans)", }, "time-window": { kind: "parsed", @@ -178,9 +178,16 @@ export const createCommand = buildCommand({ throw new ValidationError("aggregate cannot be empty.", "aggregate"); } - const dataset = normalizeMetricDataset(flags.dataset); + const { + dataset, + query, + notice: datasetNotice, + } = resolveMetricDataset(flags.dataset, flags.query); validateMetricDataset(dataset); validateMetricTimeWindow(flags["time-window"]); + if (datasetNotice) { + this.stderr.write(`${warning(`Tip: ${datasetNotice}`)}\n`); + } const triggers = parseJsonObjectList(flags.trigger, "trigger"); validateMetricTriggers(triggers); @@ -196,7 +203,7 @@ export const createCommand = buildCommand({ const body: Record = { name: flags.name, - query: flags.query, + query, aggregate: flags.aggregate, dataset, timeWindow: flags["time-window"], diff --git a/packages/cli/src/commands/alert/metrics/edit.ts b/packages/cli/src/commands/alert/metrics/edit.ts index 23c1136e8..b1aa3e6dd 100644 --- a/packages/cli/src/commands/alert/metrics/edit.ts +++ b/packages/cli/src/commands/alert/metrics/edit.ts @@ -11,6 +11,7 @@ import { } from "../../../lib/api-client.js"; import { buildCommand, numberParser } from "../../../lib/command.js"; import { ValidationError } from "../../../lib/errors.js"; +import { warning } from "../../../lib/formatters/colors.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { resolveOrgOptionalProjectFromArg } from "../../../lib/resolve-target.js"; import { @@ -18,6 +19,7 @@ import { normalizeProjectList, parseJsonObjectList, parseStatusFlag, + resolveMetricDataset, statusToMetricValue, validateMetricDataset, validateMetricTimeWindow, @@ -76,7 +78,8 @@ function validateMetricEditFlags(flags: EditFlags): void { function applyMetricCoreFields( body: Record, flags: EditFlags -): Record { +): string | undefined { + let notice: string | undefined; if (flags.name !== undefined) { body.name = flags.name; } @@ -93,15 +96,21 @@ function applyMetricCoreFields( body.aggregate = flags.aggregate; } if (flags.dataset !== undefined) { - const dataset = normalizeMetricDataset(flags.dataset); - validateMetricDataset(dataset); - body.dataset = dataset; + // Resolve against the effective query (the just-applied --query flag, or the + // rule's existing query) so a `transaction(s)` request routes to `spans` + // with `is_transaction:true` added even when --query is not also passed. + const currentQuery = typeof body.query === "string" ? body.query : ""; + const resolved = resolveMetricDataset(flags.dataset, currentQuery); + validateMetricDataset(resolved.dataset); + body.dataset = resolved.dataset; + body.query = resolved.query; + notice = resolved.notice; } if (flags["time-window"] !== undefined) { validateMetricTimeWindow(flags["time-window"]); body.timeWindow = flags["time-window"]; } - return body; + return notice; } function applyMetricOptionalFields( @@ -166,7 +175,7 @@ export const editCommand = buildCommand({ "Examples:\n" + " sentry alert metrics edit my-org/9 --name 'Error budget'\n" + " sentry alert metrics edit my-org/9 --status disabled\n" + - " sentry alert metrics edit my-org/9 --time-window 15 --dataset transactions", + " sentry alert metrics edit my-org/9 --time-window 15 --dataset spans", }, output: { human: formatEdited, @@ -213,7 +222,7 @@ export const editCommand = buildCommand({ parse: String, optional: true, brief: - "Dataset: errors (error-events), transactions (transaction-like), sessions, events, spans, metrics", + "Dataset: errors (error-events), sessions, events, spans, metrics (transaction(s) routes to spans)", }, "time-window": { kind: "parsed", @@ -271,9 +280,12 @@ export const editCommand = buildCommand({ const body = { ...(await getMetricAlertRuleDocument(orgSlug, rule.id)), } as Record; - applyMetricCoreFields(body, flags); + const datasetNotice = applyMetricCoreFields(body, flags); applyMetricOptionalFields(body, flags); validateMetricBody(body, flags); + if (datasetNotice) { + this.stderr.write(`${warning(`Tip: ${datasetNotice}`)}\n`); + } const updated = await putMetricAlertRule(orgSlug, rule.id, body); yield new CommandOutput({ ...updated, diff --git a/packages/cli/src/commands/alert/mutation-utils.ts b/packages/cli/src/commands/alert/mutation-utils.ts index f800a9206..8f7060339 100644 --- a/packages/cli/src/commands/alert/mutation-utils.ts +++ b/packages/cli/src/commands/alert/mutation-utils.ts @@ -7,7 +7,6 @@ import { ValidationError } from "../../lib/errors.js"; const ISSUE_MATCH_MODES = new Set(["all", "any"]); const METRIC_DATASET_VALUES = new Set([ "errors", - "transactions", "sessions", "events", "spans", @@ -18,10 +17,10 @@ const METRIC_DATASET_VALUES = new Set([ * Maps user-provided dataset aliases to canonical metric alert dataset values. * * Only aliases that resolve to a value already accepted by the metric alert API - * are listed here: the singular forms (`error` → `errors`) and the dashboard - * terminology for the error/transaction datasets (`error-events` → `errors`, - * `transaction-like` → `transactions`). This lets users copying from dashboard - * docs avoid a validation error without changing which dataset is sent. + * are listed here: the singular forms (`error` → `errors`, `span` → `spans`) + * and the dashboard terminology for the error dataset (`error-events` → + * `errors`). This lets users copying from dashboard docs avoid a validation + * error without changing which dataset is sent. * * Names that denote a *distinct* dataset in the metric alert path — e.g. * `tracemetrics`, `metricsenhanced`, `eap`, `events_analytics_platform` — are @@ -29,17 +28,30 @@ const METRIC_DATASET_VALUES = new Set([ * silently send the wrong dataset in the create/edit payload (the CLI's * canonical `metrics` is session/crash-rate, not trace metrics), with no * validation error to catch it. + * + * The deprecated `transaction`/`transactions` datasets are handled separately + * by {@link resolveMetricDataset} — they route to `spans` with an added + * `is_transaction:true` filter rather than a plain rename, so they are not + * listed here. */ const METRIC_DATASET_ALIASES: Record = { // Singular forms error: "errors", - transaction: "transactions", session: "sessions", metric: "metrics", - // Dashboard terminology for the error/transaction datasets + span: "spans", + // Dashboard terminology for the error dataset "error-events": "errors", - "transaction-like": "transactions", }; + +/** Deprecated dataset names that now route to `spans` with `is_transaction:true`. */ +const TRANSACTION_DATASET_NAMES = new Set(["transaction", "transactions"]); + +/** Query filter appended when migrating a `transaction(s)` request to `spans`. */ +const IS_TRANSACTION_FILTER = "is_transaction:true"; + +/** Splits a query string on runs of whitespace. */ +const QUERY_TOKEN_SEPARATOR = /\s+/; const METRIC_TIME_WINDOWS = new Set([ 1, 5, 10, 15, 30, 60, 120, 240, 360, 720, 1440, ]); @@ -203,15 +215,66 @@ export function normalizeProjectList( * Normalise a user-provided `--dataset` value to the canonical metric alert * dataset name accepted by the Sentry API. * - * Resolves known aliases (e.g. `error-events` → `errors`, `transaction` → - * `transactions`) so that values copied from dashboard docs or using singular - * forms work without manual translation. + * Resolves known aliases (e.g. `error-events` → `errors`) so that values copied + * from dashboard docs or using singular forms work without manual + * translation. */ export function normalizeMetricDataset(dataset: string): string { const lower = dataset.trim().toLowerCase(); return METRIC_DATASET_ALIASES[lower] ?? lower; } +/** + * Result of resolving a `--dataset` value, accounting for the deprecated + * `transaction(s)` datasets that now route to `spans`. + */ +export type ResolvedMetricDataset = { + /** Canonical dataset to send to the API. */ + readonly dataset: string; + /** Query with `is_transaction:true` appended when routed off `transaction(s)`. */ + readonly query: string; + /** A gentle one-line nudge to show the user, or `undefined` when none applies. */ + readonly notice?: string; +}; + +/** + * Append the `is_transaction:true` filter to a query, unless it is already + * present. Keeps existing filters intact and avoids duplicate tokens. + */ +function appendIsTransactionFilter(query: string): string { + const trimmed = query.trim(); + const tokens = trimmed.length > 0 ? trimmed.split(QUERY_TOKEN_SEPARATOR) : []; + if (tokens.includes(IS_TRANSACTION_FILTER)) { + return trimmed; + } + return [...tokens, IS_TRANSACTION_FILTER].join(" "); +} + +/** + * Resolve a user-provided `--dataset` (and its accompanying `--query`) to the + * dataset/query actually sent to the API. + * + * The `transactions` dataset was removed from alerts; the same use cases are + * served by `spans` filtered to transaction-like spans. Rather than hard-fail a + * `transaction(s)` request, route it to `spans` and add `is_transaction:true` + * to the query so existing muscle memory keeps working, returning a `notice` + * that nudges the user toward the canonical form. + */ +export function resolveMetricDataset( + dataset: string, + query: string +): ResolvedMetricDataset { + const lower = dataset.trim().toLowerCase(); + if (TRANSACTION_DATASET_NAMES.has(lower)) { + return { + dataset: "spans", + query: appendIsTransactionFilter(query), + notice: `The '${lower}' dataset is no longer supported for alerts. Routing to the 'spans' dataset with '${IS_TRANSACTION_FILTER}' added to the query. Use --dataset spans directly to silence this notice.`, + }; + } + return { dataset: normalizeMetricDataset(dataset), query }; +} + /** Validate that `dataset` is one of the allowed Sentry metric alert dataset values. */ export function validateMetricDataset(dataset: string): void { const normalized = normalizeMetricDataset(dataset); diff --git a/packages/cli/test/commands/alert/metrics/create.test.ts b/packages/cli/test/commands/alert/metrics/create.test.ts index 400c13ffe..470b565a7 100644 --- a/packages/cli/test/commands/alert/metrics/create.test.ts +++ b/packages/cli/test/commands/alert/metrics/create.test.ts @@ -321,4 +321,48 @@ describe("alert metrics create", () => { projects: ["backend"], }); }); + + test("routes --dataset transactions to spans with is_transaction:true and a tip", async () => { + const context = createContext(); + resolveOrgSpy.mockResolvedValue({ org: "test-org" }); + createSpy.mockResolvedValue({ + id: "77", + name: "Metric Rule", + status: 0, + }); + const func = (await createCommand.loader()) as unknown as ( + this: unknown, + flags: CreateFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + name: "Metric Rule", + query: "environment:prod", + aggregate: "p95(span.duration)", + dataset: "transactions", + "time-window": 5, + trigger: ['{"alertThreshold":100,"actions":[{"id":"notify"}]}'], + project: ["backend"], + "dry-run": false, + json: true, + }, + "test-org" + ); + + expect(createSpy).toHaveBeenCalledWith("test-org", { + name: "Metric Rule", + query: "environment:prod is_transaction:true", + aggregate: "p95(span.duration)", + dataset: "spans", + timeWindow: 5, + triggers: [{ alertThreshold: 100, actions: [{ id: "notify" }] }], + projects: ["backend"], + }); + expect(context.stderr.write).toHaveBeenCalledWith( + expect.stringContaining("is_transaction:true") + ); + }); }); diff --git a/packages/cli/test/commands/alert/metrics/edit.test.ts b/packages/cli/test/commands/alert/metrics/edit.test.ts index f03cf7811..8b6f0d556 100644 --- a/packages/cli/test/commands/alert/metrics/edit.test.ts +++ b/packages/cli/test/commands/alert/metrics/edit.test.ts @@ -98,7 +98,7 @@ describe("alert metrics edit", () => { status: 1, query: "event.type:error environment:prod", aggregate: "count()", - dataset: "transactions", + dataset: "spans", timeWindow: 15, triggers: [{ alertThreshold: 200, actions: [{ id: "notify" }] }], }); @@ -114,7 +114,7 @@ describe("alert metrics edit", () => { status: "disabled", query: "event.type:error environment:prod", aggregate: "count()", - dataset: "transactions", + dataset: "spans", "time-window": 15, trigger: ['{"alertThreshold":200,"actions":[{"id":"notify"}]}'], json: true, @@ -128,7 +128,7 @@ describe("alert metrics edit", () => { status: 1, query: "event.type:error environment:prod", aggregate: "count()", - dataset: "transactions", + dataset: "spans", timeWindow: 15, triggers: [{ alertThreshold: 200, actions: [{ id: "notify" }] }], }); @@ -232,4 +232,56 @@ describe("alert metrics edit", () => { expect.objectContaining({ query: "" }) ); }); + + test("routes --dataset transactions to spans with is_transaction:true and a tip", async () => { + const context = createContext(); + resolveSpy.mockResolvedValue({ org: "test-org" }); + getRuleSpy.mockResolvedValue(sampleRule); + getDocSpy.mockResolvedValue({ + id: "9", + name: "Metric Rule", + status: 0, + query: "environment:prod", + aggregate: "count()", + dataset: "errors", + timeWindow: 5, + triggers: [{ alertThreshold: 100, actions: [{ id: "notify" }] }], + }); + putSpy.mockResolvedValue({ + id: "9", + name: "Metric Rule", + status: 0, + query: "environment:prod is_transaction:true", + aggregate: "count()", + dataset: "spans", + timeWindow: 5, + triggers: [{ alertThreshold: 100, actions: [{ id: "notify" }] }], + }); + const func = (await editCommand.loader()) as unknown as ( + this: unknown, + flags: EditFlags, + arg: string + ) => Promise; + + await func.call( + context, + { + dataset: "transactions", + json: true, + }, + "test-org/9" + ); + + expect(putSpy).toHaveBeenCalledWith( + "test-org", + "9", + expect.objectContaining({ + dataset: "spans", + query: "environment:prod is_transaction:true", + }) + ); + expect(context.stderr.write).toHaveBeenCalledWith( + expect.stringContaining("is_transaction:true") + ); + }); }); diff --git a/packages/cli/test/commands/alert/mutation-utils.test.ts b/packages/cli/test/commands/alert/mutation-utils.test.ts index 86a29a112..79fa4dc9b 100644 --- a/packages/cli/test/commands/alert/mutation-utils.test.ts +++ b/packages/cli/test/commands/alert/mutation-utils.test.ts @@ -6,6 +6,7 @@ import { parseJsonObjectList, parseMatchMode, parseStatusFlag, + resolveMetricDataset, statusToMetricValue, triggerLogicType, validateIssueRuleArrays, @@ -212,23 +213,21 @@ describe("normalizeProjectList", () => { describe("normalizeMetricDataset", () => { test.each([ ["error", "errors"], - ["transaction", "transactions"], ["session", "sessions"], ["metric", "metrics"], + ["span", "spans"], ["error-events", "errors"], - ["transaction-like", "transactions"], ])('maps alias "%s" to "%s"', (input, expected) => { expect(normalizeMetricDataset(input)).toBe(expected); }); test("is case-insensitive and trims whitespace", () => { expect(normalizeMetricDataset("ERROR-EVENTS")).toBe("errors"); - expect(normalizeMetricDataset(" Transaction-Like ")).toBe("transactions"); + expect(normalizeMetricDataset(" Error-Events ")).toBe("errors"); }); test.each([ "errors", - "transactions", "sessions", "events", "spans", @@ -253,15 +252,51 @@ describe("normalizeMetricDataset", () => { }); }); -describe("validateMetricDataset", () => { - const valid = [ - "errors", +describe("resolveMetricDataset", () => { + test("passes canonical datasets through with the query untouched", () => { + expect(resolveMetricDataset("spans", "environment:prod")).toEqual({ + dataset: "spans", + query: "environment:prod", + }); + }); + + test("resolves aliases without a notice", () => { + expect(resolveMetricDataset("span", "environment:prod")).toEqual({ + dataset: "spans", + query: "environment:prod", + }); + }); + + test.each([ + "transaction", "transactions", - "sessions", - "events", - "spans", - "metrics", - ]; + "Transactions", + " TRANSACTION ", + ])('routes "%s" to spans and adds is_transaction:true with a notice', (input) => { + const result = resolveMetricDataset(input, "environment:prod"); + expect(result.dataset).toBe("spans"); + expect(result.query).toBe("environment:prod is_transaction:true"); + expect(result.notice).toContain("spans"); + expect(result.notice).toContain("is_transaction:true"); + }); + + test("adds the filter to an empty query", () => { + const result = resolveMetricDataset("transactions", ""); + expect(result.dataset).toBe("spans"); + expect(result.query).toBe("is_transaction:true"); + }); + + test("does not duplicate an existing is_transaction:true filter", () => { + const result = resolveMetricDataset( + "transactions", + "environment:prod is_transaction:true" + ); + expect(result.query).toBe("environment:prod is_transaction:true"); + }); +}); + +describe("validateMetricDataset", () => { + const valid = ["errors", "sessions", "events", "spans", "metrics"]; for (const dataset of valid) { test(`passes for "${dataset}"`, () => { @@ -272,8 +307,8 @@ describe("validateMetricDataset", () => { test.each([ "error", "error-events", - "transaction-like", "METRIC", + "span", ])('passes for alias "%s"', (dataset) => { expect(() => validateMetricDataset(dataset)).not.toThrow(); }); @@ -282,7 +317,9 @@ describe("validateMetricDataset", () => { "tracemetrics", "eap", "events_analytics_platform", - ])('throws for non-aliased dataset "%s"', (dataset) => { + "transactions", + "transaction-like", + ])('throws for obsolete or non-aliased dataset "%s"', (dataset) => { expect(() => validateMetricDataset(dataset)).toThrow(ValidationError); }); diff --git a/packages/cli/test/lib/api/alerts.test.ts b/packages/cli/test/lib/api/alerts.test.ts index 40a1e7646..5e42d82ca 100644 --- a/packages/cli/test/lib/api/alerts.test.ts +++ b/packages/cli/test/lib/api/alerts.test.ts @@ -130,8 +130,8 @@ function metricDetector(overrides: Record) { dateCreated: "2026-01-01T00:00:00Z", dataSources: [ { - aggregate: "p95(transaction.duration)", - dataset: "transactions", + aggregate: "p95(span.duration)", + dataset: "spans", query: "environment:prod", // Detectors expose the window in seconds; 300s == 5m. timeWindow: 300, @@ -159,8 +159,8 @@ describe("listMetricAlertsPaginated", () => { id: "9", name: "P95 latency", status: 0, - aggregate: "p95(transaction.duration)", - dataset: "transactions", + aggregate: "p95(span.duration)", + dataset: "spans", query: "environment:prod", // 300s from the detector payload is normalized to 5 minutes. timeWindow: 5, @@ -221,8 +221,8 @@ describe("getMetricAlertRule", () => { queryObj: { snubaQuery: { aggregate: "p75(measurements.lcp)", - dataset: "transactions", - query: "transaction.op:pageload", + dataset: "spans", + query: "span.op:pageload", timeWindow: 600, }, }, @@ -234,8 +234,8 @@ describe("getMetricAlertRule", () => { const rule = await getMetricAlertRule("test-org", "9"); expect(rule.aggregate).toBe("p75(measurements.lcp)"); - expect(rule.dataset).toBe("transactions"); - expect(rule.query).toBe("transaction.op:pageload"); + expect(rule.dataset).toBe("spans"); + expect(rule.query).toBe("span.op:pageload"); expect(rule.timeWindow).toBe(10); }); @@ -393,8 +393,8 @@ describe("createMetricAlertRule", () => { type: "metric_issue", dataSources: [ { - aggregate: "p95(transaction.duration)", - dataset: "transactions", + aggregate: "p95(span.duration)", + dataset: "spans", query: "environment:prod", queryType: 1, eventTypes: ["trace_item_span"], @@ -418,8 +418,8 @@ describe("createMetricAlertRule", () => { const created = await createMetricAlertRule("test-org", { name: "P95 latency", query: "environment:prod", - aggregate: "p95(transaction.duration)", - dataset: "transactions", + aggregate: "p95(span.duration)", + dataset: "spans", timeWindow: 5, environment: "prod", triggers: [{ alertThreshold: 500, actions: [{ id: "notify" }] }], @@ -480,8 +480,8 @@ describe("getMetricAlertRuleDocument", () => { expect(doc).toMatchObject({ id: "9", name: "Baseline", - aggregate: "p95(transaction.duration)", - dataset: "transactions", + aggregate: "p95(span.duration)", + dataset: "spans", timeWindow: 5, }); }); @@ -506,8 +506,8 @@ describe("putMetricAlertRule", () => { const updated = await putMetricAlertRule("test-org", "9", { name: "Renamed", query: "environment:prod", - aggregate: "p95(transaction.duration)", - dataset: "transactions", + aggregate: "p95(span.duration)", + dataset: "spans", timeWindow: 5, status: 1, triggers: [{ alertThreshold: 500, actions: [{ id: "notify" }] }],