Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/sentry-cli/skills/sentry-cli/references/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Add a widget to a dashboard

**Flags:**
- `-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)`
- `--dataset <value> - Widget dataset (default: spans)`
- `--dataset <value> - Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover`
- `-q, --query <value>... - Aggregate expression (e.g. count, p95:span.duration)`
- `-w, --where <value> - Search conditions filter (e.g. is:unresolved)`
- `-g, --group-by <value>... - Group-by column (repeatable)`
Expand Down Expand Up @@ -122,7 +122,7 @@ Edit a widget in a dashboard
- `-t, --title <value> - Widget title to match`
- `--new-title <value> - New widget title`
- `-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)`
- `--dataset <value> - Widget dataset (default: spans)`
- `--dataset <value> - Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover`
- `-q, --query <value>... - Aggregate expression (e.g. count, p95:span.duration)`
- `-w, --where <value> - Search conditions filter (e.g. is:unresolved)`
- `-g, --group-by <value>... - Group-by column (repeatable)`
Expand Down
53 changes: 52 additions & 1 deletion src/commands/dashboard/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,11 +609,62 @@ export function enrichDashboardError(
throw error;
}

/**
* User-facing dataset synonyms resolved to the canonical Sentry widget type.
*
* The Sentry API/UI and docs surface names like `errors` and `transactions`
* but widget types use `error-events` and `transaction-like`. The CLI accepts
* both forms so users copying from docs or using API-dataset terminology
* don't have to translate.
*
* Keys are lowercase; matching is case-insensitive via {@link normalizeDataset}.
*/
const DATASET_ALIASES: Record<string, string> = {
error: "error-events",
errors: "error-events",
transaction: "transaction-like",
transactions: "transaction-like",
log: "logs",
// `metrics` and `metricsEnhanced` both alias to the canonical `tracemetrics`.
// `metricsEnhanced` is the value surfaced by the events API dataset param
// (see WIDGET_TYPE_TO_DATASET in types/dashboard.ts) and may appear in docs.
metrics: "tracemetrics",
metricsenhanced: "tracemetrics",
};

/**
* Normalize a user-provided `--dataset` value to the canonical widget type.
*
* - Case-insensitive (e.g. `Errors`, `ERRORS` → `error-events`).
* - Maps known synonyms via {@link DATASET_ALIASES}.
* - Returns the lower-cased input unchanged if no alias matches (the enum
* check in {@link validateWidgetEnums} will reject unknown values).
* - Returns `undefined` for `undefined` input.
*
* Must be called once, up-front, and the result threaded through every
* downstream consumer (aggregate validator, warnings, PUT body). Leaving
* an un-normalized value in `flags.dataset` causes dataset-specific
* aggregate validation (e.g. `failure_rate` for `error-events`) to see
* the alias instead of the canonical name and reject valid inputs.
*/
export function normalizeDataset(dataset?: string): string | undefined {
if (dataset === undefined) {
return;
}
const lower = dataset.toLowerCase();
return DATASET_ALIASES[lower] ?? lower;
}

