From 9cee4cb4a60b1db2676505be1cf01fe9ab2949db Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Thu, 5 Nov 2026 15:01:48 -0500 Subject: [PATCH 1/4] fix(mcp): preserve time-series values and provenance Keep missing buckets distinct from observed zero and retain comparisons, units, and transformation metadata in result output. Reject response shapes that cannot be represented faithfully. Refs EXP-1408 Co-Authored-By: GPT-6.1 Sol --- .../search-events/time-series-results.test.ts | 100 ++++++++ .../search-events/time-series-results.ts | 217 ++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 packages/mcp-core/src/tools/support/search-events/time-series-results.test.ts create mode 100644 packages/mcp-core/src/tools/support/search-events/time-series-results.ts 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..b42f0a162 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/time-series-results.test.ts @@ -0,0 +1,100 @@ +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("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..466ed7e99 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/time-series-results.ts @@ -0,0 +1,217 @@ +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); + } + } +} + +export 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) { + const numeric = + bucket.value == null || series.meta.valueType === "string" + ? Number.NaN + : Number(bucket.value); + if (!Number.isFinite(numeric)) { + allNumeric = false; + continue; + } + total += numeric; + if (!bucket.incomplete && numeric > maximumValue && bucket.value != null) { + maximumValue = numeric; + 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); + const ingestion = series.meta?.ingestion; + return { + dataset: params.dataset, + query: params.query, + inputQuery: params.inputQuery, + timeRange: params.timeRange, + ...(series.meta?.start !== undefined || series.meta?.end !== undefined + ? { + executedTimeRange: { + ...(series.meta.start !== undefined + ? { start: series.meta.start } + : {}), + ...(series.meta.end !== undefined ? { end: series.meta.end } : {}), + }, + } + : {}), + requestedInterval: configuration.interval ?? null, + url: params.url, + ...(params.explanation ? { explanation: params.explanation } : {}), + ...(params.notes?.length ? { notes: params.notes } : {}), + ...(ingestion + ? { + ingestion: { + status: ingestion.status, + ...(ingestion.delaySeconds !== undefined + ? { delaySeconds: ingestion.delaySeconds } + : {}), + ...(ingestion.completeThrough !== undefined + ? { completeThrough: ingestion.completeThrough } + : {}), + }, + } + : {}), + 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 } + : {}), + ...(axis.meta.dataScanned !== undefined + ? { dataScanned: axis.meta.dataScanned } + : {}), + ...(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 } + : {}), + }, + } + : {}), + ...(bucket.sampleCount !== undefined || + bucket.sampleRate !== undefined || + bucket.confidence !== undefined + ? { + sampling: { + ...(bucket.sampleCount !== undefined + ? { sampleCount: bucket.sampleCount } + : {}), + ...(bucket.sampleRate !== undefined + ? { sampleRate: bucket.sampleRate } + : {}), + ...(bucket.confidence !== undefined + ? { confidence: bucket.confidence } + : {}), + }, + } + : {}), + })), + }; + }), + }; +} From 0663fd9a0e2272879382505e3e30a56ab05658a5 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Thu, 5 Nov 2026 15:39:05 -0500 Subject: [PATCH 2/4] fix(mcp): preserve retention and date-valued series peaks Include final OUTSIDE_RETENTION buckets when finding peaks while excluding buckets still receiving data. Compare ISO datetime values by parsed time and retain the original response value in the structured summary. Add regression coverage for raw and transformed retention maxima and date values mixed with empty buckets and an incomplete trailing bucket. Refs EXP-1408 Co-Authored-By: GPT-6.1 Sol --- .../search-events/time-series-results.test.ts | 96 +++++++++++++++++++ .../search-events/time-series-results.ts | 22 +++-- 2 files changed, 110 insertions(+), 8 deletions(-) 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 index b42f0a162..d72507ecf 100644 --- 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 @@ -32,6 +32,102 @@ function makeSeries(): EventsTimeSeriesResponse { } 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("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: { 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 index 466ed7e99..70c668538 100644 --- 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 @@ -67,17 +67,23 @@ function summarizeSeries( let maximum: { value: number | string; timestamp: number } | undefined; let maximumValue = Number.NEGATIVE_INFINITY; for (const bucket of series.values) { - const numeric = - bucket.value == null || series.meta.valueType === "string" - ? Number.NaN - : Number(bucket.value); - if (!Number.isFinite(numeric)) { + if (bucket.value == null || series.meta.valueType === "string") { allNumeric = false; continue; } - total += numeric; - if (!bucket.incomplete && numeric > maximumValue && bucket.value != null) { - maximumValue = numeric; + 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 }; } } From 72931b693df744090f33a99fed4fc509e2a06c36 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 6 Nov 2026 10:49:03 -0500 Subject: [PATCH 3/4] refactor(mcp): limit time-series output to the requested contract Merge the narrowed shared schema and remove sampling, scan, ingestion, and extra executed-range projection. Restore the existing Markdown formatter and its tests because transformed searches use structured output. Keep required values, units, comparisons, transformation flags, shape validation, and correct raw and derived summaries. Co-Authored-By: GPT-6.1 Sol --- .../search-events/time-series-results.ts | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) 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 index 70c668538..d486f3007 100644 --- 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 @@ -53,7 +53,7 @@ export function validateTimeSeriesResponse( } } -export function isAdditiveTimeSeriesAggregate(yAxis: string): boolean { +function isAdditiveTimeSeriesAggregate(yAxis: string): boolean { const aggregate = yAxis.trim().toLowerCase(); return aggregate === "count()" || aggregate.startsWith("sum("); } @@ -115,39 +115,15 @@ export function buildTimeSeriesResult(params: { }) { const { series, configuration } = params; validateTimeSeriesResponse(series, configuration); - const ingestion = series.meta?.ingestion; return { dataset: params.dataset, query: params.query, inputQuery: params.inputQuery, timeRange: params.timeRange, - ...(series.meta?.start !== undefined || series.meta?.end !== undefined - ? { - executedTimeRange: { - ...(series.meta.start !== undefined - ? { start: series.meta.start } - : {}), - ...(series.meta.end !== undefined ? { end: series.meta.end } : {}), - }, - } - : {}), requestedInterval: configuration.interval ?? null, url: params.url, ...(params.explanation ? { explanation: params.explanation } : {}), ...(params.notes?.length ? { notes: params.notes } : {}), - ...(ingestion - ? { - ingestion: { - status: ingestion.status, - ...(ingestion.delaySeconds !== undefined - ? { delaySeconds: ingestion.delaySeconds } - : {}), - ...(ingestion.completeThrough !== undefined - ? { completeThrough: ingestion.completeThrough } - : {}), - }, - } - : {}), series: series.timeSeries.map((axis, axisIndex) => { const summary = summarizeSeries( axis, @@ -164,9 +140,6 @@ export function buildTimeSeriesResult(params: { ...(axis.meta.valueUnit !== undefined ? { valueUnit: axis.meta.valueUnit } : {}), - ...(axis.meta.dataScanned !== undefined - ? { dataScanned: axis.meta.dataScanned } - : {}), ...(Object.keys(summary).length > 0 ? { summary } : {}), buckets: axis.values.map((bucket) => ({ timestamp: bucket.timestamp, @@ -199,23 +172,6 @@ export function buildTimeSeriesResult(params: { }, } : {}), - ...(bucket.sampleCount !== undefined || - bucket.sampleRate !== undefined || - bucket.confidence !== undefined - ? { - sampling: { - ...(bucket.sampleCount !== undefined - ? { sampleCount: bucket.sampleCount } - : {}), - ...(bucket.sampleRate !== undefined - ? { sampleRate: bucket.sampleRate } - : {}), - ...(bucket.confidence !== undefined - ? { confidence: bucket.confidence } - : {}), - }, - } - : {}), })), }; }), From 64d45fc862aab26885bc6d61a121beb1af54545f Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 6 Nov 2026 15:54:06 -0500 Subject: [PATCH 4/4] fix(mcp): omit empty date buckets from time-series maxima Merge the updated contract layer and exclude zero-filled date buckets from peak and derived maximum calculations. Preserve legitimate numeric zeros. Co-Authored-By: GPT-6.1 Sol --- .../search-events/time-series-results.test.ts | 44 +++++++++++++++++++ .../search-events/time-series-results.ts | 6 ++- 2 files changed, 49 insertions(+), 1 deletion(-) 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 index d72507ecf..94dcdd210 100644 --- 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 @@ -91,6 +91,50 @@ describe("buildTimeSeriesResult", () => { }, ); + 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: { 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 index d486f3007..64bc7dcb2 100644 --- 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 @@ -67,7 +67,11 @@ function summarizeSeries( 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") { + if ( + bucket.value == null || + series.meta.valueType === "string" || + (series.meta.valueType === "date" && bucket.value === 0) + ) { allNumeric = false; continue; }