Skip to content

Commit a87068e

Browse files
committed
fix(dashboard): accept dataset aliases (errors, transactions, metrics) (CLI-JG)
The Sentry API/UI and docs surface dataset names like "errors" and "transactions" but the internal widget type values are "error-events" and "transaction-like". Previously the CLI only accepted the canonical names, so `sentry dashboard widget add --dataset errors` raised: Error: Invalid --dataset value "errors". Valid datasets: discover, issue, error-events, transaction-like, spans, logs, tracemetrics, preprod-app-size Users (and AI agents) copying from Sentry documentation routinely hit this. The CLI now accepts common synonyms and normalizes case: errors, error → error-events transactions, transaction → transaction-like metrics, metricsEnhanced → tracemetrics log → logs ## Design — normalize once, up front `normalizeDataset()` resolves aliases and lower-cases the input. It is called at the top of `widget add` and `widget edit` `func()` bodies; the result replaces `flags.dataset` everywhere downstream so every consumer sees the canonical widget type. This ordering is critical. Dataset-aware aggregate validation (`validateAggregateNames` in `src/types/dashboard.ts`) branches on the dataset name — e.g. `failure_rate` is whitelisted for `error-events` / `discover` but not for an alias like `errors`. If normalization runs only inside `validateWidgetEnums`, the aggregate validator sees the unresolved alias and rejects valid queries. Regression test: `--dataset errors --query failure_rate` now succeeds and produces `widgetType: "error-events"` in the PUT body. ## Other changes - `--dataset` flag `brief` in both add and edit now lists accepted canonical names and aliases. - `dashboard widget` route `fullDescription` shows the alias mapping and calls out case-insensitive matching. - `validateWidgetEnums` is now a pure validator (no mutation, no return value changes). JSDoc documents that callers must pre-normalize. - Added unit tests for `normalizeDataset` covering all aliases, canonical pass-through, case-insensitivity, and unknown-input behavior. - Added integration tests for add/edit covering alias resolution, cross-dataset aggregate validation, case-insensitive input, and unknown-dataset rejection. All 5548 unit tests pass. Based on work in #784 by @cursor; split out per review. Fixes CLI-JG.
1 parent d0024f0 commit a87068e

8 files changed

Lines changed: 366 additions & 22 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/references/dashboard.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Add a widget to a dashboard
7676

7777
**Flags:**
7878
- `-d, --display <value> - Display type (big_number, line, area, bar, table, stacked_area, top_n, text, categorical_bar, details, wheel, rage_and_dead_clicks, server_tree, agents_traces_table)`
79-
- `--dataset <value> - Widget dataset (default: spans)`
79+
- `--dataset <value> - Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover`
8080
- `-q, --query <value>... - Aggregate expression (e.g. count, p95:span.duration)`
8181
- `-w, --where <value> - Search conditions filter (e.g. is:unresolved)`
8282
- `-g, --group-by <value>... - Group-by column (repeatable)`
@@ -122,7 +122,7 @@ Edit a widget in a dashboard
122122
- `-t, --title <value> - Widget title to match`
123123
- `--new-title <value> - New widget title`
124124
- `-d, --display <value> - Display type (big_number, line, area, bar, table, stacked_area, top_n, text, categorical_bar, details, wheel, rage_and_dead_clicks, server_tree, agents_traces_table)`
125-
- `--dataset <value> - Widget dataset (default: spans)`
125+
- `--dataset <value> - Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover`
126126
- `-q, --query <value>... - Aggregate expression (e.g. count, p95:span.duration)`
127127
- `-w, --where <value> - Search conditions filter (e.g. is:unresolved)`
128128
- `-g, --group-by <value>... - Group-by column (repeatable)`

src/commands/dashboard/resolve.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -609,11 +609,62 @@ export function enrichDashboardError(
609609
throw error;
610610
}
611611