/**
* Validate --display and --dataset flag values against known enums.
*
* Callers MUST pass a dataset value already normalized via
* {@link normalizeDataset} so aliases and casing don't leak into the
* enum check. This function does not mutate or resolve aliases itself —
* it is a pure validator.
*
* @param display - Display type flag value
* @param dataset - Dataset flag value
* @param dataset - Dataset flag value (already normalized)
*/
export function validateWidgetEnums(display?: string, dataset?: string): void {
if (
Expand Down
14 changes: 11 additions & 3 deletions src/commands/dashboard/widget/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
buildWidgetFromFlags,
enrichDashboardError,
normalizeDataset,
parseDashboardPositionalArgs,
resolveDashboardId,
resolveOrgFromTarget,
Expand Down Expand Up @@ -121,7 +122,8 @@ export const addCommand = buildCommand({
dataset: {
kind: "parsed",
parse: String,
brief: "Widget dataset (default: spans)",
brief:
"Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover",
optional: true,
},
query: {
Expand Down Expand Up @@ -204,8 +206,14 @@ export const addCommand = buildCommand({

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

// Resolve dataset aliases (e.g. "errors" → "error-events") once, up front.
// Every downstream consumer — enum validation, dataset-aware aggregate
// validation, the PUT body — must see the canonical name, so we thread
// the normalized value and never reference flags.dataset below.
const dataset = normalizeDataset(flags.dataset);

// Validate enums before any network calls (fail fast)
validateWidgetEnums(flags.display, flags.dataset);
validateWidgetEnums(flags.display, dataset);

const { dashboardRef, targetArg } =
parseDashboardPositionalArgs(dashboardArgs);
Expand All @@ -220,7 +228,7 @@ export const addCommand = buildCommand({
let newWidget = buildWidgetFromFlags({
title,
display: flags.display,
dataset: flags.dataset,
dataset,
query: flags.query,
where: flags.where,
groupBy: flags["group-by"],
Expand Down
28 changes: 23 additions & 5 deletions src/commands/dashboard/widget/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "../../../types/dashboard.js";
import {
enrichDashboardError,
normalizeDataset,
parseDashboardPositionalArgs,
resolveDashboardId,
resolveOrgFromTarget,
Expand Down Expand Up @@ -247,7 +248,8 @@ export const editCommand = buildCommand({
dataset: {
kind: "parsed",
parse: String,
brief: "Widget dataset (default: spans)",
brief:
"Widget dataset (default: spans). Accepts canonical names and API synonyms: spans, error-events/errors, transaction-like/transactions, tracemetrics/metrics, logs, issue, discover",
optional: true,
},
query: {
Expand Down Expand Up @@ -332,7 +334,19 @@ export const editCommand = buildCommand({
);
}

validateWidgetEnums(flags.display, flags.dataset);
// Resolve dataset aliases (e.g. "errors" → "error-events") once, up front.
// Replace flags.dataset with the canonical value so every downstream
// consumer — validateEnumsAndAggregates, validateAggregateNames, and the
// PUT body — sees the normalized name. Without this, dataset-aware
// aggregate validation (e.g. failure_rate for error-events) would fail
// when the user passes --dataset errors.
const normalizedDataset = normalizeDataset(flags.dataset);
const normalizedFlags: EditFlags =
normalizedDataset === flags.dataset
? flags
: { ...flags, dataset: normalizedDataset };

validateWidgetEnums(normalizedFlags.display, normalizedFlags.dataset);

const { dashboardRef, targetArg } = parseDashboardPositionalArgs(args);
const parsed = parseOrgProjectArg(targetArg);
Expand All @@ -349,15 +363,19 @@ export const editCommand = buildCommand({
enrichDashboardError(error, { orgSlug, dashboardId, operation: "view" })
);
const widgets = current.widgets ?? [];
const widgetIndex = resolveWidgetIndex(widgets, flags.index, flags.title);
const widgetIndex = resolveWidgetIndex(
widgets,
normalizedFlags.index,
normalizedFlags.title
);

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

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

const replacement = buildReplacement(flags, existing);
const replacement = buildReplacement(normalizedFlags, existing);

// Re-validate the final merged layout when the existing widget had no layout
// and FALLBACK_LAYOUT was used — the early check couldn't cross-validate
Expand Down
30 changes: 19 additions & 11 deletions src/commands/dashboard/widget/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,25 @@ export const widgetRoute = buildRouteMap({
" specialized: stacked_area (3×2), top_n (3×2), categorical_bar (3×2), text (3×2)\n" +
" internal: details (3×2), wheel (3×2), rage_and_dead_clicks (3×2),\n" +
" server_tree (3×2), agents_traces_table (3×2)\n\n" +
"Datasets:\n" +
" spans (default) Span-based queries: span.duration, span.op, transaction,\n" +
" span attributes, cache.hit, etc. Covers most use cases.\n" +
" tracemetrics Custom metrics from Sentry.metrics.distribution/gauge/count.\n" +
" Query format: aggregation(value,metric_name,metric_type,unit)\n" +
" Example: p50(value,completion.duration_ms,distribution,none)\n" +
" Supported displays: line, area, bar, big_number, categorical_bar\n" +
" discover Legacy discover queries (adds failure_rate, apdex, etc.)\n" +
" issue Issue-based queries\n" +
" error-events Error event queries\n" +
" logs Log queries\n\n" +
"Datasets (canonical name — accepted aliases):\n" +
" spans (default) Span-based queries: span.duration, span.op,\n" +
" transaction, span attributes, cache.hit, etc.\n" +
" Covers most use cases.\n" +
" tracemetrics — metrics, Custom metrics from Sentry.metrics.distribution/\n" +
" metricsEnhanced gauge/count. Query format:\n" +
" aggregation(value,metric_name,metric_type,unit)\n" +
" Example: p50(value,completion.duration_ms,distribution,none)\n" +
" Supported displays: line, area, bar, big_number,\n" +
" categorical_bar\n" +
" discover Legacy discover queries (adds failure_rate,\n" +
" apdex, etc.)\n" +
" issue Issue-based queries\n" +
" error-events — errors, error Error event queries\n" +
" transaction-like — transactions, transaction\n" +
" Transaction-based queries\n" +
" logs — log Log queries\n\n" +
"Dataset values are case-insensitive; Sentry UI/API names like 'errors'\n" +
"and 'transactions' are accepted in addition to the canonical forms.\n\n" +
"Aggregates (spans): count, count_unique, sum, avg, percentile, p50, p75,\n" +
" p90, p95, p99, p100, eps, epm, any, min, max\n" +
"Aggregates (discover adds): failure_count, failure_rate, apdex,\n" +
Expand Down
81 changes: 81 additions & 0 deletions test/commands/dashboard/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import {
enrichDashboardError,
normalizeDataset,
parseDashboardListArgs,
parseDashboardPositionalArgs,
resolveDashboardId,
resolveOrgFromTarget,
validateWidgetEnums,
} from "../../../src/commands/dashboard/resolve.js";
// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking
import * as apiClient from "../../../src/lib/api-client.js";
Expand Down Expand Up @@ -542,3 +544,82 @@ describe("enrichDashboardError", () => {
).toThrow(apiErr);
});
});

// ---------------------------------------------------------------------------
// normalizeDataset + validateWidgetEnums
// ---------------------------------------------------------------------------

describe("normalizeDataset", () => {
test("returns undefined for undefined input", () => {
expect(normalizeDataset(undefined)).toBeUndefined();
});

test("lowercases canonical values (pass-through)", () => {
expect(normalizeDataset("spans")).toBe("spans");
expect(normalizeDataset("error-events")).toBe("error-events");
expect(normalizeDataset("transaction-like")).toBe("transaction-like");
expect(normalizeDataset("tracemetrics")).toBe("tracemetrics");
expect(normalizeDataset("logs")).toBe("logs");
expect(normalizeDataset("issue")).toBe("issue");
expect(normalizeDataset("discover")).toBe("discover");
});

test("resolves error/errors aliases", () => {
expect(normalizeDataset("errors")).toBe("error-events");
expect(normalizeDataset("error")).toBe("error-events");
});

test("resolves transaction/transactions aliases", () => {
expect(normalizeDataset("transactions")).toBe("transaction-like");
expect(normalizeDataset("transaction")).toBe("transaction-like");
});

test("resolves metrics and metricsEnhanced aliases", () => {
expect(normalizeDataset("metrics")).toBe("tracemetrics");
expect(normalizeDataset("metricsEnhanced")).toBe("tracemetrics");
});

test("resolves log alias", () => {
expect(normalizeDataset("log")).toBe("logs");
});

test("case-insensitive matching", () => {
expect(normalizeDataset("Errors")).toBe("error-events");
expect(normalizeDataset("ERRORS")).toBe("error-events");
expect(normalizeDataset("SPANS")).toBe("spans");
expect(normalizeDataset("MetricsEnhanced")).toBe("tracemetrics");
});

test("returns lowercased unknown input unchanged for validator to reject", () => {
expect(normalizeDataset("unknown-dataset")).toBe("unknown-dataset");
expect(normalizeDataset("DoesNotExist")).toBe("doesnotexist");
});
});

describe("validateWidgetEnums (with normalizeDataset)", () => {
test("accepts a normalized alias without error", () => {
// Pipeline: caller runs normalizeDataset first, then passes the canonical
// value to validateWidgetEnums. This simulates the wiring in add/edit.
expect(() =>
validateWidgetEnums("bar", normalizeDataset("errors"))
).not.toThrow();
});

test("rejects an unresolved alias when passed un-normalized", () => {
// Guard: forgetting to normalize surfaces as a ValidationError listing
// canonical values, not silent success.
expect(() => validateWidgetEnums("bar", "errors")).toThrow(ValidationError);
});

test("rejects unknown datasets (no such alias)", () => {
expect(() => validateWidgetEnums(undefined, "bogus-dataset")).toThrow(
ValidationError
);
});

test("rejects unknown display types", () => {
expect(() => validateWidgetEnums("pie-chart", undefined)).toThrow(
ValidationError
);
});
});
Loading
Loading