From c7ac44257b47cf34e13794714fe0991f1a3fc0b9 Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 22:22:10 +0100 Subject: [PATCH 1/5] feat(cli): add explore time series --- .../src/fragments/commands/explore.md | 23 ++ .../skills/sentry-cli/references/explore.md | 13 +- packages/cli/script/generate-sdk.ts | 6 +- packages/cli/src/commands/explore.ts | 205 +++++++++-- packages/cli/src/lib/api-client.ts | 12 +- packages/cli/src/lib/api/explore.ts | 72 +++- packages/cli/src/lib/explore-timeseries.ts | 95 +++++ .../src/lib/formatters/explore-timeseries.ts | 171 +++++++++ packages/cli/test/commands/explore.test.ts | 346 +++++++++++++++++- packages/cli/test/lib/api/explore.test.ts | 210 ++++++++++- .../lib/explore-timeseries.property.test.ts | 50 +++ .../lib/formatters/explore-timeseries.test.ts | 243 ++++++++++++ packages/cli/test/lib/sdk-flag-types.test.ts | 7 + 13 files changed, 1411 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/lib/explore-timeseries.ts create mode 100644 packages/cli/src/lib/formatters/explore-timeseries.ts create mode 100644 packages/cli/test/lib/explore-timeseries.property.test.ts create mode 100644 packages/cli/test/lib/formatters/explore-timeseries.test.ts diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index 6dd07cda6..5ba58f8b6 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -1,5 +1,28 @@ +## Time series + +Use `--time-series` to see how an aggregate changes over time. The target, +`--query`, `--environment`, and `--period` filters work as they do for tabular +queries. Without `--field`, temporal queries count matching events in each time +bucket. For performance analysis, select aggregates such as `p95(span.duration)`; +repeat `--field` to request more than one series. + +The CLI chooses a bucket interval from the requested time range. Set `--interval` +to choose an explicit interval, such as `15m`. This flag requires `--time-series`. + +Temporal queries support errors, spans, logs, and metrics. Metrics require +`--metric` or an explicit aggregate in the raw tracemetrics format described +below. Grouping columns, replays, `--sort`, `--limit`, and `--cursor` are not +supported in temporal mode. Use a tabular query to break down a series by a field +or inspect individual events. + +JSON output includes `timeSeries`, `meta`, and `dataset`. Each series retains its +bucket timestamps, values, and available sampling or completeness metadata. +Null values remain null, and incomplete buckets remain marked as incomplete; +recent buckets may still be waiting for ingestion or for the interval to end. +Temporal output has no pagination cursor. + ## Metrics Use `--metric` (`-m`) to query metrics by name. The CLI auto-resolves the metric's type and unit. 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 616ee9d0d..382f9df9a 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 @@ -29,7 +29,9 @@ Query aggregate event data (Explore) - `-q, --query - Search query (Sentry search syntax)` - `-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")` +- `-n, --limit - Number of table rows (1-1000, default: 25)` +- `--time-series - Calculate aggregates in time buckets (one query, not a live stream)` +- `--interval - Bucket duration for --time-series (e.g., 1m, 15m, 1h; default: automatic)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-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)` @@ -67,6 +69,15 @@ sentry explore my-org/cli -F span.op -F "p50(span.duration)" -F "p95(span.durati # Top spans by count sentry explore my-org/cli -F span.op -F "count()" --dataset spans --sort "-count()" +# Error counts in 15-minute buckets over the last hour +sentry explore my-org/cli --time-series --period 1h --interval 15m + +# Latency over time with automatic bucket duration, as JSON +sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series --period 24h --json + +# A metric over time using its discovered type and unit +sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg --time-series --period 1h + # Sum a custom metric (e.g., LLM token usage) across an organization sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d diff --git a/packages/cli/script/generate-sdk.ts b/packages/cli/script/generate-sdk.ts index 10e2a7835..e26db15be 100644 --- a/packages/cli/script/generate-sdk.ts +++ b/packages/cli/script/generate-sdk.ts @@ -141,8 +141,12 @@ function inferFlagType(def: FlagDef): { }; } - // kind === "parsed" — infer from default value + // Optional numeric flags may have no parser default; their placeholder + // preserves the input type without making the default appear explicit. const numberType = f.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + if (def.placeholder === "number") { + return { type: numberType, kind: "parsed" }; + } if (def.default !== undefined && typeof def.default === "number") { return { type: numberType, kind: "parsed" }; } diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index b43280b52..f39be3ebd 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -17,6 +17,7 @@ import { isReplaySortValue, listReplays, queryEvents, + queryEventsTimeSeries, queryMetricsMeta, } from "../lib/api-client.js"; import { validateLimit } from "../lib/arg-parsing.js"; @@ -28,6 +29,15 @@ import { } from "../lib/db/pagination.js"; import { toSearchQueryError, ValidationError } from "../lib/errors.js"; import { toExploreFieldError } from "../lib/explore-errors.js"; +import { + parseTimeSeriesInterval, + resolveTimeSeriesInterval, +} from "../lib/explore-timeseries.js"; +import { + type ExploreTimeSeriesData, + formatExploreTimeSeriesHuman, + jsonTransformExploreTimeSeries, +} from "../lib/formatters/explore-timeseries.js"; import { filterFields } from "../lib/formatters/json.js"; import { buildMetaColumns } from "../lib/formatters/meta-table.js"; import { CommandOutput } from "../lib/formatters/output.js"; @@ -142,7 +152,9 @@ type ExploreFlags = { readonly query?: string; readonly sort?: string; readonly period: TimeRange; - readonly limit: number; + readonly limit?: number; + readonly "time-series"?: boolean; + readonly interval?: string; readonly cursor?: string; readonly json: boolean; readonly fresh: boolean; @@ -264,7 +276,10 @@ function orderFieldNames( } /** Format explore results for human-readable terminal output */ -function formatExploreHuman(data: ExploreData): string { +function formatExploreHuman(data: ExploreData | ExploreTimeSeriesData): string { + if ("timeSeries" in data) { + return formatExploreTimeSeriesHuman(data); + } if (data.data.length === 0) { // Empty page mid-pagination (more or prev pages exist) vs. truly no results. return data.hasMore || data.hasPrev @@ -286,7 +301,13 @@ function formatExploreHuman(data: ExploreData): string { } /** Transform explore results for JSON output */ -function jsonTransformExplore(data: ExploreData, fields?: string[]): unknown { +function jsonTransformExplore( + data: ExploreData | ExploreTimeSeriesData, + fields?: string[], +): unknown { + if ("timeSeries" in data) { + return jsonTransformExploreTimeSeries(data, fields); + } const items = fields && fields.length > 0 ? data.data.map((row) => filterFields(row, fields)) @@ -388,7 +409,7 @@ function appendFlagHints( appendSortHint(parts, flags.sort, defaultSort); appendQueryHint(parts, flags.query); appendFieldHints(parts, flags.field, flags.dataset, !!flags.metric); - if (flags.limit !== DEFAULT_LIMIT) { + if (flags.limit !== undefined && flags.limit !== DEFAULT_LIMIT) { parts.push(`--limit ${flags.limit}`); } if (flags.environment && flags.environment.length > 0) { @@ -476,6 +497,70 @@ function buildEnvironmentQuery( return `environment:[${environment.join(",")}]`; } +/** Resolve the shared dataset and filters for tabular and temporal queries. */ +function resolveEventsQuery( + dataset: string, + query: string | undefined, + project: string | undefined, + environment: string[] | undefined, +) { + const userQuery = sanitizeQuery(query, { allowBoolean: true }); + const legacyTransactions = LEGACY_TRANSACTION_DATASETS.has(dataset); + const scope = [ + project ? `project:${project}` : undefined, + buildEnvironmentQuery(environment), + legacyTransactions ? IS_TRANSACTION_FILTER : undefined, + ] + .filter(Boolean) + .join(" "); + // AND binds more tightly than OR, so CLI scope must enclose every user branch. + const scopedQuery = + legacyTransactions && userQuery === IS_TRANSACTION_FILTER + ? undefined + : userQuery; + return { + dataset: legacyTransactions ? "spans" : dataset, + query: + scope && scopedQuery ? `${scope} (${scopedQuery})` : scope || scopedQuery, + }; +} + +/** Reject table-only flags and dimensions before a temporal query is sent. */ +function validateTimeSeriesFlags(flags: ExploreFlags): void { + if (!flags["time-series"]) { + if (flags.interval !== undefined) { + throw new ValidationError( + "--interval requires --time-series.", + "interval", + ); + } + return; + } + for (const flag of ["cursor", "limit", "sort"] as const) { + if (flags[flag] !== undefined) { + throw new ValidationError( + `--${flag} is only available for tables. Time series return all buckets in chronological order; use --period and --interval to control the range and granularity.`, + flag, + ); + } + } + if (flags.dataset === "replays" && !flags.metric) { + throw new ValidationError( + "Time series support errors, spans, logs, and metrics. The replays dataset is only available as a table.", + "dataset", + ); + } + const dimensions = flags.field?.filter( + (field) => !isAggregateFieldOrEquation(field), + ); + if (dimensions?.length) { + throw new ValidationError( + `Time series require aggregate fields, such as count() or p95(span.duration). Grouping is not supported: ${dimensions.join(", ")}. Use --query to filter these fields or omit --time-series for a grouped table.`, + "field", + ); + } +} + /** * Resolve dataset-specific configuration: sort, query, validation, and fetch. * @@ -495,11 +580,8 @@ function resolveDatasetConfig(params: { environment: string[] | undefined; }): DatasetConfig { const { dataset, fieldList, flags, org, project, environment } = params; - const userQuery = sanitizeQuery(flags.query, { - allowBoolean: dataset !== "replays", - }); - if (dataset === "replays") { + const userQuery = sanitizeQuery(flags.query); const unsupportedField = fieldList.find( (field) => !isSupportedReplayField(field), ); @@ -542,21 +624,12 @@ function resolveDatasetConfig(params: { // Non-replay datasets: translate --environment into query filter terms // since the Discover/Events API expects environment:... in the query string. - const legacyTransactions = LEGACY_TRANSACTION_DATASETS.has(dataset); - const scope = [ - project ? `project:${project}` : undefined, - buildEnvironmentQuery(environment), - legacyTransactions ? IS_TRANSACTION_FILTER : undefined, - ] - .filter(Boolean) - .join(" "); - // AND binds more tightly than OR, so CLI scope must enclose every user branch. - const scopedQuery = - legacyTransactions && userQuery === IS_TRANSACTION_FILTER - ? undefined - : userQuery; - const query = - scope && scopedQuery ? `${scope} (${scopedQuery})` : scope || scopedQuery; + const resolved = resolveEventsQuery( + dataset, + flags.query, + project, + environment, + ); // Every Events dataset honors sort as long as the sort column is selected. // A stable sort is what keeps offset-based cursor pagination from @@ -566,13 +639,14 @@ function resolveDatasetConfig(params: { sort: flags.sort, }); + const query = resolved.query; return { sort, query, fetch: async ({ cursor, limit, timeRange }) => await queryEvents(org, { fields, - dataset: legacyTransactions ? "spans" : dataset, + dataset: resolved.dataset, query, sort, limit, @@ -580,9 +654,7 @@ function resolveDatasetConfig(params: { ...timeRangeToApiParams(timeRange), }).catch((error: unknown): never => { throw toExploreFieldError(error, { - dataset: legacyTransactions - ? "spans" - : (API_TO_USER_DATASET.get(dataset) ?? dataset), + dataset: API_TO_USER_DATASET.get(resolved.dataset) ?? resolved.dataset, fields, userFields: (flags.field ?? []).filter( (field) => !flags.metric || !isAggregateFieldOrEquation(field), @@ -618,6 +690,10 @@ export const exploreCommand = buildListCommand("explore", { "Supports arbitrary fields including columns (title, project),\n" + "aggregates (count(), count_unique(user), p50(span.duration)),\n" + "and equations. Results are returned as a table.\n\n" + + "Use --time-series to calculate aggregates in time buckets. " + + "--interval sets the bucket duration; otherwise it is selected automatically. " + + "This is a single historical query, not a live stream. " + + "Grouping, --sort, --limit, and --cursor are table-only.\n\n" + "Datasets:\n" + " errors Error events (default)\n" + " spans Span data\n" + @@ -675,6 +751,22 @@ export const exploreCommand = buildListCommand("explore", { command: 'sentry explore my-org/cli -F span.op -F "count()" --dataset spans --sort "-count()"', }, + { + description: "Error counts in 15-minute buckets over the last hour", + command: + "sentry explore my-org/cli --time-series --period 1h --interval 15m", + }, + { + description: + "Latency over time with automatic bucket duration, as JSON", + command: + 'sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series --period 24h --json', + }, + { + description: "A metric over time using its discovered type and unit", + command: + "sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg --time-series --period 1h", + }, { description: "Sum a custom metric (e.g., LLM token usage) across an organization", @@ -779,8 +871,22 @@ export const exploreCommand = buildListCommand("explore", { limit: { kind: "parsed", parse: parseLimit, - brief: `Number of rows (1-${LIST_MAX_LIMIT})`, - default: "25", + placeholder: "number", + brief: `Number of table rows (1-${LIST_MAX_LIMIT}, default: 25)`, + optional: true, + }, + "time-series": { + kind: "boolean", + brief: + "Calculate aggregates in time buckets (one query, not a live stream)", + default: false, + }, + interval: { + kind: "parsed", + parse: parseTimeSeriesInterval, + brief: + "Bucket duration for --time-series (e.g., 1m, 15m, 1h; default: automatic)", + optional: true, }, period: { kind: "parsed" as const, @@ -805,11 +911,14 @@ export const exploreCommand = buildListCommand("explore", { flags: ExploreFlags, target: OrgOptionalTarget, ) { + validateTimeSeriesFlags(flags); const { org, project } = await target.resolved; let dataset = flags.dataset; const userSuppliedFields = flags.field && flags.field.length > 0; - let fieldList = [...defaultFieldsForDataset(dataset)]; + let fieldList = flags["time-series"] + ? ["count()"] + : [...defaultFieldsForDataset(dataset)]; if (userSuppliedFields) { fieldList = flags.field; } @@ -858,6 +967,40 @@ export const exploreCommand = buildListCommand("explore", { validateMetricsFields(fieldList); } + if (flags["time-series"]) { + const resolved = resolveEventsQuery( + dataset, + flags.query, + project, + environment, + ); + const timeParams = timeRangeToApiParams(timeRange); + const interval = resolveTimeSeriesInterval(timeParams, flags.interval); + const response = await withProgress( + { + message: `Querying ${resolved.dataset} time series in ${project ? `${org}/${project}` : org}...`, + json: flags.json, + }, + () => + queryEventsTimeSeries(org, { + fields: fieldList, + dataset: resolved.dataset, + query: resolved.query, + interval, + ...timeParams, + }).catch((error: unknown): never => { + throw toSearchQueryError(error, flags.query); + }), + ); + yield new CommandOutput({ + ...response, + dataset: resolved.dataset, + org, + project, + }); + return; + } + const config = resolveDatasetConfig({ dataset, fieldList, @@ -892,7 +1035,7 @@ export const exploreCommand = buildListCommand("explore", { }, () => config - .fetch({ cursor, limit: flags.limit, timeRange }) + .fetch({ cursor, limit: flags.limit ?? DEFAULT_LIMIT, timeRange }) .catch((error: unknown): never => { // An unparseable user --query is a user input mistake, not a CLI bug. throw toSearchQueryError(error, flags.query); diff --git a/packages/cli/src/lib/api-client.ts b/packages/cli/src/lib/api-client.ts index 5d28c0965..ea0746030 100644 --- a/packages/cli/src/lib/api-client.ts +++ b/packages/cli/src/lib/api-client.ts @@ -62,8 +62,16 @@ export { type ResolvedEvent, resolveEventInOrg, } from "./api/events.js"; -export type { MetricMeta } from "./api/explore.js"; -export { queryEvents, queryMetricsMeta } from "./api/explore.js"; +export type { + EventsTimeSeriesResponse, + ExploreTimeSeriesQueryOptions, + MetricMeta, +} from "./api/explore.js"; +export { + queryEvents, + queryEventsTimeSeries, + queryMetricsMeta, +} from "./api/explore.js"; export { buildFeedbackQuery, type FeedbackPage, diff --git a/packages/cli/src/lib/api/explore.ts b/packages/cli/src/lib/api/explore.ts index aa13574b4..b660ad036 100644 --- a/packages/cli/src/lib/api/explore.ts +++ b/packages/cli/src/lib/api/explore.ts @@ -1,11 +1,12 @@ /** - * Sentry Explore/Discover API — aggregate event queries. + * Sentry Explore/Discover API — tabular and time-series event queries. * - * Wraps `GET /organizations/{org}/events/` with user-specified fields, dataset, - * query, sort, and time range. This is the same endpoint used by the Sentry - * Explore UI for arbitrary event queries with aggregation support. + * Wraps the `events/` and `events-timeseries/` endpoints used by the Sentry + * Explore UI, with user-specified aggregates, datasets, filters, and time ranges. */ +import type { OrganizationEventsTimeseriesResponse } from "@sentry/api"; + import type { EventsTableResponse } from "../../types/dashboard.js"; import { EventsTableResponseSchema } from "../../types/dashboard.js"; import { stringifyUnknown } from "../errors.js"; @@ -47,6 +48,69 @@ export type ExploreQueryOptions = { end?: string; }; +/** Options for an ungrouped time series, with project and environment filters in `query`. */ +export type ExploreTimeSeriesQueryOptions = Pick< + ExploreQueryOptions, + "fields" | "dataset" | "query" | "statsPeriod" | "start" | "end" +> & { + /** Bucket width as a duration, such as `5m`. Must be valid for the requested range. */ + interval: string; +}; + +type SdkTimeSeries = OrganizationEventsTimeseriesResponse["timeSeries"][number]; + +/** + * The SDK response with the backend's string-valued aggregates preserved. + * `max(timestamp)` returns an ISO date and `latest_event()` an event ID; + * neither is represented by the SDK's numeric bucket type. + */ +export type EventsTimeSeriesResponse = Omit< + OrganizationEventsTimeseriesResponse, + "timeSeries" +> & { + /** One series per aggregate, including bucket accuracy and completeness metadata. */ + timeSeries: (Omit & { + /** Buckets retain API timestamps in milliseconds and null values for missing data. */ + values: (Omit & { + /** Aggregate result, without coercion of strings or missing values. */ + value: number | string | null; + })[]; + })[]; +}; + +/** + * Query aggregate values over time without grouping or pagination. + * + * Uses the same regional transport as `queryEvents` because the generated + * SDK input types do not support repeated `yAxis` or duration-string intervals. + * Returns the complete API response, including sampling and ingestion metadata. + */ +export async function queryEventsTimeSeries( + orgSlug: string, + options: ExploreTimeSeriesQueryOptions, +): Promise { + const regionUrl = await resolveOrgRegion(orgSlug); + const { data } = await apiRequestToRegion( + regionUrl, + `/organizations/${orgSlug}/events-timeseries/`, + { + params: { + dataset: options.dataset ?? "errors", + yAxis: options.fields, + query: options.query || undefined, + interval: options.interval, + statsPeriod: + options.start || options.end + ? undefined + : (options.statsPeriod ?? "24h"), + start: options.start, + end: options.end, + }, + }, + ); + return data; +} + /** * Fetch a single page of events from the Explore/Events endpoint. * diff --git a/packages/cli/src/lib/explore-timeseries.ts b/packages/cli/src/lib/explore-timeseries.ts new file mode 100644 index 000000000..39b6ec681 --- /dev/null +++ b/packages/cli/src/lib/explore-timeseries.ts @@ -0,0 +1,95 @@ +/** + * Interval selection for Explore time series, using the granularities supported + * by Sentry's spans, logs, and metrics datasets. + */ + +import { ValidationError } from "./errors.js"; +import { type TimeRangeApiParams, timeRangeToSeconds } from "./time-range.js"; + +/** Supported interval strings and their durations in seconds. */ +const INTERVAL_SECONDS = { + "15s": 15, + "30s": 30, + "1m": 60, + "2m": 120, + "5m": 300, + "10m": 600, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "3h": 10_800, + "4h": 14_400, + "6h": 21_600, + "12h": 43_200, + "1d": 86_400, +} as const; + +/** Explore UI's minimum interval ladder, in descending range duration. */ +const DEFAULT_INTERVALS: readonly [number, string][] = [ + [30 * 86_400, "3h"], + [14 * 86_400, "1h"], + [4 * 86_400, "30m"], + [48 * 3600, "10m"], + [12 * 3600, "5m"], + [60, "1m"], + [30, "30s"], + [15, "15s"], +]; + +/** Sentry's ungrouped timeseries query budget; includes boundary buckets. */ +const MAX_BUCKETS = 10_100; + +/** Validate an explicit bucket duration without silently changing it. */ +export function parseTimeSeriesInterval( + value: string, +): keyof typeof INTERVAL_SECONDS { + if (!Object.hasOwn(INTERVAL_SECONDS, value)) { + throw new ValidationError( + `Invalid interval "${value}". Use one of: ${Object.keys(INTERVAL_SECONDS).join(", ")}.`, + "interval", + ); + } + return value as keyof typeof INTERVAL_SECONDS; +} + +/** + * Select a valid interval for a resolved API time range. The caller uses + * timeRangeToApiParams first so open-ended periods have the same boundaries + * for interval selection and the actual request. + */ +export function resolveTimeSeriesInterval( + params: TimeRangeApiParams, + interval?: string, +): string { + const seconds = timeRangeToSeconds( + params.statsPeriod + ? { type: "relative", period: params.statsPeriod } + : { type: "absolute", start: params.start, end: params.end }, + ); + if (seconds === undefined || seconds < 15) { + throw new ValidationError( + "Time series require a time range of at least 15 seconds. Use --period with a positive duration or date range.", + "period", + ); + } + + const selected = + interval ?? + DEFAULT_INTERVALS.find(([minimum]) => seconds >= minimum)?.[1] ?? + "15s"; + const intervalSeconds = INTERVAL_SECONDS[parseTimeSeriesInterval(selected)]; + if (intervalSeconds > seconds) { + throw new ValidationError( + `Interval ${selected} is longer than the requested period. Use a smaller --interval or a wider --period.`, + "interval", + ); + } + if (Math.ceil(seconds / intervalSeconds) > MAX_BUCKETS) { + throw new ValidationError( + `Interval ${selected} produces too many time buckets. Use a larger --interval or a shorter --period.`, + "interval", + ); + } + return selected; +} diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts new file mode 100644 index 000000000..20d84d014 --- /dev/null +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -0,0 +1,171 @@ +/** + * Human tables and JSON output for Explore aggregates bucketed over time. + * Keeps response metadata and bucket values intact for machine consumers. + */ + +import type { EventsTimeSeriesResponse } from "../api/explore.js"; +import { filterFields } from "./json.js"; +import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { buildMetaColumns } from "./meta-table.js"; +import { formatTable } from "./table.js"; +import { formatDurationCompactMs } from "./time-utils.js"; + +/** An API response with the query scope used by human output. */ +export type ExploreTimeSeriesData = EventsTimeSeriesResponse & { + /** API dataset used for this query. */ + dataset: string; + /** Organization slug, omitted from JSON output. */ + org: string; + /** Optional project slug, omitted from JSON output. */ + project?: string; +}; + +type TimeSeries = EventsTimeSeriesResponse["timeSeries"][number]; +type Bucket = TimeSeries["values"][number]; + +/** Format an epoch-millisecond timestamp without depending on local timezone. */ +function formatUtcTimestamp(timestamp: number): string { + return new Date(timestamp) + .toISOString() + .replace("T", " ") + .replace(".000Z", ""); +} + +/** Explain why a bucket is incomplete without describing retained data as pending. */ +function formatBucketStatus(bucket: Bucket): string { + if (!bucket.incomplete) { + return ""; + } + switch (bucket.incompleteReason) { + case "NOT_ELAPSED": + return "Window not elapsed"; + case "INGESTION_PENDING": + return "Ingestion pending"; + case "OUTSIDE_RETENTION": + return "Outside retention"; + default: + return "Incomplete"; + } +} + +/** Render one measure using the same typed cell formatting as Explore tables. */ +function formatSeries(series: TimeSeries): string { + const details = [ + `**${escapeMarkdownInline(series.yAxis)} over time**`, + `Interval: ${formatDurationCompactMs(series.meta.interval)}`, + ]; + if (series.meta.valueUnit && series.meta.valueUnit !== "none") { + details.push(`Unit: ${escapeMarkdownInline(series.meta.valueUnit)}`); + } + + if (series.meta.dataScanned === "partial") { + details.push("Only part of the available data was scanned."); + } + if (series.values.some((bucket) => bucket.confidence === "low")) { + details.push( + "Some buckets have low confidence; treat their values as estimates.", + ); + } + if ( + series.values.some( + (bucket) => bucket.sampleRate != null && bucket.sampleRate < 1, + ) + ) { + details.push("Results include sampled data."); + } + + const header = renderMarkdown(details.join("\n\n")); + if (series.values.length === 0) { + return `${header}\n\nNo time buckets returned.`; + } + + const rows = series.values.map((bucket) => ({ + time: formatUtcTimestamp(bucket.timestamp), + value: bucket.value, + status: formatBucketStatus(bucket), + })); + const columns = buildMetaColumns( + ["time", "value"], + { value: series.meta.valueType }, + { value: series.meta.valueUnit }, + ).map((column, index) => + index === 0 + ? { ...column, header: "TIME (UTC)", shrinkable: false } + : { + ...column, + value: (row: Record) => + typeof row.value === "string" + ? escapeMarkdownInline(row.value.replace(/\n/g, " ")) + : column.value(row), + }, + ); + if (series.values.some((bucket) => bucket.incomplete)) { + columns.push(...buildMetaColumns(["status"])); + } + + return `${header}\n\n${formatTable(rows, columns)}`; +} + +/** + * Render temporal buckets in UTC, retaining nulls, string values, and completeness + * distinctions. No totals or peaks are inferred from the selected aggregate. + */ +export function formatExploreTimeSeriesHuman( + data: ExploreTimeSeriesData, +): string { + const scope = data.project ? `${data.org}/${data.project}` : data.org; + const details = [ + `Querying ${escapeMarkdownInline(data.dataset)} in ${escapeMarkdownInline(scope)}:`, + ]; + if (data.meta?.start !== undefined && data.meta.end !== undefined) { + details.push( + `Range (UTC): ${formatUtcTimestamp(data.meta.start)} to ${formatUtcTimestamp(data.meta.end)}`, + ); + } + + const ingestion = data.meta?.ingestion; + const hasPendingIngestion = data.timeSeries.some((series) => + series.values.some( + (bucket) => + bucket.incomplete && bucket.incompleteReason === "INGESTION_PENDING", + ), + ); + if (ingestion && (hasPendingIngestion || ingestion.status === "stalled")) { + details.push( + `Ingestion status: ${escapeMarkdownInline(ingestion.status)}.`, + ); + if (ingestion.delaySeconds != null) { + details.push( + `Reported ingestion delay: ${formatDurationCompactMs(ingestion.delaySeconds * 1000)}.`, + ); + } + } + + const header = renderMarkdown(details.join("\n\n")); + if (data.timeSeries.length === 0) { + return `${header}\n\nNo time series returned.`; + } + return `${header}\n\n${data.timeSeries.map(formatSeries).join("\n\n")}`; +} + +/** + * Omit human-only scope fields while preserving the API response. As with the + * tabular Explore output, --fields projects each data row (here, each bucket), + * leaving series identity and metadata available to interpret those values. + */ +export function jsonTransformExploreTimeSeries( + data: ExploreTimeSeriesData, + fields?: string[], +): unknown { + const { org: _org, project: _project, ...response } = data; + if (!fields?.length) { + return response; + } + return { + ...response, + timeSeries: response.timeSeries.map((series) => ({ + ...series, + values: series.values.map((bucket) => filterFields(bucket, fields)), + })), + }; +} diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index cbfe37435..924f56371 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -80,6 +80,7 @@ let func: ExploreFunc; function createContext() { const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; return { context: { stdout: { @@ -88,14 +89,18 @@ function createContext() { }), }, stderr: { - write: vi.fn((_s: string) => { - /* no-op */ + write: vi.fn((s: string | Uint8Array) => { + stderrChunks.push( + typeof s === "string" ? s : new TextDecoder().decode(s), + ); + return true; }), }, cwd: "/tmp/test-explore", commandPrefix: ["sentry", "explore"], }, getStdout: () => stdoutChunks.join(""), + getStderr: () => stderrChunks.join(""), }; } @@ -110,6 +115,42 @@ const MOCK_EVENTS_RESPONSE = { }, }; +// Shape from events-timeseries responses, with anonymized values and timestamps. +const MOCK_TIME_SERIES_RESPONSE = { + meta: { + dataset: "errors", + start: 1_735_689_600_000, + end: 1_735_689_900_000, + }, + timeSeries: [ + { + yAxis: "count()", + meta: { valueType: "integer", valueUnit: null, interval: 60_000 }, + values: [ + { timestamp: 1_735_689_600_000, value: 0, incomplete: false }, + { + timestamp: 1_735_689_660_000, + value: 12, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + sampleCount: 6, + sampleRate: 0.5, + confidence: "low", + }, + { + timestamp: 1_735_689_720_000, + value: null, + incomplete: true, + incompleteReason: "NOT_ELAPSED", + sampleCount: 0, + sampleRate: null, + confidence: null, + }, + ], + }, + ], +}; + const MOCK_REPLAYS_RESPONSE = [ { id: "346789a703f6454384f1de473b8b9fcc", @@ -129,6 +170,7 @@ const MOCK_REPLAYS_RESPONSE = [ ]; let queryEventsSpy: ReturnType; +let queryEventsTimeSeriesSpy: ReturnType; let queryMetricsMetaSpy: ReturnType; let listReplaysSpy: ReturnType; let resolveTargetSpy: ReturnType; @@ -149,6 +191,8 @@ beforeEach(async () => { data: MOCK_EVENTS_RESPONSE, nextCursor: undefined, }); + queryEventsTimeSeriesSpy = vi.spyOn(apiClient, "queryEventsTimeSeries"); + queryEventsTimeSeriesSpy.mockResolvedValue(MOCK_TIME_SERIES_RESPONSE); queryMetricsMetaSpy = vi.spyOn(apiClient, "queryMetricsMeta"); queryMetricsMetaSpy.mockResolvedValue(MOCK_METRICS_META); listReplaysSpy = vi.spyOn(apiClient, "listReplays"); @@ -175,6 +219,7 @@ beforeEach(async () => { afterEach(() => { queryEventsSpy.mockRestore(); + queryEventsTimeSeriesSpy.mockRestore(); queryMetricsMetaSpy.mockRestore(); listReplaysSpy.mockRestore(); resolveTargetSpy.mockRestore(); @@ -192,6 +237,31 @@ const DEFAULT_FLAGS = { fresh: false, }; +const TIME_SERIES_FLAGS = { + ...DEFAULT_FLAGS, + limit: undefined, + "time-series": true, +}; + +async function runExploreArgs(args: string[]) { + const { context, getStdout, getStderr } = createContext(); + const cliContext = { + ...buildContext(process).forCommand({ prefix: ["sentry", "explore"] }), + ...context, + process: { + ...process, + stderr: { ...process.stderr, write: context.stderr.write }, + exitCode: undefined, + }, + }; + await run( + buildApplication(exploreCommand, { name: "explore" }), + args, + cliContext, + ); + return { getStdout, getStderr, exitCode: cliContext.process.exitCode }; +} + const parseExploreDataset = ( exploreCommand.parameters.flags!.dataset as { parse: (value: string) => string; @@ -618,6 +688,278 @@ describe("sentry explore", () => { }); }); + describe("time series", () => { + test("parses --time-series and chooses a usable interval for a five-minute window", async () => { + const result = await runExploreArgs([ + "test-org/", + "--time-series", + "--period", + "5m", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["count()"], + dataset: "errors", + statsPeriod: "5m", + interval: "1m", + }), + ); + expect(JSON.parse(result.getStdout()).timeSeries).toEqual( + MOCK_TIME_SERIES_RESPONSE.timeSeries, + ); + expect(queryEventsSpy).not.toHaveBeenCalled(); + expect(resolveCursorSpy).not.toHaveBeenCalled(); + expect(advancePaginationStateSpy).not.toHaveBeenCalled(); + expect(hasPreviousPageSpy).not.toHaveBeenCalled(); + }); + + test("parses explicit intervals and repeated aggregates while preserving target and environment filters", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "web" }); + + const result = await runExploreArgs([ + "test-org/web", + "--time-series", + "--interval", + "15m", + "--dataset", + "spans", + "--period", + "7d", + "-F", + "count()", + "-F", + "p95(span.duration)", + "--environment", + "production,canary", + "--query", + "span.op:http.server", + "--json", + ]); + + expect(result.exitCode).toBe(0); + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["count()", "p95(span.duration)"], + dataset: "spans", + query: + "project:web environment:[production,canary] span.op:http.server", + interval: "15m", + statsPeriod: "7d", + }), + ); + }); + + test("passes an absolute time window without a relative period", async () => { + const { context } = createContext(); + + await func.call( + context, + { + ...TIME_SERIES_FLAGS, + period: parsePeriod("2025-01-01T00:00:00Z..2025-01-01T01:00:00Z"), + interval: "15m", + }, + "test-org/", + ); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + start: "2025-01-01T00:00:00.000Z", + end: "2025-01-01T01:00:00.000Z", + interval: "15m", + }), + ); + expect( + queryEventsTimeSeriesSpy.mock.calls[0]?.[1].statsPeriod, + ).toBeUndefined(); + }); + + test("keeps legacy transaction filtering in temporal mode", async () => { + const { context } = createContext(); + + await func.call( + context, + { + ...TIME_SERIES_FLAGS, + dataset: "transactions", + query: "span.op:http.server", + field: ["p95(span.duration)"], + }, + "test-org/", + ); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["p95(span.duration)"], + dataset: "spans", + query: "span.op:http.server is_transaction:true", + }), + ); + }); + + test("resolves --metric with the requested aggregation and time window", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "web" }); + const { context } = createContext(); + + await func.call( + context, + { + ...TIME_SERIES_FLAGS, + metric: "cache.hit_rate", + agg: "avg", + period: parsePeriod("7d"), + }, + "test-org/web", + ); + + expect(queryMetricsMetaSpy).toHaveBeenCalledWith("test-org", { + statsPeriod: "7d", + project: "web", + }); + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["avg(value,cache.hit_rate,distribution,none)"], + dataset: "tracemetrics", + query: "project:web", + statsPeriod: "7d", + }), + ); + }); + + test("accepts raw metric aggregates without metadata discovery", async () => { + const { context } = createContext(); + const aggregate = "sum(value,llm.token_usage,distribution,none)"; + + await func.call( + context, + { + ...TIME_SERIES_FLAGS, + dataset: "tracemetrics", + field: [aggregate], + }, + "test-org/", + ); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: [aggregate], + dataset: "tracemetrics", + }), + ); + expect(queryMetricsMetaSpy).not.toHaveBeenCalled(); + }); + + test("preserves zero, null, string values and bucket metadata in the JSON envelope", async () => { + const response = { + ...MOCK_TIME_SERIES_RESPONSE, + timeSeries: [ + ...MOCK_TIME_SERIES_RESPONSE.timeSeries, + { + yAxis: "latest_event()", + meta: { valueType: "string", valueUnit: null, interval: 60_000 }, + values: [ + { + timestamp: 1_735_689_600_000, + value: "00123", + incomplete: false, + }, + ], + }, + ], + }; + queryEventsTimeSeriesSpy.mockResolvedValue(response); + const { context, getStdout } = createContext(); + + await func.call( + context, + { + ...TIME_SERIES_FLAGS, + field: ["count()", "latest_event()"], + json: true, + }, + "test-org/", + ); + + expect(JSON.parse(getStdout())).toEqual({ + ...response, + dataset: "errors", + }); + }); + + test("renders human-readable series without pagination hints", async () => { + const { context, getStdout } = createContext(); + + await func.call(context, TIME_SERIES_FLAGS, "test-org/"); + + expect(getStdout()).toContain("count()"); + expect(getStdout()).toContain("test-org"); + expect(getStdout()).not.toContain("Next:"); + expect(getStdout()).not.toContain("Showing 3 results"); + }); + + test.each([ + { name: "interval without temporal mode", args: ["--interval", "15m"] }, + { + name: "non-aggregate fields", + args: ["--time-series", "-F", "project", "-F", "count()"], + }, + { name: "replays", args: ["--time-series", "--dataset", "replays"] }, + { name: "cursor", args: ["--time-series", "--cursor", "next"] }, + { name: "sort", args: ["--time-series", "--sort", "-count()"] }, + { name: "explicit limit", args: ["--time-series", "--limit", "25"] }, + { + name: "invalid interval", + args: ["--time-series", "--interval", "bad"], + }, + { + name: "metrics without an aggregate", + args: ["--time-series", "--dataset", "metrics"], + }, + { + name: "metric grouping fields", + args: ["--time-series", "--metric", "llm.token_usage", "-F", "project"], + }, + ])("rejects $name before querying the API", async ({ args }) => { + const result = await runExploreArgs(["test-org/", ...args]); + + expect(result.exitCode).not.toBe(0); + expect(result.getStderr()).not.toBe(""); + expect(queryEventsTimeSeriesSpy).not.toHaveBeenCalled(); + expect(queryEventsSpy).not.toHaveBeenCalled(); + expect(queryMetricsMetaSpy).not.toHaveBeenCalled(); + expect(listReplaysSpy).not.toHaveBeenCalled(); + }); + + test("keeps the default tabular path and row limit when temporal mode is omitted", async () => { + const result = await runExploreArgs(["test-org/", "--json"]); + + expect(result.exitCode).toBe(0); + expect(queryEventsSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["title", "count()"], + limit: 25, + }), + ); + expect(queryEventsTimeSeriesSpy).not.toHaveBeenCalled(); + expect(JSON.parse(result.getStdout())).toEqual({ + ...MOCK_EVENTS_RESPONSE, + dataset: "errors", + hasMore: false, + hasPrev: false, + }); + }); + }); + describe("sort handling", () => { test("auto-sorts errors by first aggregate descending", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org" }); diff --git a/packages/cli/test/lib/api/explore.test.ts b/packages/cli/test/lib/api/explore.test.ts index 9514c68ca..b517ed20f 100644 --- a/packages/cli/test/lib/api/explore.test.ts +++ b/packages/cli/test/lib/api/explore.test.ts @@ -6,7 +6,11 @@ */ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { queryEvents } from "../../../src/lib/api/explore.js"; +import { + queryEvents, + queryEventsTimeSeries, +} from "../../../src/lib/api/explore.js"; +import { setOrgRegion } from "../../../src/lib/db/regions.js"; import { mockFetch, useTestConfigDir } from "../../helpers.js"; describe("queryEvents", () => { @@ -348,3 +352,207 @@ describe("queryEvents", () => { expect(result.data.meta).toEqual(firstPageMeta); }); }); + +describe("queryEventsTimeSeries", () => { + useTestConfigDir("explore-timeseries-test-"); + + let originalFetch: typeof globalThis.fetch; + let requests: Request[]; + + beforeEach(() => { + originalFetch = globalThis.fetch; + requests = []; + setOrgRegion("timeseries-org", "https://de.sentry.io"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + function mockResponse(body: unknown, status = 200) { + globalThis.fetch = mockFetch(async (input, init) => { + requests.push(new Request(input!, init)); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }); + } + + test("queries the org region and preserves values, nulls, and complete metadata", async () => { + const response = { + meta: { + dataset: "spans", + start: 1_729_425_600_000, + end: 1_729_425_900_000, + ingestion: { + status: "delayed", + delaySeconds: 120, + completeThrough: 1_729_425_780_000, + }, + extraMetadata: { source: "backend" }, + }, + timeSeries: [ + { + yAxis: "p95(span.duration)", + meta: { + interval: 300_000, + valueType: "duration", + valueUnit: "millisecond", + dataScanned: "partial", + }, + values: [ + { + timestamp: 1_729_425_600_000, + value: 125.5, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + sampleCount: 4, + sampleRate: 0.5, + confidence: "low", + extraBucketMetadata: true, + }, + { + timestamp: 1_729_425_900_000, + value: null, + incomplete: true, + incompleteReason: "NOT_ELAPSED", + sampleRate: null, + confidence: null, + }, + ], + }, + ], + }; + mockResponse(response); + + const result = await queryEventsTimeSeries("timeseries-org", { + fields: ["p95(span.duration)"], + dataset: "spans", + query: "project:my-project environment:production span.op:http.server", + statsPeriod: "1h", + interval: "5m", + }); + + expect(result).toEqual(response); + expect(requests).toHaveLength(1); + const request = requests[0]!; + const url = new URL(request.url); + expect(request.method).toBe("GET"); + expect(url.origin).toBe("https://de.sentry.io"); + expect(url.pathname).toBe( + "/api/0/organizations/timeseries-org/events-timeseries/", + ); + expect(url.searchParams.getAll("yAxis")).toEqual(["p95(span.duration)"]); + expect(url.searchParams.get("query")).toBe( + "project:my-project environment:production span.op:http.server", + ); + expect(url.searchParams.get("dataset")).toBe("spans"); + expect(url.searchParams.get("statsPeriod")).toBe("1h"); + expect(url.searchParams.get("interval")).toBe("5m"); + expect(url.searchParams.has("field")).toBe(false); + expect(url.searchParams.has("cursor")).toBe(false); + expect(url.searchParams.has("per_page")).toBe(false); + expect(url.searchParams.has("topEvents")).toBe(false); + }); + + test("sends repeated yAxis parameters and preserves date and string aggregates", async () => { + const response = { + timeSeries: [ + { + yAxis: "max(timestamp)", + meta: { + interval: 300_000, + valueType: "date", + valueUnit: null, + }, + values: [ + { + timestamp: 1_729_425_600_000, + value: "2024-10-20T12:01:00Z", + incomplete: false, + }, + ], + }, + { + yAxis: "latest_event()", + meta: { + interval: 300_000, + valueType: "string", + valueUnit: null, + }, + values: [ + { + timestamp: 1_729_425_600_000, + value: "00123456789012345678901234567890", + incomplete: false, + }, + ], + }, + ], + }; + mockResponse(response); + + const result = await queryEventsTimeSeries("timeseries-org", { + fields: ["max(timestamp)", "latest_event()"], + dataset: "errors", + interval: "5m", + }); + + expect(result).toEqual(response); + expect(new URL(requests[0]!.url).searchParams.getAll("yAxis")).toEqual([ + "max(timestamp)", + "latest_event()", + ]); + }); + + test("defaults to errors over 24h and preserves an empty response without metadata", async () => { + mockResponse({ timeSeries: [] }); + + const result = await queryEventsTimeSeries("timeseries-org", { + fields: ["count()"], + interval: "1h", + query: "", + }); + + expect(result).toEqual({ timeSeries: [] }); + const params = new URL(requests[0]!.url).searchParams; + expect(params.get("dataset")).toBe("errors"); + expect(params.get("statsPeriod")).toBe("24h"); + expect(params.has("query")).toBe(false); + }); + + test("uses an absolute range without also sending statsPeriod", async () => { + mockResponse({ timeSeries: [] }); + + await queryEventsTimeSeries("timeseries-org", { + fields: ["sum(value,request.count,counter,none)"], + dataset: "tracemetrics", + start: "2024-10-20T12:00:00Z", + end: "2024-10-20T13:00:00Z", + statsPeriod: "7d", + interval: "1m", + }); + + const params = new URL(requests[0]!.url).searchParams; + expect(params.get("dataset")).toBe("tracemetrics"); + expect(params.get("start")).toBe("2024-10-20T12:00:00Z"); + expect(params.get("end")).toBe("2024-10-20T13:00:00Z"); + expect(params.has("statsPeriod")).toBe(false); + }); + + test("propagates backend query errors with their detail", async () => { + mockResponse({ detail: "Selected interval is not allowed" }, 400); + + await expect( + queryEventsTimeSeries("timeseries-org", { + fields: ["count()"], + dataset: "logs", + interval: "20s", + }), + ).rejects.toMatchObject({ + status: 400, + detail: "Selected interval is not allowed", + }); + }); +}); diff --git a/packages/cli/test/lib/explore-timeseries.property.test.ts b/packages/cli/test/lib/explore-timeseries.property.test.ts new file mode 100644 index 000000000..5349ea33a --- /dev/null +++ b/packages/cli/test/lib/explore-timeseries.property.test.ts @@ -0,0 +1,50 @@ +/** Interval selection invariants across relative and absolute time ranges. */ + +import { assert as fcAssert, integer, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { resolveTimeSeriesInterval } from "../../src/lib/explore-timeseries.js"; +import { timeRangeToSeconds } from "../../src/lib/time-range.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +describe("Explore time series intervals", () => { + test("automatic buckets fit the window and query budget for either date syntax", () => { + fcAssert( + property(integer({ min: 15, max: 90 * 86_400 }), (seconds) => { + const start = Date.parse("2026-01-01T00:00:00Z"); + const relative = resolveTimeSeriesInterval({ + statsPeriod: `${seconds}s`, + }); + const absolute = resolveTimeSeriesInterval({ + start: new Date(start).toISOString(), + end: new Date(start + seconds * 1000).toISOString(), + }); + expect(absolute).toBe(relative); + const bucketSeconds = timeRangeToSeconds({ + type: "relative", + period: relative, + })!; + expect(bucketSeconds).toBeGreaterThanOrEqual(15); + expect(bucketSeconds).toBeLessThanOrEqual(seconds); + expect(Math.ceil(seconds / bucketSeconds)).toBeLessThanOrEqual(10_100); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("keeps a requested interval even when the automatic choice differs", () => { + fcAssert( + property(integer({ min: 1, max: 1000 }), (buckets) => { + expect( + resolveTimeSeriesInterval({ statsPeriod: `${buckets * 15}m` }, "15m"), + ).toBe("15m"); + }), + { numRuns: DEFAULT_NUM_RUNS }, + ); + }); + + test("rejects an explicit granularity beyond the server's bucket budget", () => { + expect(() => + resolveTimeSeriesInterval({ statsPeriod: "90d" }, "15s"), + ).toThrow("too many time buckets"); + }); +}); diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts new file mode 100644 index 000000000..9d064aa55 --- /dev/null +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type ExploreTimeSeriesData, + formatExploreTimeSeriesHuman, + jsonTransformExploreTimeSeries, +} from "../../../src/lib/formatters/explore-timeseries.js"; + +/** Sanitized shape of a captured spans timeseries response, including ingestion metadata. */ +function makeData(): ExploreTimeSeriesData { + return { + org: "test-org", + project: "test-project", + dataset: "spans", + meta: { + dataset: "spans", + start: Date.UTC(2026, 0, 1), + end: Date.UTC(2026, 0, 1, 0, 15), + ingestion: { + status: "healthy", + delaySeconds: 288, + completeThrough: Date.UTC(2026, 0, 1, 0, 10, 12), + }, + }, + timeSeries: [ + { + yAxis: "count()", + meta: { + valueType: "integer", + valueUnit: null, + interval: 300_000, + dataScanned: "full", + }, + values: [ + { + timestamp: Date.UTC(2026, 0, 1), + value: 1234, + incomplete: false, + sampleCount: 1234, + sampleRate: 1, + confidence: "high", + }, + { + timestamp: Date.UTC(2026, 0, 1, 0, 5), + value: 0, + incomplete: false, + sampleCount: 0, + sampleRate: 1, + confidence: "high", + }, + { + timestamp: Date.UTC(2026, 0, 1, 0, 10), + value: 52, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + sampleCount: 52, + sampleRate: 1, + confidence: "high", + }, + ], + }, + ], + }; +} + +beforeEach(() => { + vi.stubEnv("SENTRY_PLAIN_OUTPUT", "1"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("formatExploreTimeSeriesHuman", () => { + test("renders UTC buckets, the effective interval, and ingestion completeness", () => { + const output = formatExploreTimeSeriesHuman(makeData()); + + expect(output).toContain("Querying spans in test-org/test-project"); + expect(output).toContain( + "Range (UTC): 2026-01-01 00:00:00 to 2026-01-01 00:15:00", + ); + expect(output).toContain("count() over time"); + expect(output).toContain("Interval: 5m"); + expect(output).toContain("TIME (UTC)"); + expect(output).toContain("1,234"); + expect(output).toMatch(/2026-01-01 00:05:00\s+│\s+0/); + expect(output).toContain("Ingestion pending"); + expect(output).toContain("Ingestion status: healthy"); + expect(output).toContain("Reported ingestion delay: 4m 48s"); + expect(output).not.toContain("Total:"); + expect(output).not.toContain("Peak:"); + }); + + test("uses metadata units and keeps missing values distinct from zero", () => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.yAxis = "p95(span.duration)"; + series.meta.valueType = "duration"; + series.meta.valueUnit = "millisecond"; + series.values = [ + { timestamp: Date.UTC(2026, 0, 1), value: null, incomplete: false }, + { timestamp: Date.UTC(2026, 0, 1, 0, 5), value: 12.5, incomplete: false }, + ]; + + const output = formatExploreTimeSeriesHuman(data); + + expect(output).toContain("p95(span.duration) over time"); + expect(output).toContain("Unit: millisecond"); + expect(output).toContain("12.5ms"); + expect(output).toMatch(/2026-01-01 00:00:00\s+│\s+—/); + expect(output).not.toContain("STATUS"); + expect(output).not.toContain("Ingestion status"); + }); + + test("preserves identifier-like and markdown-like strings without coercion", () => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.yAxis = "latest_event()"; + series.meta.valueType = "string"; + series.values = [ + { timestamp: Date.UTC(2026, 0, 1), value: "00123", incomplete: false }, + { + timestamp: Date.UTC(2026, 0, 1, 0, 5), + value: "*literal_value*", + incomplete: false, + }, + { + timestamp: Date.UTC(2026, 0, 1, 0, 10), + value: null, + incomplete: false, + }, + ]; + + const output = formatExploreTimeSeriesHuman(data); + + expect(output).toContain("latest_event() over time"); + expect(output).toContain("00123"); + expect(output).toContain("*literal_value*"); + expect(output).toMatch(/2026-01-01 00:10:00\s+│\s+—/); + }); + + test("distinguishes retention, unfinished windows, and unavailable scan coverage", () => { + const data = makeData(); + const series = data.timeSeries[0]!; + series.meta.dataScanned = "partial"; + series.values[0] = { + timestamp: Date.UTC(2026, 0, 1), + value: 100, + incomplete: true, + incompleteReason: "OUTSIDE_RETENTION", + sampleCount: 10, + sampleRate: 0.1, + confidence: "low", + }; + series.values[2]!.incompleteReason = "NOT_ELAPSED"; + + const output = formatExploreTimeSeriesHuman(data); + + expect(output).toContain("Outside retention"); + expect(output).toContain("Window not elapsed"); + expect(output).toContain("Only part of the available data was scanned"); + expect(output).toContain("Some buckets have low confidence"); + expect(output).toContain("Results include sampled data"); + expect(output).not.toContain("Ingestion pending"); + expect(output).not.toContain("Reported ingestion delay"); + }); + + test("does not mistake absent series or buckets for zero-valued results", () => { + const data = makeData(); + data.timeSeries[0]!.values = []; + expect(formatExploreTimeSeriesHuman(data)).toContain( + "No time buckets returned", + ); + + data.timeSeries = []; + expect(formatExploreTimeSeriesHuman(data)).toContain( + "No time series returned", + ); + }); + + test("renders every returned measure with its own metadata", () => { + const data = makeData(); + data.timeSeries.push({ + yAxis: "avg(span.duration)", + meta: { + valueType: "duration", + valueUnit: "millisecond", + interval: 600_000, + }, + values: [ + { timestamp: Date.UTC(2026, 0, 1), value: 42, incomplete: false }, + ], + }); + + const output = formatExploreTimeSeriesHuman(data); + + expect(output).toContain("count() over time"); + expect(output).toContain("avg(span.duration) over time"); + expect(output).toContain("Interval: 10m"); + expect(output).toContain("42ms"); + }); +}); + +describe("jsonTransformExploreTimeSeries", () => { + test("preserves response values and all metadata while excluding human scope", () => { + const base = makeData(); + base.timeSeries[0]!.values[0]!.value = "00123"; + base.timeSeries[0]!.values[1]!.value = null; + const data = { + ...base, + meta: { ...base.meta, extraMetadata: { retained: true } }, + }; + const original = structuredClone(data); + const { org: _org, project: _project, ...expected } = data; + + expect(jsonTransformExploreTimeSeries(data)).toEqual(expected); + expect(data).toEqual(original); + }); + + test("applies --fields to buckets while preserving series identity and metadata", () => { + const data = makeData(); + const original = structuredClone(data); + const series = data.timeSeries[0]!; + + expect( + jsonTransformExploreTimeSeries(data, ["timestamp", "value"]), + ).toEqual({ + dataset: "spans", + meta: data.meta, + timeSeries: [ + { + yAxis: "count()", + meta: series.meta, + values: [ + { timestamp: Date.UTC(2026, 0, 1), value: 1234 }, + { timestamp: Date.UTC(2026, 0, 1, 0, 5), value: 0 }, + { timestamp: Date.UTC(2026, 0, 1, 0, 10), value: 52 }, + ], + }, + ], + }); + expect(data).toEqual(original); + }); +}); diff --git a/packages/cli/test/lib/sdk-flag-types.test.ts b/packages/cli/test/lib/sdk-flag-types.test.ts index 01b1d2226..e63b840f5 100644 --- a/packages/cli/test/lib/sdk-flag-types.test.ts +++ b/packages/cli/test/lib/sdk-flag-types.test.ts @@ -25,6 +25,13 @@ function paramsType(name: string): string { } describe("generated SDK flag types", () => { + test("keeps optional Explore limits numeric alongside temporal flags", () => { + const explore = paramsType("ExploreParams"); + expect(explore).toContain("limit?: number;"); + expect(explore).toContain("timeSeries?: boolean;"); + expect(explore).toContain("interval?: string;"); + }); + test("declares variadic flags as arrays and keeps scalar flags scalar", () => { const login = paramsType("AuthLoginParams"); expect(login).toContain("scope?: Array;"); From b385f16c7e889936d5c6397fd1954ddecc23a26a Mon Sep 17 00:00:00 2001 From: betegon Date: Sun, 25 Oct 2026 22:58:08 +0100 Subject: [PATCH 2/5] fix(cli): preserve explore periods and dataset labels --- packages/cli/src/commands/explore.ts | 16 +- .../src/lib/formatters/explore-timeseries.ts | 7 +- packages/cli/test/commands/explore.test.ts | 203 +++++++----------- packages/cli/test/e2e/explore.test.ts | 137 ++++++++++++ packages/cli/test/lib/api/explore.test.ts | 23 +- .../lib/formatters/explore-timeseries.test.ts | 98 ++------- 6 files changed, 255 insertions(+), 229 deletions(-) create mode 100644 packages/cli/test/e2e/explore.test.ts diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index f39be3ebd..45c3c4fb0 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -277,8 +277,9 @@ function orderFieldNames( /** Format explore results for human-readable terminal output */ function formatExploreHuman(data: ExploreData | ExploreTimeSeriesData): string { + const displayDataset = API_TO_USER_DATASET.get(data.dataset) ?? data.dataset; if ("timeSeries" in data) { - return formatExploreTimeSeriesHuman(data); + return formatExploreTimeSeriesHuman({ ...data, dataset: displayDataset }); } if (data.data.length === 0) { // Empty page mid-pagination (more or prev pages exist) vs. truly no results. @@ -295,7 +296,6 @@ function formatExploreHuman(data: ExploreData | ExploreTimeSeriesData): string { ); const scope = data.project ? `${data.org}/${data.project}` : data.org; - const displayDataset = API_TO_USER_DATASET.get(data.dataset) ?? data.dataset; const header = `Querying ${displayDataset} in ${scope}:\n\n`; return header + formatTable(data.data, columns); } @@ -334,7 +334,7 @@ function jsonTransformExplore( // Pagination hints // --------------------------------------------------------------------------- -/** Default limit value matching the flag default for hint comparison */ +/** Default table row limit, also omitted from pagination hints. */ const DEFAULT_LIMIT = 25; function defaultFieldsForDataset(dataset: string): readonly string[] { @@ -872,7 +872,7 @@ export const exploreCommand = buildListCommand("explore", { kind: "parsed", parse: parseLimit, placeholder: "number", - brief: `Number of table rows (1-${LIST_MAX_LIMIT}, default: 25)`, + brief: `Number of table rows (1-${LIST_MAX_LIMIT}, default: ${DEFAULT_LIMIT})`, optional: true, }, "time-series": { @@ -922,7 +922,11 @@ export const exploreCommand = buildListCommand("explore", { if (userSuppliedFields) { fieldList = flags.field; } - const timeRange = flags.period; + // SDK callers supply the public string form without Stricli parsing. + const timeRange = + typeof flags.period === "string" + ? parsePeriod(flags.period) + : flags.period; const environment = parseReplayEnvironmentFilter(flags.environment); // --metric auto mode: resolve metric name → tracemetrics aggregate @@ -1047,7 +1051,7 @@ export const exploreCommand = buildListCommand("explore", { const hasMore = !!nextCursor; const baseTarget = project ? `${org}/${project}` : `${org}/`; - const hintFlags = { ...flags, dataset }; + const hintFlags = { ...flags, dataset, period: timeRange }; const nav = paginationHint({ hasPrev, hasMore, diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index 20d84d014..e5571db7e 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -8,7 +8,10 @@ import { filterFields } from "./json.js"; import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; import { buildMetaColumns } from "./meta-table.js"; import { formatTable } from "./table.js"; -import { formatDurationCompactMs } from "./time-utils.js"; +import { + formatDurationCompact, + formatDurationCompactMs, +} from "./time-utils.js"; /** An API response with the query scope used by human output. */ export type ExploreTimeSeriesData = EventsTimeSeriesResponse & { @@ -136,7 +139,7 @@ export function formatExploreTimeSeriesHuman( ); if (ingestion.delaySeconds != null) { details.push( - `Reported ingestion delay: ${formatDurationCompactMs(ingestion.delaySeconds * 1000)}.`, + `Reported ingestion delay: ${formatDurationCompact(ingestion.delaySeconds)}.`, ); } } diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index 924f56371..b2eb41c4d 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -10,6 +10,7 @@ import { buildApplication, run } from "@stricli/core"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { exploreCommand } from "../../src/commands/explore.js"; import { buildContext } from "../../src/context.js"; +import { createSentrySDK } from "../../src/index.js"; vi.mock("../../src/lib/api-client.js", async (importOriginal) => { const actual = @@ -708,77 +709,45 @@ describe("sentry explore", () => { interval: "1m", }), ); - expect(JSON.parse(result.getStdout()).timeSeries).toEqual( - MOCK_TIME_SERIES_RESPONSE.timeSeries, - ); + expect(JSON.parse(result.getStdout())).toEqual({ + ...MOCK_TIME_SERIES_RESPONSE, + dataset: "errors", + }); expect(queryEventsSpy).not.toHaveBeenCalled(); expect(resolveCursorSpy).not.toHaveBeenCalled(); expect(advancePaginationStateSpy).not.toHaveBeenCalled(); expect(hasPreviousPageSpy).not.toHaveBeenCalled(); }); - test("parses explicit intervals and repeated aggregates while preserving target and environment filters", async () => { - resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "web" }); - - const result = await runExploreArgs([ - "test-org/web", - "--time-series", - "--interval", - "15m", - "--dataset", - "spans", - "--period", - "7d", - "-F", - "count()", - "-F", - "p95(span.duration)", - "--environment", - "production,canary", - "--query", - "span.op:http.server", - "--json", - ]); - - expect(result.exitCode).toBe(0); - expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( - "test-org", - expect.objectContaining({ - fields: ["count()", "p95(span.duration)"], - dataset: "spans", - query: - "project:web environment:[production,canary] span.op:http.server", - interval: "15m", - statsPeriod: "7d", - }), - ); - }); - - test("passes an absolute time window without a relative period", async () => { - const { context } = createContext(); - - await func.call( - context, - { - ...TIME_SERIES_FLAGS, - period: parsePeriod("2025-01-01T00:00:00Z..2025-01-01T01:00:00Z"), - interval: "15m", - }, - "test-org/", - ); - - expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( - "test-org", - expect.objectContaining({ + test.each([ + { period: "1h", timeParams: { statsPeriod: "1h" } }, + { + period: "2025-01-01T00:00:00Z..2025-01-01T01:00:00Z", + timeParams: { start: "2025-01-01T00:00:00.000Z", end: "2025-01-01T01:00:00.000Z", + }, + }, + ])( + "accepts the SDK's string period $period", + async ({ period, timeParams }) => { + const sdk = createSentrySDK({ cwd: "/tmp/test-explore" }); + await sdk.explore({ + target: "test-org/", + timeSeries: true, + period, interval: "15m", - }), - ); - expect( - queryEventsTimeSeriesSpy.mock.calls[0]?.[1].statsPeriod, - ).toBeUndefined(); - }); + }); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith("test-org", { + fields: ["count()"], + dataset: "errors", + query: undefined, + interval: "15m", + ...timeParams, + }); + }, + ); test("keeps legacy transaction filtering in temporal mode", async () => { const { context } = createContext(); @@ -834,20 +803,18 @@ describe("sentry explore", () => { ); }); - test("accepts raw metric aggregates without metadata discovery", async () => { - const { context } = createContext(); + test("shows the public metrics name while querying raw aggregates without discovery", async () => { const aggregate = "sum(value,llm.token_usage,distribution,none)"; - - await func.call( - context, - { - ...TIME_SERIES_FLAGS, - dataset: "tracemetrics", - field: [aggregate], - }, + const result = await runExploreArgs([ "test-org/", - ); + "--time-series", + "--dataset", + "metrics", + "-F", + aggregate, + ]); + expect(result.exitCode).toBe(0); expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( "test-org", expect.objectContaining({ @@ -856,83 +823,63 @@ describe("sentry explore", () => { }), ); expect(queryMetricsMetaSpy).not.toHaveBeenCalled(); - }); - - test("preserves zero, null, string values and bucket metadata in the JSON envelope", async () => { - const response = { - ...MOCK_TIME_SERIES_RESPONSE, - timeSeries: [ - ...MOCK_TIME_SERIES_RESPONSE.timeSeries, - { - yAxis: "latest_event()", - meta: { valueType: "string", valueUnit: null, interval: 60_000 }, - values: [ - { - timestamp: 1_735_689_600_000, - value: "00123", - incomplete: false, - }, - ], - }, - ], - }; - queryEventsTimeSeriesSpy.mockResolvedValue(response); - const { context, getStdout } = createContext(); - - await func.call( - context, - { - ...TIME_SERIES_FLAGS, - field: ["count()", "latest_event()"], - json: true, - }, - "test-org/", - ); - - expect(JSON.parse(getStdout())).toEqual({ - ...response, - dataset: "errors", - }); - }); - - test("renders human-readable series without pagination hints", async () => { - const { context, getStdout } = createContext(); - - await func.call(context, TIME_SERIES_FLAGS, "test-org/"); - - expect(getStdout()).toContain("count()"); - expect(getStdout()).toContain("test-org"); - expect(getStdout()).not.toContain("Next:"); - expect(getStdout()).not.toContain("Showing 3 results"); + expect(result.getStdout()).toContain("Querying metrics in test-org"); + expect(result.getStdout()).not.toContain("tracemetrics"); + expect(result.getStdout()).not.toContain("Next:"); }); test.each([ - { name: "interval without temporal mode", args: ["--interval", "15m"] }, + { + name: "interval without temporal mode", + args: ["--interval", "15m"], + error: "--interval requires --time-series", + }, { name: "non-aggregate fields", args: ["--time-series", "-F", "project", "-F", "count()"], + error: "Grouping is not supported: project", + }, + { + name: "replays", + args: ["--time-series", "--dataset", "replays"], + error: "The replays dataset is only available as a table", + }, + { + name: "cursor", + args: ["--time-series", "--cursor", "next"], + error: "--cursor is only available for tables", + }, + { + name: "sort", + args: ["--time-series", "--sort", "-count()"], + error: "--sort is only available for tables", + }, + { + name: "explicit limit", + args: ["--time-series", "--limit", "25"], + error: "--limit is only available for tables", }, - { name: "replays", args: ["--time-series", "--dataset", "replays"] }, - { name: "cursor", args: ["--time-series", "--cursor", "next"] }, - { name: "sort", args: ["--time-series", "--sort", "-count()"] }, - { name: "explicit limit", args: ["--time-series", "--limit", "25"] }, { name: "invalid interval", args: ["--time-series", "--interval", "bad"], + error: 'Invalid interval "bad"', }, { name: "metrics without an aggregate", args: ["--time-series", "--dataset", "metrics"], + error: + "The metrics dataset requires --metric or explicit --field flags", }, { name: "metric grouping fields", args: ["--time-series", "--metric", "llm.token_usage", "-F", "project"], + error: "Grouping is not supported: project", }, - ])("rejects $name before querying the API", async ({ args }) => { + ])("rejects $name before querying the API", async ({ args, error }) => { const result = await runExploreArgs(["test-org/", ...args]); expect(result.exitCode).not.toBe(0); - expect(result.getStderr()).not.toBe(""); + expect(result.getStderr()).toContain(error); expect(queryEventsTimeSeriesSpy).not.toHaveBeenCalled(); expect(queryEventsSpy).not.toHaveBeenCalled(); expect(queryMetricsMetaSpy).not.toHaveBeenCalled(); diff --git a/packages/cli/test/e2e/explore.test.ts b/packages/cli/test/e2e/explore.test.ts new file mode 100644 index 000000000..4d9e4ec3a --- /dev/null +++ b/packages/cli/test/e2e/explore.test.ts @@ -0,0 +1,137 @@ +/** Explore time series through the real CLI router and HTTP transport. */ + +import { expect, test } from "vitest"; +import { createE2EContext } from "../fixture.js"; +import organizationFixture from "../fixtures/organization.json"; +import { useTestConfigDir } from "../helpers.js"; +import { TEST_ORG, TEST_PROJECT, TEST_TOKEN } from "../mocks/routes.js"; +import { createMockServer } from "../mocks/server.js"; + +const getConfigDir = useTestConfigDir("e2e-explore-"); +const start = Date.UTC(2026, 0, 1); +const end = start + 120_000; + +/** Minimal sanitized timeseries response with numeric, missing, and string values. */ +const response = { + meta: { + dataset: "spans", + start, + end, + ingestion: { status: "healthy", delaySeconds: 30 }, + }, + timeSeries: [ + { + yAxis: "count()", + meta: { + valueType: "integer", + valueUnit: null, + interval: 60_000, + dataScanned: "partial", + }, + values: [ + { timestamp: start, value: 0, incomplete: false }, + { + timestamp: start + 60_000, + value: null, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + sampleRate: 0.5, + confidence: "low", + }, + ], + }, + { + yAxis: "latest_event()", + meta: { valueType: "string", valueUnit: null, interval: 60_000 }, + values: [{ timestamp: start, value: "00123", incomplete: false }], + }, + ], +}; + +test("queries repeated aggregates and preserves metadata when projecting buckets", async () => { + const requests: URL[] = []; + const server = createMockServer( + [ + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/`, + response: organizationFixture, + }, + { + method: "GET", + path: `/api/0/organizations/${TEST_ORG}/events-timeseries/`, + response: (request: Request) => { + requests.push(new URL(request.url)); + return { body: response }; + }, + }, + ], + { validTokens: [TEST_TOKEN] }, + ); + await server.start(); + try { + const ctx = createE2EContext(getConfigDir(), server.url); + await ctx.setAuthToken(TEST_TOKEN); + const args = [ + "explore", + `${TEST_ORG}/${TEST_PROJECT}`, + "--time-series", + "--dataset", + "spans", + "-F", + "count()", + "-F", + "latest_event()", + "--interval", + "1m", + "--period", + "2026-01-01T00:00:00Z..2026-01-01T00:02:00Z", + "--environment", + "production,canary", + "--query", + "span.op:http.server", + "--json", + ]; + const result = await ctx.run(args); + + expect(result.exitCode, result.stderr + result.stdout).toBe(0); + expect(requests).toHaveLength(1); + const params = requests[0]!.searchParams; + expect(params.getAll("yAxis")).toEqual(["count()", "latest_event()"]); + expect(Object.fromEntries(params)).toEqual({ + dataset: "spans", + yAxis: "latest_event()", + interval: "1m", + query: `project:${TEST_PROJECT} environment:[production,canary] span.op:http.server`, + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }); + expect(JSON.parse(result.stdout)).toEqual({ + ...response, + dataset: "spans", + }); + + const projected = await ctx.run([...args, "--fields", "timestamp,value"]); + + expect(projected.exitCode, projected.stderr + projected.stdout).toBe(0); + expect(JSON.parse(projected.stdout)).toEqual({ + ...response, + dataset: "spans", + timeSeries: [ + { + ...response.timeSeries[0], + values: [ + { timestamp: start, value: 0 }, + { timestamp: start + 60_000, value: null }, + ], + }, + { + ...response.timeSeries[1], + values: [{ timestamp: start, value: "00123" }], + }, + ], + }); + } finally { + server.stop(); + } +}); diff --git a/packages/cli/test/lib/api/explore.test.ts b/packages/cli/test/lib/api/explore.test.ts index b517ed20f..e18260833 100644 --- a/packages/cli/test/lib/api/explore.test.ts +++ b/packages/cli/test/lib/api/explore.test.ts @@ -456,7 +456,7 @@ describe("queryEventsTimeSeries", () => { expect(url.searchParams.has("topEvents")).toBe(false); }); - test("sends repeated yAxis parameters and preserves date and string aggregates", async () => { + test("defaults to errors over 24h with repeated yAxis and string-valued aggregates", async () => { const response = { timeSeries: [ { @@ -495,28 +495,16 @@ describe("queryEventsTimeSeries", () => { const result = await queryEventsTimeSeries("timeseries-org", { fields: ["max(timestamp)", "latest_event()"], - dataset: "errors", interval: "5m", + query: "", }); expect(result).toEqual(response); - expect(new URL(requests[0]!.url).searchParams.getAll("yAxis")).toEqual([ + const params = new URL(requests[0]!.url).searchParams; + expect(params.getAll("yAxis")).toEqual([ "max(timestamp)", "latest_event()", ]); - }); - - test("defaults to errors over 24h and preserves an empty response without metadata", async () => { - mockResponse({ timeSeries: [] }); - - const result = await queryEventsTimeSeries("timeseries-org", { - fields: ["count()"], - interval: "1h", - query: "", - }); - - expect(result).toEqual({ timeSeries: [] }); - const params = new URL(requests[0]!.url).searchParams; expect(params.get("dataset")).toBe("errors"); expect(params.get("statsPeriod")).toBe("24h"); expect(params.has("query")).toBe(false); @@ -525,7 +513,7 @@ describe("queryEventsTimeSeries", () => { test("uses an absolute range without also sending statsPeriod", async () => { mockResponse({ timeSeries: [] }); - await queryEventsTimeSeries("timeseries-org", { + const result = await queryEventsTimeSeries("timeseries-org", { fields: ["sum(value,request.count,counter,none)"], dataset: "tracemetrics", start: "2024-10-20T12:00:00Z", @@ -534,6 +522,7 @@ describe("queryEventsTimeSeries", () => { interval: "1m", }); + expect(result).toEqual({ timeSeries: [] }); const params = new URL(requests[0]!.url).searchParams; expect(params.get("dataset")).toBe("tracemetrics"); expect(params.get("start")).toBe("2024-10-20T12:00:00Z"); diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index 9d064aa55..4ad86d6ef 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { type ExploreTimeSeriesData, formatExploreTimeSeriesHuman, - jsonTransformExploreTimeSeries, } from "../../../src/lib/formatters/explore-timeseries.js"; /** Sanitized shape of a captured spans timeseries response, including ingestion metadata. */ @@ -86,26 +85,37 @@ describe("formatExploreTimeSeriesHuman", () => { expect(output).toContain("Ingestion pending"); expect(output).toContain("Ingestion status: healthy"); expect(output).toContain("Reported ingestion delay: 4m 48s"); - expect(output).not.toContain("Total:"); - expect(output).not.toContain("Peak:"); }); - test("uses metadata units and keeps missing values distinct from zero", () => { + test("renders each measure with its own metadata and distinguishes missing values from zero", () => { const data = makeData(); - const series = data.timeSeries[0]!; - series.yAxis = "p95(span.duration)"; - series.meta.valueType = "duration"; - series.meta.valueUnit = "millisecond"; - series.values = [ - { timestamp: Date.UTC(2026, 0, 1), value: null, incomplete: false }, - { timestamp: Date.UTC(2026, 0, 1, 0, 5), value: 12.5, incomplete: false }, - ]; + data.timeSeries[0]!.values.pop(); + data.timeSeries.push({ + yAxis: "p95(span.duration)", + meta: { + valueType: "duration", + valueUnit: "millisecond", + interval: 600_000, + }, + values: [ + { timestamp: Date.UTC(2026, 0, 1), value: null, incomplete: false }, + { + timestamp: Date.UTC(2026, 0, 1, 0, 10), + value: 12.5, + incomplete: false, + }, + ], + }); const output = formatExploreTimeSeriesHuman(data); + expect(output).toContain("count() over time"); expect(output).toContain("p95(span.duration) over time"); + expect(output).toContain("Interval: 5m"); + expect(output).toContain("Interval: 10m"); expect(output).toContain("Unit: millisecond"); expect(output).toContain("12.5ms"); + expect(output).toMatch(/2026-01-01 00:05:00\s+│\s+0/); expect(output).toMatch(/2026-01-01 00:00:00\s+│\s+—/); expect(output).not.toContain("STATUS"); expect(output).not.toContain("Ingestion status"); @@ -176,68 +186,4 @@ describe("formatExploreTimeSeriesHuman", () => { "No time series returned", ); }); - - test("renders every returned measure with its own metadata", () => { - const data = makeData(); - data.timeSeries.push({ - yAxis: "avg(span.duration)", - meta: { - valueType: "duration", - valueUnit: "millisecond", - interval: 600_000, - }, - values: [ - { timestamp: Date.UTC(2026, 0, 1), value: 42, incomplete: false }, - ], - }); - - const output = formatExploreTimeSeriesHuman(data); - - expect(output).toContain("count() over time"); - expect(output).toContain("avg(span.duration) over time"); - expect(output).toContain("Interval: 10m"); - expect(output).toContain("42ms"); - }); -}); - -describe("jsonTransformExploreTimeSeries", () => { - test("preserves response values and all metadata while excluding human scope", () => { - const base = makeData(); - base.timeSeries[0]!.values[0]!.value = "00123"; - base.timeSeries[0]!.values[1]!.value = null; - const data = { - ...base, - meta: { ...base.meta, extraMetadata: { retained: true } }, - }; - const original = structuredClone(data); - const { org: _org, project: _project, ...expected } = data; - - expect(jsonTransformExploreTimeSeries(data)).toEqual(expected); - expect(data).toEqual(original); - }); - - test("applies --fields to buckets while preserving series identity and metadata", () => { - const data = makeData(); - const original = structuredClone(data); - const series = data.timeSeries[0]!; - - expect( - jsonTransformExploreTimeSeries(data, ["timestamp", "value"]), - ).toEqual({ - dataset: "spans", - meta: data.meta, - timeSeries: [ - { - yAxis: "count()", - meta: series.meta, - values: [ - { timestamp: Date.UTC(2026, 0, 1), value: 1234 }, - { timestamp: Date.UTC(2026, 0, 1, 0, 5), value: 0 }, - { timestamp: Date.UTC(2026, 0, 1, 0, 10), value: 52 }, - ], - }, - ], - }); - expect(data).toEqual(original); - }); }); From baa1294aa945d81f72db61e28e5596589549e161 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 26 Oct 2026 16:24:12 +0100 Subject: [PATCH 3/5] fix(cli): handle long time series and preserve Explore recovery --- packages/cli/src/commands/explore.ts | 15 +++++++- packages/cli/src/lib/explore-timeseries.ts | 14 +++++-- .../src/lib/formatters/explore-timeseries.ts | 2 +- packages/cli/test/commands/explore.test.ts | 38 ++++++++++++++++--- packages/cli/test/e2e/explore.test.ts | 4 +- .../lib/explore-timeseries.property.test.ts | 15 ++++++-- .../lib/formatters/explore-timeseries.test.ts | 4 +- 7 files changed, 73 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index 45c3c4fb0..da8f4840a 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -654,7 +654,8 @@ function resolveDatasetConfig(params: { ...timeRangeToApiParams(timeRange), }).catch((error: unknown): never => { throw toExploreFieldError(error, { - dataset: API_TO_USER_DATASET.get(resolved.dataset) ?? resolved.dataset, + dataset: + API_TO_USER_DATASET.get(resolved.dataset) ?? resolved.dataset, fields, userFields: (flags.field ?? []).filter( (field) => !flags.metric || !isAggregateFieldOrEquation(field), @@ -993,7 +994,17 @@ export const exploreCommand = buildListCommand("explore", { interval, ...timeParams, }).catch((error: unknown): never => { - throw toSearchQueryError(error, flags.query); + const fieldError = toExploreFieldError(error, { + dataset: + API_TO_USER_DATASET.get(resolved.dataset) ?? resolved.dataset, + fields: fieldList, + userFields: (flags.field ?? []).filter( + (field) => !flags.metric || !isAggregateFieldOrEquation(field), + ), + sort: undefined, + userSort: undefined, + }); + throw toSearchQueryError(fieldError, flags.query); }), ); yield new CommandOutput({ diff --git a/packages/cli/src/lib/explore-timeseries.ts b/packages/cli/src/lib/explore-timeseries.ts index 39b6ec681..8e4b8fbd8 100644 --- a/packages/cli/src/lib/explore-timeseries.ts +++ b/packages/cli/src/lib/explore-timeseries.ts @@ -6,7 +6,7 @@ import { ValidationError } from "./errors.js"; import { type TimeRangeApiParams, timeRangeToSeconds } from "./time-range.js"; -/** Supported interval strings and their durations in seconds. */ +/** Supported intervals in increasing duration order, with durations in seconds. */ const INTERVAL_SECONDS = { "15s": 15, "30s": 30, @@ -26,7 +26,7 @@ const INTERVAL_SECONDS = { } as const; /** Explore UI's minimum interval ladder, in descending range duration. */ -const DEFAULT_INTERVALS: readonly [number, string][] = [ +const DEFAULT_INTERVALS: readonly [number, keyof typeof INTERVAL_SECONDS][] = [ [30 * 86_400, "3h"], [14 * 86_400, "1h"], [4 * 86_400, "30m"], @@ -74,10 +74,16 @@ export function resolveTimeSeriesInterval( ); } + const defaultInterval = + DEFAULT_INTERVALS.find(([minimum]) => seconds >= minimum)?.[1] ?? "15s"; const selected = interval ?? - DEFAULT_INTERVALS.find(([minimum]) => seconds >= minimum)?.[1] ?? - "15s"; + Object.entries(INTERVAL_SECONDS).find( + ([, duration]) => + duration >= INTERVAL_SECONDS[defaultInterval] && + Math.ceil(seconds / duration) <= MAX_BUCKETS, + )?.[0] ?? + "1d"; const intervalSeconds = INTERVAL_SECONDS[parseTimeSeriesInterval(selected)]; if (intervalSeconds > seconds) { throw new ValidationError( diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index e5571db7e..7b68638a8 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -31,7 +31,7 @@ function formatUtcTimestamp(timestamp: number): string { return new Date(timestamp) .toISOString() .replace("T", " ") - .replace(".000Z", ""); + .replace(/\.\d{3}Z$/, ""); } /** Explain why a bucket is incomplete without describing retained data as pending. */ diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index b2eb41c4d..bae36e469 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -749,7 +749,8 @@ describe("sentry explore", () => { }, ); - test("keeps legacy transaction filtering in temporal mode", async () => { + test("keeps transaction, project, and environment scope on every temporal OR branch", async () => { + resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "web" }); const { context } = createContext(); await func.call( @@ -757,10 +758,11 @@ describe("sentry explore", () => { { ...TIME_SERIES_FLAGS, dataset: "transactions", - query: "span.op:http.server", + environment: ["production"], + query: "is_transaction:true OR span.op:http.server", field: ["p95(span.duration)"], }, - "test-org/", + "test-org/web", ); expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( @@ -768,11 +770,36 @@ describe("sentry explore", () => { expect.objectContaining({ fields: ["p95(span.duration)"], dataset: "spans", - query: "span.op:http.server is_transaction:true", + query: + "project:web environment:production is_transaction:true (is_transaction:true OR span.op:http.server)", }), ); }); + test("explains a rejected temporal field without retrying or hiding the backend detail", async () => { + const detail = + "span is invalid for parameter 1 in count. Its a string type field, but it must be one of these types: {'number', 'duration'}"; + queryEventsTimeSeriesSpy.mockRejectedValue( + new ApiError("Bad request", 400, detail), + ); + + const result = await runExploreArgs([ + "test-org/", + "--time-series", + "--dataset", + "spans", + "-F", + "count(span)", + ]); + + expect(result.exitCode).not.toBe(0); + expect(result.getStderr()).toContain(detail); + expect(result.getStderr()).toContain("Use count() to count spans"); + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledTimes(1); + expect(advancePaginationStateSpy).not.toHaveBeenCalled(); + expect(result.getStdout()).toBe(""); + }); + test("resolves --metric with the requested aggregation and time window", async () => { resolveTargetSpy.mockResolvedValue({ org: "test-org", project: "web" }); const { context } = createContext(); @@ -804,7 +831,8 @@ describe("sentry explore", () => { }); test("shows the public metrics name while querying raw aggregates without discovery", async () => { - const aggregate = "sum(value,llm.token_usage,distribution,none)"; + const aggregate = + "count_if(`release:example`,value,request_duration,distribution,none)"; const result = await runExploreArgs([ "test-org/", "--time-series", diff --git a/packages/cli/test/e2e/explore.test.ts b/packages/cli/test/e2e/explore.test.ts index 4d9e4ec3a..323c462e7 100644 --- a/packages/cli/test/e2e/explore.test.ts +++ b/packages/cli/test/e2e/explore.test.ts @@ -89,7 +89,7 @@ test("queries repeated aggregates and preserves metadata when projecting buckets "--environment", "production,canary", "--query", - "span.op:http.server", + "span.op:http.server OR span.status:internal_error", "--json", ]; const result = await ctx.run(args); @@ -102,7 +102,7 @@ test("queries repeated aggregates and preserves metadata when projecting buckets dataset: "spans", yAxis: "latest_event()", interval: "1m", - query: `project:${TEST_PROJECT} environment:[production,canary] span.op:http.server`, + query: `project:${TEST_PROJECT} environment:[production,canary] (span.op:http.server OR span.status:internal_error)`, start: new Date(start).toISOString(), end: new Date(end).toISOString(), }); diff --git a/packages/cli/test/lib/explore-timeseries.property.test.ts b/packages/cli/test/lib/explore-timeseries.property.test.ts index 5349ea33a..c7fca5e50 100644 --- a/packages/cli/test/lib/explore-timeseries.property.test.ts +++ b/packages/cli/test/lib/explore-timeseries.property.test.ts @@ -7,9 +7,9 @@ import { timeRangeToSeconds } from "../../src/lib/time-range.js"; import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; describe("Explore time series intervals", () => { - test("automatic buckets fit the window and query budget for either date syntax", () => { + test("automatic buckets fit windows through the supported maximum for either date syntax", () => { fcAssert( - property(integer({ min: 15, max: 90 * 86_400 }), (seconds) => { + property(integer({ min: 15, max: 10_100 * 86_400 }), (seconds) => { const start = Date.parse("2026-01-01T00:00:00Z"); const relative = resolveTimeSeriesInterval({ statsPeriod: `${seconds}s`, @@ -27,7 +27,10 @@ describe("Explore time series intervals", () => { expect(bucketSeconds).toBeLessThanOrEqual(seconds); expect(Math.ceil(seconds / bucketSeconds)).toBeLessThanOrEqual(10_100); }), - { numRuns: DEFAULT_NUM_RUNS }, + { + numRuns: DEFAULT_NUM_RUNS, + examples: [[10_100 * 10_800], [10_100 * 10_800 + 1], [10_100 * 86_400]], + }, ); }); @@ -47,4 +50,10 @@ describe("Explore time series intervals", () => { resolveTimeSeriesInterval({ statsPeriod: "90d" }, "15s"), ).toThrow("too many time buckets"); }); + + test("rejects windows beyond the budget of the largest supported interval", () => { + expect(() => + resolveTimeSeriesInterval({ statsPeriod: `${10_100 * 86_400 + 1}s` }), + ).toThrow("too many time buckets"); + }); }); diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index 4ad86d6ef..e4a79cb73 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -12,8 +12,8 @@ function makeData(): ExploreTimeSeriesData { dataset: "spans", meta: { dataset: "spans", - start: Date.UTC(2026, 0, 1), - end: Date.UTC(2026, 0, 1, 0, 15), + start: Date.UTC(2026, 0, 1) + 123, + end: Date.UTC(2026, 0, 1, 0, 15) + 456, ingestion: { status: "healthy", delaySeconds: 288, From 4f4ef204b39fac56fa612b8b3c3194c2e631a835 Mon Sep 17 00:00:00 2001 From: betegon Date: Mon, 26 Oct 2026 19:50:30 +0000 Subject: [PATCH 4/5] refactor(cli): unify Explore time series flags --- .../src/fragments/commands/explore.md | 8 ++- .../skills/sentry-cli/references/explore.md | 9 ++- packages/cli/src/commands/explore.ts | 52 +++++++--------- packages/cli/src/lib/explore-timeseries.ts | 12 ++-- packages/cli/test/commands/explore.test.ts | 61 +++++++++++++------ packages/cli/test/e2e/explore.test.ts | 23 ++++--- packages/cli/test/lib/sdk-flag-types.test.ts | 4 +- 7 files changed, 92 insertions(+), 77 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index 5ba58f8b6..6d82f3a8d 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -2,14 +2,16 @@ ## Time series -Use `--time-series` to see how an aggregate changes over time. The target, +Use `--time-series auto` (`-b auto`) to see how an aggregate changes over time. The target, `--query`, `--environment`, and `--period` filters work as they do for tabular queries. Without `--field`, temporal queries count matching events in each time bucket. For performance analysis, select aggregates such as `p95(span.duration)`; repeat `--field` to request more than one series. -The CLI chooses a bucket interval from the requested time range. Set `--interval` -to choose an explicit interval, such as `15m`. This flag requires `--time-series`. +With `auto`, the CLI chooses a bucket interval from the requested time range. +Pass a duration, such as `--time-series 15m` or `-b 15m`, to set the bucket width. +Use `--period` (`-t`) to set the overall time range. Each invocation returns a +snapshot and exits; it does not follow live data. Temporal queries support errors, spans, logs, and metrics. Metrics require `--metric` or an explicit aggregate in the raw tracemetrics format described 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 382f9df9a..c8fbf1bcf 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 @@ -30,8 +30,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 table rows (1-1000, default: 25)` -- `--time-series - Calculate aggregates in time buckets (one query, not a live stream)` -- `--interval - Bucket duration for --time-series (e.g., 1m, 15m, 1h; default: automatic)` +- `-b, --time-series - Query time series with automatic or explicit buckets (auto, 1m, 15m, 1h)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-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)` @@ -70,13 +69,13 @@ sentry explore my-org/cli -F span.op -F "p50(span.duration)" -F "p95(span.durati sentry explore my-org/cli -F span.op -F "count()" --dataset spans --sort "-count()" # Error counts in 15-minute buckets over the last hour -sentry explore my-org/cli --time-series --period 1h --interval 15m +sentry explore my-org/cli --time-series 15m --period 1h # Latency over time with automatic bucket duration, as JSON -sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series --period 24h --json +sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series auto --period 24h --json # A metric over time using its discovered type and unit -sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg --time-series --period 1h +sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b auto -t 1h # Sum a custom metric (e.g., LLM token usage) across an organization sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index da8f4840a..f60157164 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -153,8 +153,8 @@ type ExploreFlags = { readonly sort?: string; readonly period: TimeRange; readonly limit?: number; - readonly "time-series"?: boolean; - readonly interval?: string; + /** Bucket duration or "auto"; omitted for tabular output. */ + readonly "time-series"?: string; readonly cursor?: string; readonly json: boolean; readonly fresh: boolean; @@ -527,19 +527,13 @@ function resolveEventsQuery( /** Reject table-only flags and dimensions before a temporal query is sent. */ function validateTimeSeriesFlags(flags: ExploreFlags): void { - if (!flags["time-series"]) { - if (flags.interval !== undefined) { - throw new ValidationError( - "--interval requires --time-series.", - "interval", - ); - } + if (flags["time-series"] === undefined) { return; } for (const flag of ["cursor", "limit", "sort"] as const) { if (flags[flag] !== undefined) { throw new ValidationError( - `--${flag} is only available for tables. Time series return all buckets in chronological order; use --period and --interval to control the range and granularity.`, + `--${flag} is only available for tables. Time series return all buckets in chronological order; use --period and --time-series to control the range and granularity.`, flag, ); } @@ -691,8 +685,8 @@ export const exploreCommand = buildListCommand("explore", { "Supports arbitrary fields including columns (title, project),\n" + "aggregates (count(), count_unique(user), p50(span.duration)),\n" + "and equations. Results are returned as a table.\n\n" + - "Use --time-series to calculate aggregates in time buckets. " + - "--interval sets the bucket duration; otherwise it is selected automatically. " + + "Use --time-series auto to calculate aggregates in time buckets with an automatic interval, " + + "or pass a bucket duration such as --time-series 15m (-b 15m). " + "This is a single historical query, not a live stream. " + "Grouping, --sort, --limit, and --cursor are table-only.\n\n" + "Datasets:\n" + @@ -754,19 +748,18 @@ export const exploreCommand = buildListCommand("explore", { }, { description: "Error counts in 15-minute buckets over the last hour", - command: - "sentry explore my-org/cli --time-series --period 1h --interval 15m", + command: "sentry explore my-org/cli --time-series 15m --period 1h", }, { description: "Latency over time with automatic bucket duration, as JSON", command: - 'sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series --period 24h --json', + 'sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series auto --period 24h --json', }, { description: "A metric over time using its discovered type and unit", command: - "sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg --time-series --period 1h", + "sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b auto -t 1h", }, { description: @@ -877,16 +870,12 @@ export const exploreCommand = buildListCommand("explore", { optional: true, }, "time-series": { - kind: "boolean", - brief: - "Calculate aggregates in time buckets (one query, not a live stream)", - default: false, - }, - interval: { kind: "parsed", - parse: parseTimeSeriesInterval, + parse: (value: string) => + value === "auto" ? value : parseTimeSeriesInterval(value), + placeholder: "auto|interval", brief: - "Bucket duration for --time-series (e.g., 1m, 15m, 1h; default: automatic)", + "Query time series with automatic or explicit buckets (auto, 1m, 15m, 1h)", optional: true, }, period: { @@ -905,6 +894,7 @@ export const exploreCommand = buildListCommand("explore", { q: "query", s: "sort", n: "limit", + b: "time-series", }, }, async *func( @@ -917,9 +907,10 @@ export const exploreCommand = buildListCommand("explore", { let dataset = flags.dataset; const userSuppliedFields = flags.field && flags.field.length > 0; - let fieldList = flags["time-series"] - ? ["count()"] - : [...defaultFieldsForDataset(dataset)]; + let fieldList = + flags["time-series"] !== undefined + ? ["count()"] + : [...defaultFieldsForDataset(dataset)]; if (userSuppliedFields) { fieldList = flags.field; } @@ -972,7 +963,7 @@ export const exploreCommand = buildListCommand("explore", { validateMetricsFields(fieldList); } - if (flags["time-series"]) { + if (flags["time-series"] !== undefined) { const resolved = resolveEventsQuery( dataset, flags.query, @@ -980,7 +971,10 @@ export const exploreCommand = buildListCommand("explore", { environment, ); const timeParams = timeRangeToApiParams(timeRange); - const interval = resolveTimeSeriesInterval(timeParams, flags.interval); + const interval = resolveTimeSeriesInterval( + timeParams, + flags["time-series"] === "auto" ? undefined : flags["time-series"], + ); const response = await withProgress( { message: `Querying ${resolved.dataset} time series in ${project ? `${org}/${project}` : org}...`, diff --git a/packages/cli/src/lib/explore-timeseries.ts b/packages/cli/src/lib/explore-timeseries.ts index 8e4b8fbd8..a73b0ae20 100644 --- a/packages/cli/src/lib/explore-timeseries.ts +++ b/packages/cli/src/lib/explore-timeseries.ts @@ -46,8 +46,8 @@ export function parseTimeSeriesInterval( ): keyof typeof INTERVAL_SECONDS { if (!Object.hasOwn(INTERVAL_SECONDS, value)) { throw new ValidationError( - `Invalid interval "${value}". Use one of: ${Object.keys(INTERVAL_SECONDS).join(", ")}.`, - "interval", + `Invalid interval "${value}". Use auto or one of: ${Object.keys(INTERVAL_SECONDS).join(", ")}.`, + "time-series", ); } return value as keyof typeof INTERVAL_SECONDS; @@ -87,14 +87,14 @@ export function resolveTimeSeriesInterval( const intervalSeconds = INTERVAL_SECONDS[parseTimeSeriesInterval(selected)]; if (intervalSeconds > seconds) { throw new ValidationError( - `Interval ${selected} is longer than the requested period. Use a smaller --interval or a wider --period.`, - "interval", + `Interval ${selected} is longer than the requested period. Use a smaller --time-series interval or a wider --period.`, + "time-series", ); } if (Math.ceil(seconds / intervalSeconds) > MAX_BUCKETS) { throw new ValidationError( - `Interval ${selected} produces too many time buckets. Use a larger --interval or a shorter --period.`, - "interval", + `Interval ${selected} produces too many time buckets. Use a larger --time-series interval or a shorter --period.`, + "time-series", ); } return selected; diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index bae36e469..e17819361 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -241,7 +241,7 @@ const DEFAULT_FLAGS = { const TIME_SERIES_FLAGS = { ...DEFAULT_FLAGS, limit: undefined, - "time-series": true, + "time-series": "auto", }; async function runExploreArgs(args: string[]) { @@ -690,10 +690,11 @@ describe("sentry explore", () => { }); describe("time series", () => { - test("parses --time-series and chooses a usable interval for a five-minute window", async () => { + test("parses --time-series auto and chooses a usable interval for a five-minute window", async () => { const result = await runExploreArgs([ "test-org/", "--time-series", + "auto", "--period", "5m", "--json", @@ -720,30 +721,36 @@ describe("sentry explore", () => { }); test.each([ - { period: "1h", timeParams: { statsPeriod: "1h" } }, { + timeSeries: "auto", + period: "1h", + interval: "1m", + timeParams: { statsPeriod: "1h" }, + }, + { + timeSeries: "15m", period: "2025-01-01T00:00:00Z..2025-01-01T01:00:00Z", + interval: "15m", timeParams: { start: "2025-01-01T00:00:00.000Z", end: "2025-01-01T01:00:00.000Z", }, }, ])( - "accepts the SDK's string period $period", - async ({ period, timeParams }) => { + "maps SDK timeSeries $timeSeries with period $period to API parameters", + async ({ timeSeries, period, interval, timeParams }) => { const sdk = createSentrySDK({ cwd: "/tmp/test-explore" }); await sdk.explore({ target: "test-org/", - timeSeries: true, + timeSeries, period, - interval: "15m", }); expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith("test-org", { fields: ["count()"], dataset: "errors", query: undefined, - interval: "15m", + interval, ...timeParams, }); }, @@ -786,6 +793,7 @@ describe("sentry explore", () => { const result = await runExploreArgs([ "test-org/", "--time-series", + "auto", "--dataset", "spans", "-F", @@ -836,6 +844,7 @@ describe("sentry explore", () => { const result = await runExploreArgs([ "test-org/", "--time-series", + "auto", "--dataset", "metrics", "-F", @@ -858,49 +867,61 @@ describe("sentry explore", () => { test.each([ { - name: "interval without temporal mode", + name: "removed interval flag", args: ["--interval", "15m"], - error: "--interval requires --time-series", + error: "No flag registered for --interval", + }, + { + name: "missing time-series value", + args: ["--time-series"], + error: "Expected input for flag --time-series", }, { name: "non-aggregate fields", - args: ["--time-series", "-F", "project", "-F", "count()"], + args: ["--time-series", "auto", "-F", "project", "-F", "count()"], error: "Grouping is not supported: project", }, { name: "replays", - args: ["--time-series", "--dataset", "replays"], + args: ["--time-series", "auto", "--dataset", "replays"], error: "The replays dataset is only available as a table", }, { name: "cursor", - args: ["--time-series", "--cursor", "next"], + args: ["--time-series", "auto", "--cursor", "next"], error: "--cursor is only available for tables", }, { name: "sort", - args: ["--time-series", "--sort", "-count()"], + args: ["--time-series", "auto", "--sort", "-count()"], error: "--sort is only available for tables", }, { name: "explicit limit", - args: ["--time-series", "--limit", "25"], + args: ["--time-series", "auto", "--limit", "25"], error: "--limit is only available for tables", }, { - name: "invalid interval", - args: ["--time-series", "--interval", "bad"], - error: 'Invalid interval "bad"', + name: "unsupported time-series interval", + args: ["--time-series", "20s"], + error: 'Invalid interval "20s"', }, { name: "metrics without an aggregate", - args: ["--time-series", "--dataset", "metrics"], + args: ["--time-series", "auto", "--dataset", "metrics"], error: "The metrics dataset requires --metric or explicit --field flags", }, { name: "metric grouping fields", - args: ["--time-series", "--metric", "llm.token_usage", "-F", "project"], + args: [ + "--time-series", + "auto", + "--metric", + "llm.token_usage", + "-F", + "project", + ], error: "Grouping is not supported: project", }, ])("rejects $name before querying the API", async ({ args, error }) => { diff --git a/packages/cli/test/e2e/explore.test.ts b/packages/cli/test/e2e/explore.test.ts index 323c462e7..b0bd055fc 100644 --- a/packages/cli/test/e2e/explore.test.ts +++ b/packages/cli/test/e2e/explore.test.ts @@ -9,7 +9,7 @@ import { createMockServer } from "../mocks/server.js"; const getConfigDir = useTestConfigDir("e2e-explore-"); const start = Date.UTC(2026, 0, 1); -const end = start + 120_000; +const end = start + 240_000; /** Minimal sanitized timeseries response with numeric, missing, and string values. */ const response = { @@ -25,13 +25,13 @@ const response = { meta: { valueType: "integer", valueUnit: null, - interval: 60_000, + interval: 120_000, dataScanned: "partial", }, values: [ { timestamp: start, value: 0, incomplete: false }, { - timestamp: start + 60_000, + timestamp: start + 120_000, value: null, incomplete: true, incompleteReason: "INGESTION_PENDING", @@ -42,13 +42,13 @@ const response = { }, { yAxis: "latest_event()", - meta: { valueType: "string", valueUnit: null, interval: 60_000 }, + meta: { valueType: "string", valueUnit: null, interval: 120_000 }, values: [{ timestamp: start, value: "00123", incomplete: false }], }, ], }; -test("queries repeated aggregates and preserves metadata when projecting buckets", async () => { +test("combines -b buckets with -t period and preserves metadata when projecting repeated aggregates", async () => { const requests: URL[] = []; const server = createMockServer( [ @@ -75,17 +75,16 @@ test("queries repeated aggregates and preserves metadata when projecting buckets const args = [ "explore", `${TEST_ORG}/${TEST_PROJECT}`, - "--time-series", + "-b", + "2m", "--dataset", "spans", "-F", "count()", "-F", "latest_event()", - "--interval", - "1m", - "--period", - "2026-01-01T00:00:00Z..2026-01-01T00:02:00Z", + "-t", + "2026-01-01T00:00:00Z..2026-01-01T00:04:00Z", "--environment", "production,canary", "--query", @@ -101,7 +100,7 @@ test("queries repeated aggregates and preserves metadata when projecting buckets expect(Object.fromEntries(params)).toEqual({ dataset: "spans", yAxis: "latest_event()", - interval: "1m", + interval: "2m", query: `project:${TEST_PROJECT} environment:[production,canary] (span.op:http.server OR span.status:internal_error)`, start: new Date(start).toISOString(), end: new Date(end).toISOString(), @@ -122,7 +121,7 @@ test("queries repeated aggregates and preserves metadata when projecting buckets ...response.timeSeries[0], values: [ { timestamp: start, value: 0 }, - { timestamp: start + 60_000, value: null }, + { timestamp: start + 120_000, value: null }, ], }, { diff --git a/packages/cli/test/lib/sdk-flag-types.test.ts b/packages/cli/test/lib/sdk-flag-types.test.ts index e63b840f5..fafd5b9ab 100644 --- a/packages/cli/test/lib/sdk-flag-types.test.ts +++ b/packages/cli/test/lib/sdk-flag-types.test.ts @@ -28,8 +28,8 @@ describe("generated SDK flag types", () => { test("keeps optional Explore limits numeric alongside temporal flags", () => { const explore = paramsType("ExploreParams"); expect(explore).toContain("limit?: number;"); - expect(explore).toContain("timeSeries?: boolean;"); - expect(explore).toContain("interval?: string;"); + expect(explore).toContain("timeSeries?: string;"); + expect(explore).not.toContain("interval?:"); }); test("declares variadic flags as arrays and keeps scalar flags scalar", () => { From 0ddcda71280d481cd7772a32aaba8c66d5a05cc8 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 27 Oct 2026 18:13:56 +0000 Subject: [PATCH 5/5] fix(cli): default bare time series flags to auto Move UTC timestamp formatting to the shared time utilities without changing output. --- .../src/fragments/commands/explore.md | 5 +++-- .../skills/sentry-cli/references/explore.md | 6 ++--- packages/cli/src/commands/explore.ts | 13 ++++++----- .../src/lib/formatters/explore-timeseries.ts | 9 +------- packages/cli/src/lib/formatters/time-utils.ts | 11 ++++++++++ packages/cli/test/commands/explore.test.ts | 22 +++++++------------ .../lib/formatters/explore-timeseries.test.ts | 1 + 7 files changed, 35 insertions(+), 32 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index 6d82f3a8d..b8d6bd1f7 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -2,14 +2,15 @@ ## Time series -Use `--time-series auto` (`-b auto`) to see how an aggregate changes over time. The target, +Use `--time-series` (`-b`) to see how an aggregate changes over time. The target, `--query`, `--environment`, and `--period` filters work as they do for tabular queries. Without `--field`, temporal queries count matching events in each time bucket. For performance analysis, select aggregates such as `p95(span.duration)`; repeat `--field` to request more than one series. -With `auto`, the CLI chooses a bucket interval from the requested time range. +With no value or `auto`, the CLI chooses a bucket interval from the requested time range. Pass a duration, such as `--time-series 15m` or `-b 15m`, to set the bucket width. +When omitting the value, place an explicit target before the flag. Use `--period` (`-t`) to set the overall time range. Each invocation returns a snapshot and exits; it does not follow live data. 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 c8fbf1bcf..7377ba722 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 @@ -30,7 +30,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 table rows (1-1000, default: 25)` -- `-b, --time-series - Query time series with automatic or explicit buckets (auto, 1m, 15m, 1h)` +- `-b, --time-series - Query time series (default: auto; optional bucket duration, e.g., 15m)` - `-t, --period - Time range: "7d", "2024-01-01..2024-02-01", ">=2024-01-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)` @@ -72,10 +72,10 @@ sentry explore my-org/cli -F span.op -F "count()" --dataset spans --sort "-count sentry explore my-org/cli --time-series 15m --period 1h # Latency over time with automatic bucket duration, as JSON -sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series auto --period 24h --json +sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series --period 24h --json # A metric over time using its discovered type and unit -sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b auto -t 1h +sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b -t 1h # Sum a custom metric (e.g., LLM token usage) across an organization sentry explore my-org/ -m llm.token_usage --dataset metrics --period 7d diff --git a/packages/cli/src/commands/explore.ts b/packages/cli/src/commands/explore.ts index f60157164..50329e92c 100644 --- a/packages/cli/src/commands/explore.ts +++ b/packages/cli/src/commands/explore.ts @@ -685,7 +685,7 @@ export const exploreCommand = buildListCommand("explore", { "Supports arbitrary fields including columns (title, project),\n" + "aggregates (count(), count_unique(user), p50(span.duration)),\n" + "and equations. Results are returned as a table.\n\n" + - "Use --time-series auto to calculate aggregates in time buckets with an automatic interval, " + + "Use --time-series to calculate aggregates in time buckets with an automatic interval, " + "or pass a bucket duration such as --time-series 15m (-b 15m). " + "This is a single historical query, not a live stream. " + "Grouping, --sort, --limit, and --cursor are table-only.\n\n" + @@ -754,12 +754,12 @@ export const exploreCommand = buildListCommand("explore", { description: "Latency over time with automatic bucket duration, as JSON", command: - 'sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series auto --period 24h --json', + 'sentry explore my-org/cli --dataset spans -F "p95(span.duration)" --time-series --period 24h --json', }, { description: "A metric over time using its discovered type and unit", command: - "sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b auto -t 1h", + "sentry explore my-org/cli --dataset metrics -m cache.hit_rate --agg avg -b -t 1h", }, { description: @@ -872,11 +872,14 @@ export const exploreCommand = buildListCommand("explore", { "time-series": { kind: "parsed", parse: (value: string) => - value === "auto" ? value : parseTimeSeriesInterval(value), + value === "" || value === "auto" + ? "auto" + : parseTimeSeriesInterval(value), placeholder: "auto|interval", brief: - "Query time series with automatic or explicit buckets (auto, 1m, 15m, 1h)", + "Query time series (default: auto; optional bucket duration, e.g., 15m)", optional: true, + inferEmpty: true, }, period: { kind: "parsed" as const, diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index 7b68638a8..ab8df9411 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -11,6 +11,7 @@ import { formatTable } from "./table.js"; import { formatDurationCompact, formatDurationCompactMs, + formatUtcTimestamp, } from "./time-utils.js"; /** An API response with the query scope used by human output. */ @@ -26,14 +27,6 @@ export type ExploreTimeSeriesData = EventsTimeSeriesResponse & { type TimeSeries = EventsTimeSeriesResponse["timeSeries"][number]; type Bucket = TimeSeries["values"][number]; -/** Format an epoch-millisecond timestamp without depending on local timezone. */ -function formatUtcTimestamp(timestamp: number): string { - return new Date(timestamp) - .toISOString() - .replace("T", " ") - .replace(/\.\d{3}Z$/, ""); -} - /** Explain why a bucket is incomplete without describing retained data as pending. */ function formatBucketStatus(bucket: Bucket): string { if (!bucket.incomplete) { diff --git a/packages/cli/src/lib/formatters/time-utils.ts b/packages/cli/src/lib/formatters/time-utils.ts index b06954306..d87d58a38 100644 --- a/packages/cli/src/lib/formatters/time-utils.ts +++ b/packages/cli/src/lib/formatters/time-utils.ts @@ -11,6 +11,17 @@ import type { TraceSpan } from "../../types/index.js"; import { colorTag } from "./markdown.js"; +/** + * Format an epoch-millisecond timestamp as YYYY-MM-DD HH:mm:ss in UTC. + * Omits fractional seconds and never depends on the machine's local timezone. + */ +export function formatUtcTimestamp(timestamp: number): string { + return new Date(timestamp) + .toISOString() + .replace("T", " ") + .replace(/\.\d{3}Z$/, ""); +} + /** * Format a date string as a relative time label. * diff --git a/packages/cli/test/commands/explore.test.ts b/packages/cli/test/commands/explore.test.ts index e17819361..a742a83f1 100644 --- a/packages/cli/test/commands/explore.test.ts +++ b/packages/cli/test/commands/explore.test.ts @@ -690,15 +690,14 @@ describe("sentry explore", () => { }); describe("time series", () => { - test("parses --time-series auto and chooses a usable interval for a five-minute window", async () => { - const result = await runExploreArgs([ - "test-org/", - "--time-series", - "auto", - "--period", - "5m", - "--json", - ]); + test.each([ + { + name: "--time-series before another flag", + args: ["--time-series", "--period", "5m"], + }, + { name: "-b at the end", args: ["--period", "5m", "-b"] }, + ])("uses automatic intervals with bare $name", async ({ args }) => { + const result = await runExploreArgs(["test-org/", "--json", ...args]); expect(result.exitCode).toBe(0); expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( @@ -871,11 +870,6 @@ describe("sentry explore", () => { args: ["--interval", "15m"], error: "No flag registered for --interval", }, - { - name: "missing time-series value", - args: ["--time-series"], - error: "Expected input for flag --time-series", - }, { name: "non-aggregate fields", args: ["--time-series", "auto", "-F", "project", "-F", "count()"], diff --git a/packages/cli/test/lib/formatters/explore-timeseries.test.ts b/packages/cli/test/lib/formatters/explore-timeseries.test.ts index e4a79cb73..b67c49eb6 100644 --- a/packages/cli/test/lib/formatters/explore-timeseries.test.ts +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -71,6 +71,7 @@ afterEach(() => { describe("formatExploreTimeSeriesHuman", () => { test("renders UTC buckets, the effective interval, and ingestion completeness", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); const output = formatExploreTimeSeriesHuman(makeData()); expect(output).toContain("Querying spans in test-org/test-project");