612+
/**
613+
* User-facing dataset synonyms resolved to the canonical Sentry widget type.
614+
*
615+
* The Sentry API/UI and docs surface names like `errors` and `transactions`
616+
* but widget types use `error-events` and `transaction-like`. The CLI accepts
617+
* both forms so users copying from docs or using API-dataset terminology
618+
* don't have to translate.
619+
*
620+
* Keys are lowercase; matching is case-insensitive via {@link normalizeDataset}.
621+
*/
622+
const DATASET_ALIASES: Record<string, string> = {
623+
error: "error-events",
624+
errors: "error-events",
625+
transaction: "transaction-like",
626+
transactions: "transaction-like",
627+
log: "logs",
628+
// `metrics` and `metricsEnhanced` both alias to the canonical `tracemetrics`.
629+
// `metricsEnhanced` is the value surfaced by the events API dataset param
630+
// (see WIDGET_TYPE_TO_DATASET in types/dashboard.ts) and may appear in docs.
631+
metrics: "tracemetrics",
632+
metricsenhanced: "tracemetrics",
633+
};
634+
635+
/**
636+
* Normalize a user-provided `--dataset` value to the canonical widget type.
637+
*
638+
* - Case-insensitive (e.g. `Errors`, `ERRORS` → `error-events`).
639+
* - Maps known synonyms via {@link DATASET_ALIASES}.
640+
* - Returns the lower-cased input unchanged if no alias matches (the enum
641+
* check in {@link validateWidgetEnums} will reject unknown values).
642+
* - Returns `undefined` for `undefined` input.
643+
*
644+
* Must be called once, up-front, and the result threaded through every
645+
* downstream consumer (aggregate validator, warnings, PUT body). Leaving
646+
* an un-normalized value in `flags.dataset` causes dataset-specific
647+
* aggregate validation (e.g. `failure_rate` for `error-events`) to see
648+
* the alias instead of the canonical name and reject valid inputs.
649+
*/
650+
export function normalizeDataset(dataset?: string): string | undefined {
651+
if (dataset === undefined) {
652+
return;
653+
}
654+
const lower = dataset.toLowerCase();
655+
return DATASET_ALIASES[lower] ?? lower;
656+
}
657+
612658
/**
613659
* Validate --display and --dataset flag values against known enums.
614660
*
661+
* Callers MUST pass a dataset value already normalized via
662+
* {@link normalizeDataset} so aliases and casing don't leak into the
663+
* enum check. This function does not mutate or resolve aliases itself —
664+
* it is a pure validator.
665+
*
615666
* @param display - Display type flag value
616-
* @param dataset - Dataset flag value
667+
* @param dataset - Dataset flag value (already normalized)
617668
*/
618669
export function validateWidgetEnums(display?: string, dataset?: string): void {
619670
if (

src/commands/dashboard/widget/add.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import {
2626
buildWidgetFromFlags,
2727
enrichDashboardError,
28+
normalizeDataset,
2829
parseDashboardPositionalArgs,
2930
resolveDashboardId,
3031
resolveOrgFromTarget,
@@ -121,7 +122,8 @@ export const addCommand = buildCommand({
121122
dataset: {
122123
kind: "parsed",
123124
parse: String,
124-
brief: "Widget dataset (default: spans)",
125+
brief:
126+
"Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover",
125127
optional: true,
126128
},
127129
query: {
@@ -204,8 +206,14 @@ export const addCommand = buildCommand({
204206

205207
const { dashboardArgs, title } = parseAddPositionalArgs(args);
206208

209+
// Resolve dataset aliases (e.g. "errors" → "error-events") once, up front.
210+
// Every downstream consumer — enum validation, dataset-aware aggregate
211+
// validation, the PUT body — must see the canonical name, so we thread
212+
// the normalized value and never reference flags.dataset below.
213+
const dataset = normalizeDataset(flags.dataset);
214+
207215
// Validate enums before any network calls (fail fast)
208-
validateWidgetEnums(flags.display, flags.dataset);
216+
validateWidgetEnums(flags.display, dataset);
209217

210218
const { dashboardRef, targetArg } =
211219
parseDashboardPositionalArgs(dashboardArgs);
@@ -220,7 +228,7 @@ export const addCommand = buildCommand({
220228
let newWidget = buildWidgetFromFlags({
221229
title,
222230
display: flags.display,
223-
dataset: flags.dataset,
231+
dataset,
224232
query: flags.query,
225233
where: flags.where,
226234
groupBy: flags["group-by"],

src/commands/dashboard/widget/edit.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from "../../../types/dashboard.js";
2929
import {
3030
enrichDashboardError,
31+
normalizeDataset,
3132
parseDashboardPositionalArgs,
3233
resolveDashboardId,
3334
resolveOrgFromTarget,
@@ -247,7 +248,8 @@ export const editCommand = buildCommand({
247248
dataset: {
248249
kind: "parsed",
249250
parse: String,
250-
brief: "Widget dataset (default: spans)",
251+
brief:
252+
"Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover",
251253
optional: true,
252254
},
253255
query: {
@@ -332,7 +334,19 @@ export const editCommand = buildCommand({
332334
);
333335
}
334336

335-
validateWidgetEnums(flags.display, flags.dataset);
337+
// Resolve dataset aliases (e.g. "errors" → "error-events") once, up front.
338+
// Replace flags.dataset with the canonical value so every downstream
339+
// consumer — validateEnumsAndAggregates, validateAggregateNames, and the
340+
// PUT body — sees the normalized name. Without this, dataset-aware
341+
// aggregate validation (e.g. failure_rate for error-events) would fail
342+
// when the user passes --dataset errors.
343+
const normalizedDataset = normalizeDataset(flags.dataset);
344+
const normalizedFlags: EditFlags =
345+
normalizedDataset === flags.dataset
346+
? flags
347+
: { ...flags, dataset: normalizedDataset };
348+
349+
validateWidgetEnums(normalizedFlags.display, normalizedFlags.dataset);
336350

337351
const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args);
338352
const parsed = parseOrgProjectArg(targetArg);
@@ -349,15 +363,19 @@ export const editCommand = buildCommand({
349363
enrichDashboardError(error, { orgSlug, dashboardId, operation: "view" })
350364
);
351365
const widgets = current.widgets ?? [];
352-
const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title);
366+
const widgetIndex = resolveWidgetIndex(
367+
widgets,
368+
normalizedFlags.index,
369+
normalizedFlags.title
370+
);
353371

354372
const updateBody = prepareDashboardForUpdate(current);
355373
const existing = updateBody.widgets[widgetIndex] as DashboardWidget;
356374

357375
// Validate individual layout flag ranges early (catches --col -1, --width 7, etc.)
358-
validateWidgetLayout(flags, existing.layout);
376+
validateWidgetLayout(normalizedFlags, existing.layout);
359377

360-
const replacement = buildReplacement(flags, existing);
378+
const replacement = buildReplacement(normalizedFlags, existing);
361379

362380
// Re-validate the final merged layout when the existing widget had no layout
363381
// and FALLBACK_LAYOUT was used — the early check couldn't cross-validate

src/commands/dashboard/widget/index.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,25 @@ export const widgetRoute = buildRouteMap({
1919
" specialized: stacked_area (3×2), top_n (3×2), categorical_bar (3×2), text (3×2)\n" +
2020
" internal: details (3×2), wheel (3×2), rage_and_dead_clicks (3×2),\n" +
2121
" server_tree (3×2), agents_traces_table (3×2)\n\n" +
22-
"Datasets:\n" +
23-
" spans (default) Span-based queries: span.duration, span.op, transaction,\n" +
24-
" span attributes, cache.hit, etc. Covers most use cases.\n" +
25-
" tracemetrics Custom metrics from Sentry.metrics.distribution/gauge/count.\n" +
26-
" Query format: aggregation(value,metric_name,metric_type,unit)\n" +
27-
" Example: p50(value,completion.duration_ms,distribution,none)\n" +
28-
" Supported displays: line, area, bar, big_number, categorical_bar\n" +
29-
" discover Legacy discover queries (adds failure_rate, apdex, etc.)\n" +
30-
" issue Issue-based queries\n" +
31-
" error-events Error event queries\n" +
32-
" logs Log queries\n\n" +
22+
"Datasets (canonical name — accepted aliases):\n" +
23+
" spans (default) Span-based queries: span.duration, span.op,\n" +
24+
" transaction, span attributes, cache.hit, etc.\n" +
25+
" Covers most use cases.\n" +
26+
" tracemetrics — metrics, Custom metrics from Sentry.metrics.distribution/\n" +
27+
" metricsEnhanced gauge/count. Query format:\n" +
28+
" aggregation(value,metric_name,metric_type,unit)\n" +
29+
" Example: p50(value,completion.duration_ms,distribution,none)\n" +
30+
" Supported displays: line, area, bar, big_number,\n" +
31+
" categorical_bar\n" +
32+
" discover Legacy discover queries (adds failure_rate,\n" +
33+
" apdex, etc.)\n" +
34+
" issue Issue-based queries\n" +
35+
" error-events — errors, error Error event queries\n" +
36+
" transaction-like — transactions, transaction\n" +
37+
" Transaction-based queries\n" +
38+
" logs — log Log queries\n\n" +
39+
"Dataset values are case-insensitive; Sentry UI/API names like 'errors'\n" +
40+
"and 'transactions' are accepted in addition to the canonical forms.\n\n" +
3341
"Aggregates (spans): count, count_unique, sum, avg, percentile, p50, p75,\n" +
3442
" p90, p95, p99, p100, eps, epm, any, min, max\n" +
3543
"Aggregates (discover adds): failure_count, failure_rate, apdex,\n" +

test/commands/dashboard/resolve.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
99
import {
1010
enrichDashboardError,
11+
normalizeDataset,
1112
parseDashboardListArgs,
1213
parseDashboardPositionalArgs,
1314
resolveDashboardId,
1415
resolveOrgFromTarget,
16+
validateWidgetEnums,
1517
} from "../../../src/commands/dashboard/resolve.js";
1618
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
1719
import * as apiClient from "../../../src/lib/api-client.js";
@@ -542,3 +544,82 @@ describe("enrichDashboardError", () => {
542544
).toThrow(apiErr);
543545
});
544546
});
547+
548+
// ---------------------------------------------------------------------------
549+
// normalizeDataset + validateWidgetEnums
550+
// ---------------------------------------------------------------------------
551+
552+
describe("normalizeDataset", () => {
553+
test("returns undefined for undefined input", () => {
554+
expect(normalizeDataset(undefined)).toBeUndefined();
555+
});
556+
557+
test("lowercases canonical values (pass-through)", () => {
558+
expect(normalizeDataset("spans")).toBe("spans");
559+
expect(normalizeDataset("error-events")).toBe("error-events");
560+
expect(normalizeDataset("transaction-like")).toBe("transaction-like");
561+
expect(normalizeDataset("tracemetrics")).toBe("tracemetrics");
562+
expect(normalizeDataset("logs")).toBe("logs");
563+
expect(normalizeDataset("issue")).toBe("issue");
564+
expect(normalizeDataset("discover")).toBe("discover");
565+
});
566+
567+
test("resolves error/errors aliases", () => {
568+
expect(normalizeDataset("errors")).toBe("error-events");
569+
expect(normalizeDataset("error")).toBe("error-events");
570+
});
571+
572+
test("resolves transaction/transactions aliases", () => {
573+
expect(normalizeDataset("transactions")).toBe("transaction-like");
574+
expect(normalizeDataset("transaction")).toBe("transaction-like");
575+
});
576+
577+
test("resolves metrics and metricsEnhanced aliases", () => {
578+
expect(normalizeDataset("metrics")).toBe("tracemetrics");
579+
expect(normalizeDataset("metricsEnhanced")).toBe("tracemetrics");
580+
});
581+
582+
test("resolves log alias", () => {
583+
expect(normalizeDataset("log")).toBe("logs");
584+
});
585+
586+
test("case-insensitive matching", () => {
587+
expect(normalizeDataset("Errors")).toBe("error-events");
588+
expect(normalizeDataset("ERRORS")).toBe("error-events");
589+
expect(normalizeDataset("SPANS")).toBe("spans");
590+
expect(normalizeDataset("MetricsEnhanced")).toBe("tracemetrics");
591+
});
592+
593+
test("returns lowercased unknown input unchanged for validator to reject", () => {
594+
expect(normalizeDataset("unknown-dataset")).toBe("unknown-dataset");
595+
expect(normalizeDataset("DoesNotExist")).toBe("doesnotexist");
596+
});
597+
});
598+
599+
describe("validateWidgetEnums (with normalizeDataset)", () => {
600+
test("accepts a normalized alias without error", () => {
601+
// Pipeline: caller runs normalizeDataset first, then passes the canonical
602+
// value to validateWidgetEnums. This simulates the wiring in add/edit.
603+
expect(() =>
604+
validateWidgetEnums("bar", normalizeDataset("errors"))
605+
).not.toThrow();
606+
});
607+
608+
test("rejects an unresolved alias when passed un-normalized", () => {
609+
// Guard: forgetting to normalize surfaces as a ValidationError listing
610+
// canonical values, not silent success.
611+
expect(() => validateWidgetEnums("bar", "errors")).toThrow(ValidationError);
612+
});
613+
614+
test("rejects unknown datasets (no such alias)", () => {
615+
expect(() => validateWidgetEnums(undefined, "bogus-dataset")).toThrow(
616+
ValidationError
617+
);
618+
});
619+
620+
test("rejects unknown display types", () => {
621+
expect(() => validateWidgetEnums("pie-chart", undefined)).toThrow(
622+
ValidationError
623+
);
624+
});
625+
});

0 commit comments

Comments
 (0)