diff --git a/apps/cli-docs/src/fragments/commands/explore.md b/apps/cli-docs/src/fragments/commands/explore.md index 6dd07cda6..b8d6bd1f7 100644 --- a/apps/cli-docs/src/fragments/commands/explore.md +++ b/apps/cli-docs/src/fragments/commands/explore.md @@ -1,5 +1,31 @@ +## Time series + +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 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. + +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..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 @@ -29,7 +29,8 @@ 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)` +- `-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)` @@ -67,6 +68,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 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 + +# A metric over time using its discovered type and unit +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/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..50329e92c 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; + /** Bucket duration or "auto"; omitted for tabular output. */ + readonly "time-series"?: string; readonly cursor?: string; readonly json: boolean; readonly fresh: boolean; @@ -264,7 +276,11 @@ function orderFieldNames( } /** Format explore results for human-readable terminal output */ -function formatExploreHuman(data: ExploreData): string { +function formatExploreHuman(data: ExploreData | ExploreTimeSeriesData): string { + const displayDataset = API_TO_USER_DATASET.get(data.dataset) ?? data.dataset; + if ("timeSeries" in data) { + return formatExploreTimeSeriesHuman({ ...data, dataset: displayDataset }); + } if (data.data.length === 0) { // Empty page mid-pagination (more or prev pages exist) vs. truly no results. return data.hasMore || data.hasPrev @@ -280,13 +296,18 @@ function formatExploreHuman(data: ExploreData): 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); } /** 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)) @@ -313,7 +334,7 @@ function jsonTransformExplore(data: ExploreData, fields?: string[]): unknown { // 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[] { @@ -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,64 @@ 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"] === 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 --time-series 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 +574,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 +618,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 +633,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 +648,8 @@ 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 +685,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 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" + " errors Error events (default)\n" + " spans Span data\n" + @@ -675,6 +746,21 @@ 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 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', + }, + { + 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 -t 1h", + }, { description: "Sum a custom metric (e.g., LLM token usage) across an organization", @@ -779,8 +865,21 @@ 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: ${DEFAULT_LIMIT})`, + optional: true, + }, + "time-series": { + kind: "parsed", + parse: (value: string) => + value === "" || value === "auto" + ? "auto" + : parseTimeSeriesInterval(value), + placeholder: "auto|interval", + brief: + "Query time series (default: auto; optional bucket duration, e.g., 15m)", + optional: true, + inferEmpty: true, }, period: { kind: "parsed" as const, @@ -798,6 +897,7 @@ export const exploreCommand = buildListCommand("explore", { q: "query", s: "sort", n: "limit", + b: "time-series", }, }, async *func( @@ -805,15 +905,23 @@ 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"] !== undefined + ? ["count()"] + : [...defaultFieldsForDataset(dataset)]; 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 @@ -858,6 +966,53 @@ export const exploreCommand = buildListCommand("explore", { validateMetricsFields(fieldList); } + if (flags["time-series"] !== undefined) { + const resolved = resolveEventsQuery( + dataset, + flags.query, + project, + environment, + ); + const timeParams = timeRangeToApiParams(timeRange); + 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}...`, + json: flags.json, + }, + () => + queryEventsTimeSeries(org, { + fields: fieldList, + dataset: resolved.dataset, + query: resolved.query, + interval, + ...timeParams, + }).catch((error: unknown): never => { + 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({ + ...response, + dataset: resolved.dataset, + org, + project, + }); + return; + } + const config = resolveDatasetConfig({ dataset, fieldList, @@ -892,7 +1047,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); @@ -904,7 +1059,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/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..a73b0ae20 --- /dev/null +++ b/packages/cli/src/lib/explore-timeseries.ts @@ -0,0 +1,101 @@ +/** + * 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 intervals in increasing duration order, with 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, keyof typeof INTERVAL_SECONDS][] = [ + [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 auto or one of: ${Object.keys(INTERVAL_SECONDS).join(", ")}.`, + "time-series", + ); + } + 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 defaultInterval = + DEFAULT_INTERVALS.find(([minimum]) => seconds >= minimum)?.[1] ?? "15s"; + const selected = + interval ?? + 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( + `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 --time-series interval or a shorter --period.`, + "time-series", + ); + } + 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..ab8df9411 --- /dev/null +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -0,0 +1,167 @@ +/** + * 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 { + formatDurationCompact, + formatDurationCompactMs, + formatUtcTimestamp, +} 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]; + +/** 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: ${formatDurationCompact(ingestion.delaySeconds)}.`, + ); + } + } + + 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/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 cbfe37435..a742a83f1 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 = @@ -80,6 +81,7 @@ let func: ExploreFunc; function createContext() { const stdoutChunks: string[] = []; + const stderrChunks: string[] = []; return { context: { stdout: { @@ -88,14 +90,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 +116,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 +171,7 @@ const MOCK_REPLAYS_RESPONSE = [ ]; let queryEventsSpy: ReturnType; +let queryEventsTimeSeriesSpy: ReturnType; let queryMetricsMetaSpy: ReturnType; let listReplaysSpy: ReturnType; let resolveTargetSpy: ReturnType; @@ -149,6 +192,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 +220,7 @@ beforeEach(async () => { afterEach(() => { queryEventsSpy.mockRestore(); + queryEventsTimeSeriesSpy.mockRestore(); queryMetricsMetaSpy.mockRestore(); listReplaysSpy.mockRestore(); resolveTargetSpy.mockRestore(); @@ -192,6 +238,31 @@ const DEFAULT_FLAGS = { fresh: false, }; +const TIME_SERIES_FLAGS = { + ...DEFAULT_FLAGS, + limit: undefined, + "time-series": "auto", +}; + +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 +689,267 @@ describe("sentry explore", () => { }); }); + describe("time series", () => { + 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( + "test-org", + expect.objectContaining({ + fields: ["count()"], + dataset: "errors", + statsPeriod: "5m", + interval: "1m", + }), + ); + 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.each([ + { + 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", + }, + }, + ])( + "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, + period, + }); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith("test-org", { + fields: ["count()"], + dataset: "errors", + query: undefined, + interval, + ...timeParams, + }); + }, + ); + + 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( + context, + { + ...TIME_SERIES_FLAGS, + dataset: "transactions", + environment: ["production"], + query: "is_transaction:true OR span.op:http.server", + field: ["p95(span.duration)"], + }, + "test-org/web", + ); + + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: ["p95(span.duration)"], + dataset: "spans", + 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", + "auto", + "--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(); + + 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("shows the public metrics name while querying raw aggregates without discovery", async () => { + const aggregate = + "count_if(`release:example`,value,request_duration,distribution,none)"; + const result = await runExploreArgs([ + "test-org/", + "--time-series", + "auto", + "--dataset", + "metrics", + "-F", + aggregate, + ]); + + expect(result.exitCode).toBe(0); + expect(queryEventsTimeSeriesSpy).toHaveBeenCalledWith( + "test-org", + expect.objectContaining({ + fields: [aggregate], + dataset: "tracemetrics", + }), + ); + expect(queryMetricsMetaSpy).not.toHaveBeenCalled(); + expect(result.getStdout()).toContain("Querying metrics in test-org"); + expect(result.getStdout()).not.toContain("tracemetrics"); + expect(result.getStdout()).not.toContain("Next:"); + }); + + test.each([ + { + name: "removed interval flag", + args: ["--interval", "15m"], + error: "No flag registered for --interval", + }, + { + name: "non-aggregate fields", + args: ["--time-series", "auto", "-F", "project", "-F", "count()"], + error: "Grouping is not supported: project", + }, + { + name: "replays", + args: ["--time-series", "auto", "--dataset", "replays"], + error: "The replays dataset is only available as a table", + }, + { + name: "cursor", + args: ["--time-series", "auto", "--cursor", "next"], + error: "--cursor is only available for tables", + }, + { + name: "sort", + args: ["--time-series", "auto", "--sort", "-count()"], + error: "--sort is only available for tables", + }, + { + name: "explicit limit", + args: ["--time-series", "auto", "--limit", "25"], + error: "--limit is only available for tables", + }, + { + name: "unsupported time-series interval", + args: ["--time-series", "20s"], + error: 'Invalid interval "20s"', + }, + { + name: "metrics without an aggregate", + args: ["--time-series", "auto", "--dataset", "metrics"], + error: + "The metrics dataset requires --metric or explicit --field flags", + }, + { + name: "metric grouping fields", + 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 }) => { + const result = await runExploreArgs(["test-org/", ...args]); + + expect(result.exitCode).not.toBe(0); + expect(result.getStderr()).toContain(error); + 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/e2e/explore.test.ts b/packages/cli/test/e2e/explore.test.ts new file mode 100644 index 000000000..b0bd055fc --- /dev/null +++ b/packages/cli/test/e2e/explore.test.ts @@ -0,0 +1,136 @@ +/** 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 + 240_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: 120_000, + dataScanned: "partial", + }, + values: [ + { timestamp: start, value: 0, incomplete: false }, + { + timestamp: start + 120_000, + value: null, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + sampleRate: 0.5, + confidence: "low", + }, + ], + }, + { + yAxis: "latest_event()", + meta: { valueType: "string", valueUnit: null, interval: 120_000 }, + values: [{ timestamp: start, value: "00123", incomplete: false }], + }, + ], +}; + +test("combines -b buckets with -t period and preserves metadata when projecting repeated aggregates", 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}`, + "-b", + "2m", + "--dataset", + "spans", + "-F", + "count()", + "-F", + "latest_event()", + "-t", + "2026-01-01T00:00:00Z..2026-01-01T00:04:00Z", + "--environment", + "production,canary", + "--query", + "span.op:http.server OR span.status:internal_error", + "--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: "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(), + }); + 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 + 120_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 9514c68ca..e18260833 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,196 @@ 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("defaults to errors over 24h with repeated yAxis and string-valued 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()"], + interval: "5m", + query: "", + }); + + expect(result).toEqual(response); + const params = new URL(requests[0]!.url).searchParams; + expect(params.getAll("yAxis")).toEqual([ + "max(timestamp)", + "latest_event()", + ]); + 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: [] }); + + const result = 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", + }); + + 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"); + 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..c7fca5e50 --- /dev/null +++ b/packages/cli/test/lib/explore-timeseries.property.test.ts @@ -0,0 +1,59 @@ +/** 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 windows through the supported maximum for either date syntax", () => { + fcAssert( + 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`, + }); + 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, + examples: [[10_100 * 10_800], [10_100 * 10_800 + 1], [10_100 * 86_400]], + }, + ); + }); + + 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"); + }); + + 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 new file mode 100644 index 000000000..b67c49eb6 --- /dev/null +++ b/packages/cli/test/lib/formatters/explore-timeseries.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type ExploreTimeSeriesData, + formatExploreTimeSeriesHuman, +} 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) + 123, + end: Date.UTC(2026, 0, 1, 0, 15) + 456, + 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", () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + 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"); + }); + + test("renders each measure with its own metadata and distinguishes missing values from zero", () => { + const data = makeData(); + 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"); + }); + + 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", + ); + }); +}); diff --git a/packages/cli/test/lib/sdk-flag-types.test.ts b/packages/cli/test/lib/sdk-flag-types.test.ts index 01b1d2226..fafd5b9ab 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?: string;"); + expect(explore).not.toContain("interval?:"); + }); + test("declares variadic flags as arrays and keeps scalar flags scalar", () => { const login = paramsType("AuthLoginParams"); expect(login).toContain("scope?: Array;");