Skip to content
Open
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
109 changes: 53 additions & 56 deletions AGENTS.md

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions docs/src/fragments/commands/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,26 @@ sentry explore my-org/cli -F span.op -F "count()" \

### Metrics

Use `--metric` (`-m`) to query metrics by name. The CLI auto-resolves the metric's type and unit.

```bash
# Sum a custom metric (e.g., LLM token usage) across an org
sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d

# Break down by a tag column (e.g., model name)
sentry explore my-org/seer -F gen_ai.request.model \
-m llm.token_usage --dataset metrics --period 7d

# Use a different aggregation (default is sum)
sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics
```

You can also use the raw tracemetrics format: `aggregation(value,metric_name,metric_type,unit)`.

```bash
# Custom metric aggregations
sentry explore my-org/cli -F transaction -F "avg(measurements.fcp)" \
--dataset metrics --period 24h
sentry explore my-org/ \
-F "sum(value,llm.token_usage,distribution,none)" \
--dataset metrics --period 7d
```

### Logs
Expand Down
18 changes: 15 additions & 3 deletions plugins/sentry-cli/skills/sentry-cli/references/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Query aggregate event data (Explore)

**Flags:**
- `-F, --field <value>... - API field or aggregate (repeatable). E.g., title, "count()", "p50(transaction.duration)"`
- `-m, --metric <value> - Metric name for --dataset metrics. Auto-resolves type/unit via API.`
- `--agg <value> - Aggregation for --metric (sum, avg, count, p50, p95, etc.) - (default: "sum")`
- `-d, --dataset <value> - Dataset to query (errors, spans, metrics, logs, replays) - (default: "errors")`
- `-q, --query <value> - Search query (Sentry search syntax)`
- `-s, --sort <value> - Sort field (prefix with - for desc, e.g., "-count()")`
Expand Down Expand Up @@ -57,9 +59,19 @@ sentry explore my-org/cli -F span.op -F "p50(span.duration)" \
sentry explore my-org/cli -F span.op -F "count()" \
--dataset spans --sort "-count()"

# Custom metric aggregations
sentry explore my-org/cli -F transaction -F "avg(measurements.fcp)" \
--dataset metrics --period 24h
# Sum a custom metric (e.g., LLM token usage) across an org
sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d

# Break down by a tag column (e.g., model name)
sentry explore my-org/seer -F gen_ai.request.model \
-m llm.token_usage --dataset metrics --period 7d

# Use a different aggregation (default is sum)
sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics

sentry explore my-org/ \
-F "sum(value,llm.token_usage,distribution,none)" \
--dataset metrics --period 7d

