diff --git a/packages/mcp-core/src/tools/support/search-events/time-series-results.test.ts b/packages/mcp-core/src/tools/support/search-events/time-series-results.test.ts new file mode 100644 index 000000000..94dcdd210 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/time-series-results.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import type { EventsTimeSeriesResponse } from "../../../api-client/schema"; +import { + buildTimeSeriesResult, + validateTimeSeriesResponse, +} from "./time-series-results"; + +const configuration = { + axes: [ + { yAxis: "count()", transformations: ["fill(locf)", "smooth(sma,2)"] }, + { yAxis: "count()", transformations: ["smooth(ema,2)", "fill(zero)"] }, + ], + interval: "1h", +}; + +function makeSeries(): EventsTimeSeriesResponse { + return { + timeSeries: configuration.axes.map(({ yAxis }) => ({ + yAxis, + meta: { interval: 3600000 }, + values: [ + { + timestamp: 1757548800000, + value: 0, + incomplete: false, + filled: false, + smoothed: false, + }, + ], + })), + }; +} + +describe("buildTimeSeriesResult", () => { + it.each([false, true])( + "includes final retention buckets in the maximum (transformed: %s)", + (transformed) => { + const result = buildTimeSeriesResult({ + series: { + timeSeries: [ + { + yAxis: "count()", + meta: { interval: 60000, valueType: "number" }, + values: [ + { timestamp: 1, value: 8, incomplete: false }, + { + timestamp: 2, + value: 12, + incomplete: true, + incompleteReason: "OUTSIDE_RETENTION", + }, + { + timestamp: 3, + value: 20, + incomplete: true, + incompleteReason: "INGESTION_PENDING", + }, + { + timestamp: 4, + value: 30, + incomplete: true, + incompleteReason: "NOT_ELAPSED", + }, + ].map((bucket) => ({ + ...bucket, + filled: false, + smoothed: false, + })), + }, + ], + }, + configuration: { + axes: [ + { + yAxis: "count()", + transformations: transformed ? ["smooth(sma,2)"] : [], + }, + ], + }, + dataset: "spans", + query: "", + inputQuery: "requests", + timeRange: { statsPeriod: "1h" }, + url: "https://example.sentry.io/explore/traces/", + }); + + expect(result.series[0]?.summary).toHaveProperty( + transformed ? "derivedMaximum" : "peak", + { value: 12, timestamp: 2 }, + ); + }, + ); + + it.each([false, true])( + "omits a date maximum when only empty buckets are final (transformed: %s)", + (transformed) => { + const result = buildTimeSeriesResult({ + series: { + timeSeries: [ + { + yAxis: "max(timestamp)", + meta: { interval: 60000, valueType: "date" }, + values: [ + { timestamp: 1, value: 0, incomplete: false }, + { + timestamp: 2, + value: "2026-11-05T10:30:00Z", + incomplete: true, + incompleteReason: "INGESTION_PENDING", + }, + ].map((bucket) => ({ + ...bucket, + filled: false, + smoothed: false, + })), + }, + ], + }, + configuration: { + axes: [ + { + yAxis: "max(timestamp)", + transformations: transformed ? ["smooth(sma,2)"] : [], + }, + ], + }, + dataset: "logs", + query: "severity:error", + inputQuery: "latest error over time", + timeRange: { statsPeriod: "1h" }, + url: "https://example.sentry.io/explore/logs/", + }); + + expect(result.series[0]?.summary).toBeUndefined(); + }, + ); + + it("orders ISO date values by time and preserves the original peak value", () => { + const result = buildTimeSeriesResult({ + series: { + timeSeries: [ + { + yAxis: "max(timestamp)", + meta: { interval: 60000, valueType: "date" }, + values: [ + { timestamp: 1, value: 0, incomplete: false }, + { + timestamp: 2, + value: "2026-11-05T10:30:00Z", + incomplete: false, + }, + { + timestamp: 3, + value: "2026-11-05T10:00:00-01:00", + incomplete: false, + }, + { timestamp: 4, value: "2026-11-05T11:30:00Z", incomplete: true }, + ], + }, + ], + }, + configuration: { axes: [{ yAxis: "max(timestamp)" }] }, + dataset: "logs", + query: "severity:error", + inputQuery: "latest error over time", + timeRange: { statsPeriod: "1h" }, + url: "https://example.sentry.io/explore/logs/", + }); + + expect(result.series[0]?.summary).toEqual({ + peak: { value: "2026-11-05T10:00:00-01:00", timestamp: 3 }, + }); + }); + + it("does not replace a missing value with zero when finding a raw peak", () => { + const result = buildTimeSeriesResult({ + series: { + timeSeries: [ + { + yAxis: "sum(value)", + meta: { interval: 60000, valueType: "number" }, + values: [ + { timestamp: 1, value: null, incomplete: false }, + { timestamp: 2, value: -4, incomplete: false }, + ], + }, + ], + }, + configuration: { axes: [{ yAxis: "sum(value)" }] }, + dataset: "metrics", + query: "metric.name:balance metric.type:gauge", + inputQuery: "balance", + timeRange: { statsPeriod: "1h" }, + url: "https://example.sentry.io/explore/metrics/", + }); + + expect(result.series[0]).toMatchObject({ + summary: { peak: { value: -4, timestamp: 2 } }, + buckets: [ + { timestamp: 1, value: null, incomplete: false }, + { timestamp: 2, value: -4, incomplete: false }, + ], + }); + expect(result.series[0]?.summary).not.toHaveProperty("total"); + }); +}); + +describe("validateTimeSeriesResponse", () => { + it("rejects empty transformed results but accepts an empty raw search", () => { + expect(() => + validateTimeSeriesResponse({ timeSeries: [] }, configuration), + ).toThrow("Empty transformed results cannot verify execution"); + expect( + buildTimeSeriesResult({ + series: { timeSeries: [] }, + configuration: { axes: [{ yAxis: "count()" }] }, + dataset: "logs", + query: "severity:error", + inputQuery: "severity:error", + timeRange: { statsPeriod: "1h" }, + url: "https://example.sentry.io/explore/logs/", + }), + ).toMatchObject({ requestedInterval: null, series: [] }); + }); + + it("rejects grouped or reordered output instead of discarding series", () => { + const grouped = makeSeries(); + grouped.timeSeries[0]!.groupBy = [ + { key: "environment", value: "production" }, + ]; + expect(() => validateTimeSeriesResponse(grouped, configuration)).toThrow( + "Expected one ungrouped series per requested axis", + ); + const reordered = makeSeries(); + reordered.timeSeries[0]!.yAxis = "avg(span.duration)"; + expect(() => validateTimeSeriesResponse(reordered, configuration)).toThrow( + "in the same order", + ); + }); +}); diff --git a/packages/mcp-core/src/tools/support/search-events/time-series-results.ts b/packages/mcp-core/src/tools/support/search-events/time-series-results.ts new file mode 100644 index 000000000..64bc7dcb2 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/time-series-results.ts @@ -0,0 +1,183 @@ +import type { EventsTimeSeriesResponse } from "../../../api-client/schema"; +import { UserInputError } from "../../../errors"; +import type { TimeSeriesConfig } from "../../../utils/time-series"; + +const UNVERIFIED_TRANSFORMATIONS = + "Sentry did not confirm that the requested transformations ran. Enable Explore interpolation and smoothing for this organization and use a deployment that returns transformation flags. Empty transformed results cannot verify execution. Retry with a time range containing data, or explicitly remove the transformations for raw results."; + +const UNSUPPORTED_SERIES_SHAPE = + "Sentry returned an unsupported time-series shape. Expected one ungrouped series per requested axis, in the same order. Remove grouping and retry."; + +export function validateTimeSeriesResponse( + response: EventsTimeSeriesResponse, + configuration: TimeSeriesConfig, +): void { + const hasTransformations = configuration.axes.some( + (axis) => (axis.transformations?.length ?? 0) > 0, + ); + if (response.timeSeries.length === 0) { + if (hasTransformations) { + throw new UserInputError(UNVERIFIED_TRANSFORMATIONS); + } + return; + } + if (response.timeSeries.length !== configuration.axes.length) { + throw new UserInputError(UNSUPPORTED_SERIES_SHAPE); + } + for (const [index, axis] of configuration.axes.entries()) { + const series = response.timeSeries[index]; + if ( + !series || + series.yAxis !== axis.yAxis || + series.meta.isOther === true || + (Array.isArray(series.groupBy) && series.groupBy.length > 0) + ) { + throw new UserInputError(UNSUPPORTED_SERIES_SHAPE); + } + if ((axis.transformations?.length ?? 0) === 0) { + continue; + } + if ( + series.values.length === 0 || + series.values.some( + (bucket) => + typeof bucket.filled !== "boolean" || + typeof bucket.smoothed !== "boolean" || + (bucket.comparisonValue !== undefined && + (typeof bucket.comparisonFilled !== "boolean" || + typeof bucket.comparisonSmoothed !== "boolean")), + ) + ) { + throw new UserInputError(UNVERIFIED_TRANSFORMATIONS); + } + } +} + +function isAdditiveTimeSeriesAggregate(yAxis: string): boolean { + const aggregate = yAxis.trim().toLowerCase(); + return aggregate === "count()" || aggregate.startsWith("sum("); +} + +function summarizeSeries( + series: EventsTimeSeriesResponse["timeSeries"][number], + transformed: boolean, +) { + let total = 0; + let allNumeric = series.meta.valueType !== "string"; + let maximum: { value: number | string; timestamp: number } | undefined; + let maximumValue = Number.NEGATIVE_INFINITY; + for (const bucket of series.values) { + if ( + bucket.value == null || + series.meta.valueType === "string" || + (series.meta.valueType === "date" && bucket.value === 0) + ) { + allNumeric = false; + continue; + } + const numeric = Number(bucket.value); + if (Number.isFinite(numeric)) { + total += numeric; + } else { + allNumeric = false; + } + const comparable = Number.isFinite(numeric) + ? numeric + : Date.parse(String(bucket.value)); + const filling = + bucket.incomplete && bucket.incompleteReason !== "OUTSIDE_RETENTION"; + if (!filling && Number.isFinite(comparable) && comparable > maximumValue) { + maximumValue = comparable; + maximum = { value: bucket.value, timestamp: bucket.timestamp }; + } + } + return { + ...(!transformed && + allNumeric && + series.values.length > 0 && + isAdditiveTimeSeriesAggregate(series.yAxis) + ? { total } + : {}), + ...(maximum + ? transformed + ? { derivedMaximum: maximum } + : { peak: maximum } + : {}), + }; +} + +export function buildTimeSeriesResult(params: { + series: EventsTimeSeriesResponse; + configuration: TimeSeriesConfig; + dataset: string; + query: string; + inputQuery: string; + timeRange: { statsPeriod?: string; start?: string; end?: string }; + url: string; + explanation?: string; + notes?: string[]; +}) { + const { series, configuration } = params; + validateTimeSeriesResponse(series, configuration); + return { + dataset: params.dataset, + query: params.query, + inputQuery: params.inputQuery, + timeRange: params.timeRange, + requestedInterval: configuration.interval ?? null, + url: params.url, + ...(params.explanation ? { explanation: params.explanation } : {}), + ...(params.notes?.length ? { notes: params.notes } : {}), + series: series.timeSeries.map((axis, axisIndex) => { + const summary = summarizeSeries( + axis, + (configuration.axes[axisIndex]?.transformations?.length ?? 0) > 0, + ); + return { + axisIndex, + yAxis: axis.yAxis, + transformations: configuration.axes[axisIndex]?.transformations ?? [], + intervalMs: axis.meta.interval, + ...(axis.meta.valueType !== undefined + ? { valueType: axis.meta.valueType } + : {}), + ...(axis.meta.valueUnit !== undefined + ? { valueUnit: axis.meta.valueUnit } + : {}), + ...(Object.keys(summary).length > 0 ? { summary } : {}), + buckets: axis.values.map((bucket) => ({ + timestamp: bucket.timestamp, + value: bucket.value ?? null, + incomplete: bucket.incomplete, + ...(bucket.incompleteReason !== undefined + ? { incompleteReason: bucket.incompleteReason } + : {}), + ...(bucket.comparisonValue !== undefined + ? { comparisonValue: bucket.comparisonValue } + : {}), + ...(bucket.filled !== undefined || + bucket.smoothed !== undefined || + bucket.comparisonFilled !== undefined || + bucket.comparisonSmoothed !== undefined + ? { + transformation: { + ...(bucket.filled !== undefined + ? { filled: bucket.filled } + : {}), + ...(bucket.smoothed !== undefined + ? { smoothed: bucket.smoothed } + : {}), + ...(bucket.comparisonFilled !== undefined + ? { comparisonFilled: bucket.comparisonFilled } + : {}), + ...(bucket.comparisonSmoothed !== undefined + ? { comparisonSmoothed: bucket.comparisonSmoothed } + : {}), + }, + } + : {}), + })), + }; + }), + }; +}