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
12 changes: 5 additions & 7 deletions packages/cli/src/commands/dashboard/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* ID resolution from numeric IDs or title strings.
*/

import { parseFunction } from "@sentry/toolkit-core/events-fields";
import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js";
import {
API_MAX_PER_PAGE,
Expand Down Expand Up @@ -544,16 +545,13 @@ function warnUnknownAggregateFields(
return;
}
for (const agg of aggregates) {
const parenIdx = agg.indexOf("(");
if (parenIdx < 0) {
continue;
}
const fn = agg.slice(0, parenIdx);
const parsed = parseFunction(agg);
// Only check numeric aggregate functions — count_unique, any, etc. accept any column
if (!NUMERIC_AGGREGATE_FUNCTIONS.has(fn)) {
if (!(parsed && NUMERIC_AGGREGATE_FUNCTIONS.has(parsed.name))) {
continue;
}
const inner = agg.slice(parenIdx + 1, -1);
// The aggregated field is the first argument; percentile() adds a level.
const inner = parsed.arguments[0];
if (!inner) {
continue;
}
Expand Down
93 changes: 19 additions & 74 deletions packages/cli/src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
* and ad-hoc event queries.
*/

import {
isAggregateFieldOrEquation,
isTracemetricsAggregate,
} from "@sentry/toolkit-core/events-fields";
import { resolveEventsSort } from "@sentry/toolkit-core/events-sort";
import type { SentryContext } from "../context.js";
import {
isReplaySortValue,
Expand Down Expand Up @@ -337,7 +342,7 @@ function appendFieldHints(
): void {
const fields = rawFields ?? [];
const fieldList = metricActive
? fields.filter((f) => !isAggregate(f))
? fields.filter((f) => !isAggregateFieldOrEquation(f))
: fields;
const defaults = defaultFieldsForDataset(dataset).join(",");
if (fieldList.join(",") !== defaults && fieldList.length > 0) {
Expand Down Expand Up @@ -388,32 +393,6 @@ function appendFlagHints(
return parts.length > 0 ? `${base} ${parts.join(" ")}` : base;
}

/**
* Detect the first aggregate function in the field list.
* Aggregates contain parentheses, e.g., `count()`, `p50(span.duration)`.
*/
function findFirstAggregate(fieldList: string[]): string | undefined {
return fieldList.find((f) => f.includes("(") && f.includes(")"));
}

/** True when the field looks like an aggregate call: `fn(...)`. */
function isAggregate(field: string): boolean {
return field.includes("(") && field.endsWith(")");
}

/**
* True when the aggregate uses the tracemetrics comma-separated format:
* `aggregation(value,metric_name,metric_type,unit)`.
*/
function isTracemetricsAggregate(aggregate: string): boolean {
const parenIdx = aggregate.indexOf("(");
if (parenIdx < 0) {
return false;
}
const inner = aggregate.slice(parenIdx + 1, -1);
return inner.startsWith("value,") && inner.split(",").length === 4;
}

/**
* Validate that aggregate fields use the tracemetrics format when querying
* the `tracemetrics` dataset. Standard aggregates like `count()` or
Expand All @@ -422,7 +401,7 @@ function isTracemetricsAggregate(aggregate: string): boolean {
*/
function validateMetricsFields(fieldList: string[]): void {
const badAggs = fieldList.filter(
(f) => isAggregate(f) && !isTracemetricsAggregate(f),
(f) => isAggregateFieldOrEquation(f) && !isTracemetricsAggregate(f),
);
if (badAggs.length === 0) {
return;
Expand All @@ -447,27 +426,6 @@ function validateMetricsFields(fieldList: string[]): void {
// Dataset configuration
// ---------------------------------------------------------------------------

/**
* Datasets whose Events endpoint accepts a `sort` param. A deterministic sort
* is what makes offset-based cursor pagination stable — without it, multi-page
* grouped aggregate queries overlap and skip rows (#1519). `metrics`
* (`tracemetrics`) and `logs` reject `sort` with a 400, so they stay unsorted.
*
* This is a hand-curated contract, not a machine-readable capability lookup,
* because no canonical source for it exists today (#1523):
* - The OpenAPI spec (`@sentry/api` `listOrganizationEvents`) models `sort`
* as a flat query param whose only documented constraint is "must be in
* the `field` list" — it does not encode which datasets accept it.
* - There is no dataset-capability introspection endpoint in the spec.
* - The spec's `dataset` enum is
* `errors | logs | profile_functions | spans | tracemetrics |
* uptime_results`; `discover` is a legacy virtual dataset that is not a
* valid `--dataset` value here (see {@link DATASET_ALIASES}), so it is
* intentionally omitted.
* Revisit if Sentry ever publishes a per-dataset capability contract.
*/
const SORTABLE_DATASETS = new Set(["spans", "errors"]);

/**
* Dataset-specific configuration resolved before the main query loop.
*
Expand Down Expand Up @@ -526,8 +484,9 @@ function resolveEventsDataset(dataset: string, query: string | undefined) {
* For the `replays` dataset this validates fields, resolves replay-specific
* sort, and returns a fetch function that calls `listReplays`. For all other
* datasets it translates `--environment` values into `environment:...` query
* filter terms, resolves explore sort (spans-only), prepends `project:<slug>`
* to the query, and returns a `queryEvents` fetch.
* filter terms, resolves the sort with {@link resolveEventsSort} (which also
* selects the sort column), prepends `project:<slug>` to the query, and
* returns a `queryEvents` fetch.
*/
function resolveDatasetConfig(params: {
dataset: string;
Expand Down Expand Up @@ -587,35 +546,21 @@ function resolveDatasetConfig(params: {
const queryWithEnv =
[envPrefix, resolved.query].filter(Boolean).join(" ") || undefined;

const firstAgg = findFirstAggregate(fieldList);
const rawSort = flags.sort ?? (firstAgg ? `-${firstAgg}` : undefined);
let sort: string | undefined;
if (SORTABLE_DATASETS.has(resolved.dataset)) {
// A deterministic sort is required for correct cursor pagination: the
// events cursor is offset-based, so without a stable total order the
// separate page requests overlap and skip rows, producing duplicate and
// missing dimension tuples across the merged result (#1519). Defaulting
// to `-<firstAggregate>` gives grouped aggregate queries a stable order.
sort = rawSort;
} else {
// Warn only when the user explicitly passed --sort on a dataset that
// rejects it (metrics/logs). An auto-derived sort is silently dropped.
if (rawSort && flags.sort) {
const displayDataset = API_TO_USER_DATASET.get(dataset) ?? dataset;
log.warn(
`--sort is not supported on the ${displayDataset} dataset. Ignoring sort.`,
);
}
sort = undefined;
}
// Every Events dataset honors sort as long as the sort column is selected.
// A stable sort is what keeps offset-based cursor pagination from
// duplicating and skipping rows (#1519) and makes --limit a top N (#1477).
const { fields, sort } = resolveEventsSort({
fields: fieldList,
sort: flags.sort,
});

const query = buildProjectQuery(queryWithEnv, project);
return {
sort,
query,
fetch: async ({ cursor, limit, timeRange }) =>
queryEvents(org, {
fields: fieldList,
fields,
dataset: resolved.dataset,
query,
sort,
Expand Down Expand Up @@ -803,7 +748,7 @@ export const exploreCommand = buildListCommand("explore", {
const aggField = resolveMetricField(flags.metric, flags.agg, metrics);
// Prepend any user-supplied grouping fields, then the resolved aggregate
const groupByFields = userSuppliedFields
? fieldList.filter((f) => !isAggregate(f))
? fieldList.filter((f) => !isAggregateFieldOrEquation(f))
: [];
fieldList = [...groupByFields, aggField];
} else if (dataset === "tracemetrics") {
Expand Down
24 changes: 13 additions & 11 deletions packages/cli/src/lib/api/dashboards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@sentry/api";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
import { resolveEventsSort } from "@sentry/toolkit-core/events-sort";

import { array, safeParse } from "valibot";

Expand Down Expand Up @@ -476,14 +477,16 @@ async function queryWidgetTimeseries(

// Group-by columns enable topEvents mode
if (hasGroupBy) {
reqParams.field = columns;
// Sort by the aggregate to get the actual top N groups. Every dataset
// honors sort, but the errors path rejects a sort column that is not in
// `field`, so select the aggregates alongside the group-by columns.
const { fields, sort } = resolveEventsSort({
fields: [...columns, ...aggregates],
sort: query.orderby,
});
reqParams.field = fields;
reqParams.sort = sort;
reqParams.topEvents = widget.limit ?? 5;
// Sort by the aggregate to get the actual top N groups.
// The sort param is only supported on the spans dataset —
// the errors endpoint rejects it with 400.
if (dataset === "spans") {
reqParams.sort = query.orderby ?? `-${aggregates[0] ?? "count()"}`;
}
}

const { data: raw } = await apiRequestToRegion<unknown>(
Expand Down Expand Up @@ -529,21 +532,20 @@ async function queryWidgetTable(
...(query?.aggregates ?? []),
];
const dataset = mapWidgetTypeToDataset(widget.widgetType);
const request = resolveEventsSort({ fields, sort: query?.orderby });

const { data } = await apiRequestToRegion(
regionUrl,
`/organizations/${orgSlug}/events/`,
{
params: {
field: fields,
field: request.fields,
query: query?.conditions || undefined,
dataset: dataset ?? undefined,
statsPeriod,
start,
end,
// sort is only supported on the spans dataset —
// the errors endpoint rejects it with 400.
sort: dataset === "spans" ? query?.orderby || undefined : undefined,
sort: request.sort,
per_page: widget.limit ?? 10,
environment: options.environment,
project: options.project?.map(String),
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/lib/api/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export type ExploreQueryOptions = {
query?: string;
/**
* Sort field. Prefix with `-` for descending.
* Supported on the `spans`, `errors`, and `discover` datasets; `metrics` and
* `logs` reject it with 400. A stable sort is required for correct
* Every Events dataset accepts it as long as the sort column is in
* `fields`; resolve both with `resolveEventsSort` from
* `@sentry/toolkit-core/events-sort`. A stable sort is required for correct
* offset-based cursor pagination on grouped aggregate queries (#1519).
*/
sort?: string;
Expand Down
22 changes: 9 additions & 13 deletions packages/cli/src/types/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
* before PUT requests, and strict input validation for user-authored widgets.
*/

import {
isEquation,
isTracemetricsAggregate,
parseFunction,
} from "@sentry/toolkit-core/events-fields";
import {
array,
boolean,
Expand Down Expand Up @@ -375,26 +380,17 @@ export function parseAggregate(input: string): string {
* "p95(span.duration)" → "p95"
*/
function extractFunctionName(aggregate: string): string {
const parenIdx = aggregate.indexOf("(");
return parenIdx > 0 ? aggregate.slice(0, parenIdx) : aggregate;
return parseFunction(aggregate)?.name ?? aggregate;
}

/**
* Check whether a parsed aggregate uses the tracemetrics comma-separated format.
* Format: `aggregation(value,metric_name,metric_type,unit)`
* Example: `p50(value,completion.duration_ms,distribution,none)`
*/
function isTracemetricsAggregate(aggregate: string): boolean {
function isTracemetricsWidgetAggregate(aggregate: string): boolean {
// equation| prefix denotes a formula combining multiple tracemetrics aggregates
if (aggregate.startsWith("equation|")) {
return true;
}
const parenIdx = aggregate.indexOf("(");
if (parenIdx < 0) {
return false;
}
const inner = aggregate.slice(parenIdx + 1, -1);
return inner.startsWith("value,") && inner.split(",").length === 4;
return isEquation(aggregate) || isTracemetricsAggregate(aggregate);
}

/**
Expand All @@ -416,7 +412,7 @@ export function validateAggregateNames(
// tracemetrics uses a different aggregate format — validate structure, not function names
if (dataset === "tracemetrics") {
for (const agg of aggregates) {
if (!isTracemetricsAggregate(agg)) {
if (!isTracemetricsWidgetAggregate(agg)) {
throw new ValidationError(
`Invalid tracemetrics aggregate "${agg}".\n\n` +
"tracemetrics queries must use the format: aggregation(value,metric_name,metric_type,unit)\n" +
Expand Down
Loading
Loading