# Log severity counts in the last hour
sentry explore my-org/cli -F severity -F "count()" \
Expand Down
155 changes: 146 additions & 9 deletions src/commands/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isReplaySortValue,
listReplays,
queryEvents,
queryMetricsMeta,
} from "../lib/api-client.js";
import { buildProjectQuery, validateLimit } from "../lib/arg-parsing.js";
import {
Expand All @@ -33,6 +34,7 @@ import {
paginationHint,
} from "../lib/list-command.js";
import { logger } from "../lib/logger.js";
import { resolveMetricField } from "../lib/metrics-transform.js";
import { withProgress } from "../lib/polling.js";
import {
DEFAULT_REPLAY_EXPLORE_FIELDS,
Expand Down Expand Up @@ -123,6 +125,8 @@ const API_TO_USER_DATASET = new Map(

type ExploreFlags = {
readonly field?: string[];
readonly metric?: string;
readonly agg: string;
readonly dataset: string;
readonly environment?: readonly string[];
readonly query?: string;
Expand Down Expand Up @@ -311,7 +315,15 @@ function appendFlagHints(
base: string,
flags: Pick<
ExploreFlags,
"dataset" | "environment" | "sort" | "query" | "period" | "field" | "limit"
| "dataset"
| "environment"
| "sort"
| "query"
| "period"
| "field"
| "limit"
| "metric"
| "agg"
>
): string {
const parts: string[] = [];
Expand All @@ -323,10 +335,20 @@ function appendFlagHints(
API_TO_USER_DATASET.get(flags.dataset) ?? flags.dataset;
parts.push(`--dataset ${displayDataset}`);
}
if (flags.metric) {
parts.push(`-m "${flags.metric}"`);
if (flags.agg !== "sum") {
parts.push(`--agg ${flags.agg}`);
}
}
Comment thread
sentry[bot] marked this conversation as resolved.
appendSortHint(parts, flags.sort, defaultSort);
appendQueryHint(parts, flags.query);
// Include --field flags when non-default
const fieldList = flags.field ?? [];
// Include --field flags when non-default.
// When --metric is active, aggregates are dropped from the query — mirror that here.
const rawFields = flags.field ?? [];
const fieldList = flags.metric
? rawFields.filter((f) => !isAggregate(f))
: rawFields;
const currentFieldStr = fieldList.join(",");
if (
currentFieldStr !== defaultFieldsForDataset(flags.dataset).join(",") &&
Expand Down Expand Up @@ -356,6 +378,53 @@ function findFirstAggregate(fieldList: string[]): string | undefined {
return fieldList.find((f) => f.includes("(") && f.includes(")"));
}

/** True when the field looks like an aggregate call: `fn(...)`. */
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pagination hints omit new --metric and --agg flags

Medium Severity

appendFlagHints picks only "dataset" | "environment" | "sort" | "query" | "period" | "field" | "limit" from ExploreFlags, omitting the new metric and agg properties. When a --metric query has more than one page of results, the pagination hint (e.g., sentry explore my-org/ -c next --dataset metrics) won't include -m or --agg, so copy-pasting it will hit the "requires --metric or explicit --field flags" validation error instead of fetching the next page.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 54ad40b. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 6ea7639appendFlagHints now includes metric and agg in its Pick type and emits -m / --agg flags when set.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in 6ea7639appendFlagHints now picks metric and agg from ExploreFlags and emits -m / --agg in pagination hints.

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 `metricsEnhanced` dataset. Standard aggregates like `count()` or
* `avg(measurements.fcp)` are invalid — the API requires the four-part
* comma-separated format: `aggregation(value,metric_name,metric_type,unit)`.
*/
function validateMetricsFields(fieldList: string[]): void {
const badAggs = fieldList.filter(
(f) => isAggregate(f) && !isTracemetricsAggregate(f)
);
if (badAggs.length === 0) {
return;
}

throw new ValidationError(
`Invalid metrics aggregate${badAggs.length > 1 ? "s" : ""}: ${badAggs.join(", ")}\n\n` +
"The metrics dataset requires the format: aggregation(value,metric_name,metric_type,unit)\n\n" +
"Examples:\n" +
' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics\n' +
' sentry explore my-org/ -F gen_ai.request.model -F "avg(value,cache.hit_rate,distribution,none)" --dataset metrics\n\n' +
"Parameters:\n" +
' - value: literal string "value"\n' +
" - metric_name: the metric name emitted by the SDK (e.g., llm.token_usage)\n" +
" - metric_type: distribution, gauge, counter, or set\n" +
" - unit: none, byte, second, millisecond, etc.",
"field"
);
}

// ---------------------------------------------------------------------------
// Dataset configuration
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -508,7 +577,7 @@ export const exploreCommand = buildListCommand("explore", {
"Datasets:\n" +
" errors Error events (default)\n" +
" spans Span data\n" +
" metrics Custom metrics\n" +
" metrics Custom metrics (tracemetrics format)\n" +
" logs Log entries\n" +
" replays Session replay search\n\n" +
"Targets:\n" +
Expand All @@ -523,7 +592,11 @@ export const exploreCommand = buildListCommand("explore", {
"--dataset spans\n" +
" sentry explore my-org/cli --dataset replays -F id -F user.email -F count_errors\n" +
' sentry explore -F span.op -F "count()" --dataset spans --period 1h\n' +
" sentry explore --json",
" sentry explore --json\n\n" +
"Metrics (auto mode — resolves type/unit automatically):\n" +
" sentry explore my-org/ -m llm.token_usage --dataset metrics\n" +
" sentry explore my-org/seer -F gen_ai.request.model -m llm.token_usage --dataset metrics --period 7d\n" +
" sentry explore my-org/ -m cache.hit_rate --agg avg --dataset metrics",
},
output: {
human: formatExploreHuman,
Expand Down Expand Up @@ -551,6 +624,19 @@ export const exploreCommand = buildListCommand("explore", {
variadic: true,
optional: true,
},
metric: {
kind: "parsed",
parse: String,
brief:
"Metric name for --dataset metrics. Auto-resolves type/unit via API.",
optional: true,
},
agg: {
kind: "parsed",
parse: String,
brief: "Aggregation for --metric (sum, avg, count, p50, p95, etc.)",
default: "sum",
},
dataset: {
kind: "parsed",
parse: parseDataset,
Expand Down Expand Up @@ -594,6 +680,7 @@ export const exploreCommand = buildListCommand("explore", {
...PERIOD_ALIASES,
e: "environment",
F: "field",
m: "metric",
d: "dataset",
q: "query",
s: "sort",
Expand All @@ -608,14 +695,57 @@ export const exploreCommand = buildListCommand("explore", {
"explore"
);

const dataset = flags.dataset;
let dataset = flags.dataset;
const userSuppliedFields = flags.field && flags.field.length > 0;
let fieldList = [...defaultFieldsForDataset(dataset)];
if (flags.field && flags.field.length > 0) {
if (userSuppliedFields) {
fieldList = flags.field;
}
const timeRange = flags.period;
const environment = parseReplayEnvironmentFilter(flags.environment);

// --metric auto mode: resolve metric name → tracemetrics aggregate
if (flags.metric) {
if (dataset !== "metricsEnhanced") {
log.warn("--metric implies --dataset metrics; switching dataset.");
dataset = "metricsEnhanced";
}
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// Use the user's --period for metadata discovery so older metrics are found
const metaParams = timeRangeToApiParams(timeRange);
const metrics = await withProgress(
{
message: `Discovering metric '${flags.metric}'...`,
json: flags.json,
},
() =>
queryMetricsMeta(org, {
...metaParams,
project,
})
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolute time ranges silently ignored for metric discovery

Medium Severity

When the user specifies an absolute time range (e.g., --period "2026-04-01..2026-05-01"), timeRangeToApiParams() returns { start, end } with no statsPeriod. The code only passes metaParams.statsPeriod to queryMetricsMeta, which is undefined for absolute ranges, causing it to silently fall back to "7d". The start/end params are completely dropped because queryMetricsMeta doesn't accept them. This means metric discovery searches the wrong time window, potentially failing to find the metric and throwing a misleading "not found" error.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f645f2a. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch — fixed in 320e10a. queryMetricsMeta now accepts start/end and the call site spreads the full timeRangeToApiParams result instead of cherry-picking statsPeriod.


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 = [...groupByFields, aggField];
} else if (dataset === "metricsEnhanced") {
if (!userSuppliedFields) {
throw new ValidationError(
"The metrics dataset requires --metric or explicit --field flags.\n\n" +
"Auto mode (recommended):\n" +
" sentry explore my-org/ -m llm.token_usage --dataset metrics\n" +
" sentry explore my-org/ -m llm.token_usage --agg avg --dataset metrics\n\n" +
"Manual mode (tracemetrics format):\n" +
' sentry explore my-org/ -F "sum(value,llm.token_usage,distribution,none)" --dataset metrics',
"field"
);
}
validateMetricsFields(fieldList);
}

const config = resolveDatasetConfig({
dataset,
fieldList,
Comment thread
sentry[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -656,11 +786,18 @@ export const exploreCommand = buildListCommand("explore", {
const hasMore = !!nextCursor;

const baseTarget = project ? `${org}/${project}` : `${org}/`;
const hintFlags = { ...flags, dataset };
const nav = paginationHint({
hasPrev,
hasMore,
prevHint: appendFlagHints(`sentry explore ${baseTarget} -c prev`, flags),
nextHint: appendFlagHints(`sentry explore ${baseTarget} -c next`, flags),
prevHint: appendFlagHints(
`sentry explore ${baseTarget} -c prev`,
hintFlags
),
nextHint: appendFlagHints(
`sentry explore ${baseTarget} -c next`,
hintFlags
),
});

const hint = buildResultHint(response.data.length, nav);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export {
queryAllWidgets,
updateDashboard,
} from "./api/dashboards.js";
export { queryEvents } from "./api/discover.js";
export type { MetricMeta } from "./api/discover.js";
export { queryEvents, queryMetricsMeta } from "./api/discover.js";
export {
findEventAcrossOrgs,
getEvent,
Expand Down
64 changes: 64 additions & 0 deletions src/lib/api/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,70 @@ async function fetchEventsPage(
return { data, nextCursor };
}

/** Metric metadata returned by {@link queryMetricsMeta}. */
export type MetricMeta = {
name: string;
type: string;
unit: string;
};

/**
* Discover available metrics for an org via the Events API.
*
* Queries `dataset=metricsEnhanced` with meta-fields (`metric.name`, etc.)
* — the same technique the Sentry Explore Metrics UI uses.
*
* Auto-paginates to collect all available metrics (bounded by
* {@link MAX_PAGINATION_PAGES} to prevent runaway loops).
*/
export async function queryMetricsMeta(
orgSlug: string,
options?: {
statsPeriod?: string;
start?: string;
end?: string;
project?: string;
}
): Promise<MetricMeta[]> {
const regionUrl = await resolveOrgRegion(orgSlug);
const query = options?.project ? `project:${options.project}` : undefined;

const baseOptions: ExploreQueryOptions = {
fields: ["metric.name", "metric.type", "metric.unit"],
dataset: "metricsEnhanced",
query,
statsPeriod:
options?.start || options?.end
? undefined
: (options?.statsPeriod ?? "7d"),
start: options?.start,
end: options?.end,
};

const allRows: Record<string, unknown>[] = [];
let cursor: string | undefined;

for (let page = 0; page < MAX_PAGINATION_PAGES; page += 1) {
const result = await fetchEventsPage(
regionUrl,
orgSlug,
{ ...baseOptions, cursor },
API_MAX_PER_PAGE
);

allRows.push(...result.data.data);

if (!result.nextCursor) break;
cursor = result.nextCursor;
}

return allRows.map((row) => ({
name: String(row["metric.name"] ?? ""),
type: String(row["metric.type"] ?? "distribution"),
unit: String(row["metric.unit"] ?? "none"),
}));
}

/**
* Query the Explore/Events endpoint for aggregate or tabular event data.
*
Expand Down
Loading
Loading