From 032c442aceb7b310a22970ea95864c0db49dfe61 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Thu, 5 Nov 2026 14:54:36 -0500 Subject: [PATCH 1/4] feat(mcp): add ordered time-series contracts and serializers Preserve per-axis transformation pipelines across API requests and Explore links while retaining the existing single-axis client call shape. Refs EXP-1408 Co-Authored-By: GPT-6.1 Sol --- packages/mcp-core/src/api-client/client.ts | 46 ++++- packages/mcp-core/src/api-client/schema.ts | 20 +- .../mcp-core/src/utils/time-series.test.ts | 50 +++++ packages/mcp-core/src/utils/time-series.ts | 194 ++++++++++++++++++ packages/mcp-core/src/utils/url-utils.test.ts | 96 +++++++++ packages/mcp-core/src/utils/url-utils.ts | 116 +++++++++-- 6 files changed, 496 insertions(+), 26 deletions(-) create mode 100644 packages/mcp-core/src/utils/time-series.test.ts create mode 100644 packages/mcp-core/src/utils/time-series.ts diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index acc0d2629..24155507a 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -100,6 +100,12 @@ import { normalizeEventsDataset, } from "../utils/events-datasets"; import { isNumericId } from "../utils/slug-validation"; +import { + appendTimeSeriesParams, + normalizeTimeSeriesConfig, + timeSeriesCharts, + type TimeSeriesConfig, +} from "../utils/time-series"; import { type DashboardUrlOptions, getAIConversationsUrl as getAIConversationsUrlUtil, @@ -292,6 +298,7 @@ type ExplorerAggregateParams = { fields?: string[]; aggregateFunctions?: string[]; groupByFields?: string[]; + timeSeries?: TimeSeriesConfig; }; type ExplorerUrlParams = ExplorerAggregateParams & { @@ -1082,6 +1089,7 @@ export class SentryApiService { private isAggregateExplorerQuery(params: ExplorerAggregateParams): boolean { return Boolean( + params.timeSeries || params.aggregateFunctions?.length || params.fields?.some(isAggregateFieldOrEquation), ); @@ -1099,6 +1107,12 @@ export class SentryApiService { urlParams: URLSearchParams, params: ExplorerAggregateParams, ): void { + if (params.timeSeries) { + for (const chart of timeSeriesCharts(params.timeSeries)) { + urlParams.append("aggregateField", JSON.stringify(chart)); + } + return; + } const { fields, aggregateFunctions, groupByFields } = params; if (aggregateFunctions?.length || groupByFields?.length) { @@ -1136,8 +1150,16 @@ export class SentryApiService { private appendExplorerTimeParams( urlParams: URLSearchParams, - params: { statsPeriod?: string; start?: string; end?: string }, + params: { + statsPeriod?: string; + start?: string; + end?: string; + timeSeries?: TimeSeriesConfig; + }, ): void { + if (params.timeSeries?.interval) { + urlParams.set("interval", params.timeSeries.interval); + } if (params.start && params.end) { urlParams.set("start", params.start); urlParams.set("end", params.end); @@ -1285,6 +1307,7 @@ export class SentryApiService { start?: string, end?: string, eventData?: Record[], + timeSeries?: TimeSeriesConfig, ): string { if (dataset === "errors") { // Route to legacy Discover API @@ -1316,6 +1339,7 @@ export class SentryApiService { aggregateFunctions, groupByFields, traceMetrics: this.extractTraceMetricsFromResults(eventData), + timeSeries, }, this.protocol, ); @@ -1351,6 +1375,7 @@ export class SentryApiService { end, aggregateFunctions, groupByFields, + timeSeries, }; return dataset === "logs" @@ -4544,7 +4569,7 @@ export class SentryApiService { } /** - * Fetch a timeseries (events-timeseries) for a single yAxis, bucketed over + * Fetch a timeseries (events-timeseries) for ordered axes, bucketed over * time. Buckets that may still receive data are flagged `incomplete`, and * `meta.ingestion` reports the measured ingestion delay when available. * @@ -4558,6 +4583,7 @@ export class SentryApiService { query, yAxis, interval, + timeSeries, projectId, dataset = "errors", statsPeriod, @@ -4566,24 +4592,24 @@ export class SentryApiService { }: { organizationSlug: string; query: string; - yAxis: string; - interval?: string; projectId?: string; dataset?: EventsDataset; statsPeriod?: string; start?: string; end?: string; - }, + } & ( + | { yAxis: string; interval?: string; timeSeries?: never } + | { timeSeries: TimeSeriesConfig; yAxis?: never; interval?: never } + ), opts?: RequestOptions, ) { const queryParams = new URLSearchParams(); queryParams.set("dataset", normalizeEventsDataset(dataset)); queryParams.set("query", query); - queryParams.set("yAxis", yAxis); - // Omit interval to let Sentry pick a sensible bucket size for the range. - if (interval) { - queryParams.set("interval", interval); - } + appendTimeSeriesParams( + queryParams, + normalizeTimeSeriesConfig(timeSeries ?? { yAxis, interval }), + ); applyTimeParams(queryParams, statsPeriod, start, end); if (projectId) { queryParams.set("project", projectId); diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index e2605bf25..a8d62383d 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2511,12 +2511,19 @@ export const EventsTimeSeriesValueSchema = z value: z.union([z.number(), z.string()]).nullish(), incomplete: z.boolean(), incompleteReason: z.string().optional(), + comparisonValue: z.union([z.number(), z.string()]).nullish(), + filled: z.boolean().optional(), + smoothed: z.boolean().optional(), + comparisonFilled: z.boolean().optional(), + comparisonSmoothed: z.boolean().optional(), + sampleCount: z.number().nullish(), + sampleRate: z.number().nullish(), + confidence: z.enum(["low", "high"]).nullish(), }) .passthrough(); /** - * Response from the events-timeseries endpoint. The MCP always requests a - * single yAxis without topEvents, so `timeSeries` holds exactly one series. + * Response from the events-timeseries endpoint in request axis order. */ export const EventsTimeSeriesResponseSchema = z .object({ @@ -2525,12 +2532,21 @@ export const EventsTimeSeriesResponseSchema = z .object({ yAxis: z.string(), values: z.array(EventsTimeSeriesValueSchema), + groupBy: z + .array( + z.object({ + key: z.string(), + value: z.union([z.string(), z.number()]).nullable(), + }), + ) + .optional(), meta: z .object({ // Bucket width in milliseconds interval: z.number(), valueType: z.string().optional(), valueUnit: z.string().nullish(), + dataScanned: z.enum(["partial", "full"]).optional(), }) .passthrough(), }) diff --git a/packages/mcp-core/src/utils/time-series.test.ts b/packages/mcp-core/src/utils/time-series.test.ts new file mode 100644 index 000000000..65c63d850 --- /dev/null +++ b/packages/mcp-core/src/utils/time-series.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { TimeSeriesConfigSchema } from "./time-series"; + +describe("time series configuration", () => { + it("accepts automatic EMA factor and ASAP window selection", () => { + expect( + TimeSeriesConfigSchema.parse({ + axes: [ + { yAxis: "count()", transformations: ["smooth(ema,3)"] }, + { yAxis: "count()", transformations: ["smooth(asap)"] }, + { yAxis: "count()", transformations: ["smooth(asap,10)"] }, + ], + }).axes, + ).toEqual([ + { yAxis: "count()", transformations: ["smooth(ema,3)"] }, + { yAxis: "count()", transformations: ["smooth(asap)"] }, + { yAxis: "count()", transformations: ["smooth(asap,10)"] }, + ]); + }); + + it.each([ + ["fill(zero)", "fill(locf)"], + ["smooth(sma,2)", "smooth(ema,2)"], + ["smooth(sma,0)"], + ["smooth(sma,2.5)"], + ["smooth(ema,2,0)"], + ["smooth(ema,2,1.1)"], + ["smooth(asap,0)"], + ["smooth(asap,2,0.5)"], + ["fill(missing)"], + ])("rejects invalid operation pipeline %j", (...operations) => { + expect( + TimeSeriesConfigSchema.safeParse({ + axes: [{ yAxis: "count()", transformations: operations }], + }).success, + ).toBe(false); + }); + + it("rejects unknown chart shape properties instead of discarding them", () => { + expect( + TimeSeriesConfigSchema.safeParse({ + axes: [{ yAxis: "count()" }], + groupBy: ["span.op"], + }).success, + ).toBe(false); + expect( + TimeSeriesConfigSchema.safeParse({ axes: [], interval: "0m" }).success, + ).toBe(false); + }); +}); diff --git a/packages/mcp-core/src/utils/time-series.ts b/packages/mcp-core/src/utils/time-series.ts new file mode 100644 index 000000000..eb24283e6 --- /dev/null +++ b/packages/mcp-core/src/utils/time-series.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; +import { UserInputError } from "../errors"; + +const positiveInteger = (value: string | undefined): boolean => + value !== undefined && + /^[1-9]\d*$/.test(value) && + Number.isSafeInteger(Number(value)); + +function isTransformation(value: string): boolean { + if (/^fill\((?:zero|locf|linear)\)$/.test(value)) { + return true; + } + const match = /^smooth\((sma|ema|asap)(?:,([^,()]+))?(?:,([^,()]+))?\)$/.exec( + value, + ); + if (!match) { + return false; + } + const [, mode, window, factor] = match; + if (mode === "asap") { + return ( + factor === undefined && (window === undefined || positiveInteger(window)) + ); + } + if (!positiveInteger(window)) { + return false; + } + if (mode === "sma") { + return factor === undefined; + } + return ( + factor === undefined || + (/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(factor) && + Number(factor) > 0 && + Number(factor) <= 1) + ); +} + +export const TransformationPipelineSchema = z + .array( + z + .string() + .trim() + .describe( + "Operation in execution order: fill(zero|locf|linear), smooth(sma,window), smooth(ema,window[,factor]), or smooth(asap[,maxWindow]).", + ) + .refine(isTransformation, { + message: + "Use fill(zero|locf|linear), smooth(sma,window), smooth(ema,window[,factor]), or smooth(asap[,maxWindow]). Windows must be positive integers and EMA factor must be greater than zero and at most one.", + }), + ) + .max(2) + .refine( + (operations) => + new Set(operations.map((operation) => operation.split("(", 1)[0])) + .size === operations.length, + { + message: "Use at most one fill and one smooth operation per axis.", + }, + ) + .describe( + "Ordered pipeline with at most one fill and one smooth operation. An empty array requests raw values.", + ); + +export const TimeSeriesConfigSchema = z + .object({ + axes: z + .array( + z + .object({ + yAxis: z + .string() + .trim() + .min(1) + .regex( + /^[\w]+\(.*\)$/, + "Use an aggregate such as count() or avg(span.duration).", + ), + transformations: TransformationPipelineSchema.optional(), + }) + .strict(), + ) + .min(1), + interval: z + .string() + .regex( + /^[1-9]\d*[smhdw]$/, + "Use a positive bucket interval such as 1m, 1h, or 1d.", + ) + .nullable() + .optional(), + }) + .strict(); + +export type TimeSeriesConfig = z.infer; + +const DURATION_SECONDS: Record = { + s: 1, + m: 60, + h: 3600, + d: 86400, + w: 604800, +}; + +const EXPLORE_INTERVAL_MINIMUMS = [ + [30 * 86400, 10800], + [14 * 86400, 3600], + [4 * 86400, 1800], + [48 * 3600, 600], + [12 * 3600, 300], + [0, 60], +] as const; + +function durationSeconds(value: string): number { + const match = /^(\d+)([smhdw])$/.exec(value); + return match ? Number(match[1]) * DURATION_SECONDS[match[2]!]! : Number.NaN; +} + +export function getTimeSeriesExploreInterval( + config: TimeSeriesConfig, + executedIntervalMs: number | undefined, + timeRange: { statsPeriod?: string; start?: string; end?: string }, +): string | undefined { + const interval = + executedIntervalMs === undefined + ? config.interval + : config.interval && + durationSeconds(config.interval) === executedIntervalMs / 1000 + ? config.interval + : `${executedIntervalMs / 1000}s`; + if (!interval) { + return undefined; + } + const rangeSeconds = + timeRange.start && timeRange.end + ? (Date.parse(timeRange.end) - Date.parse(timeRange.start)) / 1000 + : durationSeconds(timeRange.statsPeriod ?? "14d"); + // Explore's useChartInterval.tsx replaces intervals outside this range. + const minimum = + EXPLORE_INTERVAL_MINIMUMS.find(([range]) => rangeSeconds >= range)?.[1] ?? + 60; + const intervalSeconds = durationSeconds(interval); + if ( + !Number.isFinite(rangeSeconds) || + !Number.isFinite(intervalSeconds) || + intervalSeconds < minimum || + intervalSeconds > rangeSeconds + ) { + throw new UserInputError( + `Explore cannot preserve this bucket interval for the requested time range. Use timeSeries.interval between ${minimum}s and ${rangeSeconds}s, or change period.`, + ); + } + return interval; +} + +export function normalizeTimeSeriesConfig( + value: + | TimeSeriesConfig + | { yAxis: string; transformations?: string[]; interval?: string | null }, +): TimeSeriesConfig { + if ("axes" in value) { + return value; + } + return { + axes: [{ yAxis: value.yAxis, transformations: value.transformations }], + interval: value.interval, + }; +} + +export function appendTimeSeriesParams( + params: URLSearchParams, + config: TimeSeriesConfig, +): void { + for (const [index, axis] of config.axes.entries()) { + params.append("yAxis", axis.yAxis); + for (const operation of axis.transformations ?? []) { + params.append(`transformations[${index}]`, operation); + } + } + if (config.interval) { + params.set("interval", config.interval); + } +} + +export function timeSeriesCharts( + config: TimeSeriesConfig, +): Array<{ yAxes: string[]; transformations?: string[] }> { + return config.axes.map((axis) => ({ + yAxes: [axis.yAxis], + ...(axis.transformations === undefined + ? {} + : { transformations: axis.transformations }), + })); +} diff --git a/packages/mcp-core/src/utils/url-utils.test.ts b/packages/mcp-core/src/utils/url-utils.test.ts index bfd2a0742..ef1c8aace 100644 --- a/packages/mcp-core/src/utils/url-utils.test.ts +++ b/packages/mcp-core/src/utils/url-utils.test.ts @@ -556,6 +556,102 @@ describe("url-utils", () => { }); describe("getTraceMetricsExploreUrl", () => { + it("preserves axis order across metric identities and qualifies bare value aggregates", () => { + const url = new URL( + getTraceMetricsExploreUrl("sentry.io", "myorg", { + query: + "metric.name:http.duration metric.type:distribution metric.unit:millisecond environment:production", + projectId: "123", + statsPeriod: "24h", + timeSeries: { + axes: [ + { + yAxis: "p95(value)", + transformations: ["fill(linear)", "smooth(ema,3)"], + }, + { + yAxis: "sum(value,requests,counter,none)", + transformations: ["smooth(asap)"], + }, + { yAxis: "p95(value)", transformations: [] }, + ], + interval: "1m", + }, + }), + ); + expect( + url.searchParams.getAll("metric").map((state) => JSON.parse(state)), + ).toEqual([ + { + metric: { + name: "http.duration", + type: "distribution", + unit: "millisecond", + }, + query: + "metric.name:http.duration metric.type:distribution metric.unit:millisecond environment:production", + aggregateFields: [ + { + yAxes: ["p95(value,http.duration,distribution,millisecond)"], + transformations: ["fill(linear)", "smooth(ema,3)"], + }, + ], + aggregateSortBys: [], + mode: "aggregate", + }, + { + metric: { name: "requests", type: "counter", unit: "none" }, + query: + "metric.name:http.duration metric.type:distribution metric.unit:millisecond environment:production", + aggregateFields: [ + { + yAxes: ["sum(value,requests,counter,none)"], + transformations: ["smooth(asap)"], + }, + ], + aggregateSortBys: [], + mode: "aggregate", + }, + { + metric: { + name: "http.duration", + type: "distribution", + unit: "millisecond", + }, + query: + "metric.name:http.duration metric.type:distribution metric.unit:millisecond environment:production", + aggregateFields: [ + { + yAxes: ["p95(value,http.duration,distribution,millisecond)"], + transformations: [], + }, + ], + aggregateSortBys: [], + mode: "aggregate", + }, + ]); + expect(url.searchParams.get("interval")).toBe("1m"); + expect(url.searchParams.get("statsPeriod")).toBe("24h"); + expect(url.searchParams.get("project")).toBe("123"); + }); + + it.each([ + "environment:production", + "metric.name:http.* metric.type:distribution", + "metric.name:[a,b] metric.type:distribution", + "metric.name:a metric.name:b metric.type:distribution", + "metric.name:a metric.type:distribution OR environment:production", + ])("rejects an ambiguous metric identity in %s", (query) => { + expect(() => + getTraceMetricsExploreUrl("sentry.io", "myorg", { + query, + timeSeries: { + axes: [{ yAxis: "p95(value)", transformations: ["smooth(asap)"] }], + }, + }), + ).toThrow("exact metric identity"); + }); + it("should build sample metric URLs with concrete metric state", () => { const result = getTraceMetricsExploreUrl("sentry.io", "myorg", { query: "", diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index 6bbfb0885..422291bac 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -1,6 +1,17 @@ -import { getColumnsAndAggregates } from "@sentry/toolkit-core/events-fields"; +import { + getColumnsAndAggregates, + parseFunction, +} from "@sentry/toolkit-core/events-fields"; import { isSentryHost } from "@sentry/toolkit-core/sentry-host"; +import { parseSearch } from "@sentry/search-syntax/parser"; +import { + BooleanOperator, + TermOperator, + Token, +} from "@sentry/search-syntax/tokens"; +import { UserInputError } from "../errors"; import type { SentryProtocol } from "../types"; +import { timeSeriesCharts, type TimeSeriesConfig } from "./time-series"; import { type EventsDataset, isMetricsDataset, @@ -31,6 +42,7 @@ export interface TraceMetricsExplorerUrlOptions { aggregateFunctions?: string[]; groupByFields?: string[]; traceMetrics?: TraceMetricIdentifier[]; + timeSeries?: TimeSeriesConfig; } export interface ProfilesExplorerUrlOptions { @@ -94,24 +106,20 @@ function normalizeTraceMetric( function extractTraceMetricFromAggregate( field: string, ): TraceMetricIdentifier | null { - const match = field.match(/^[^(]+\((.*)\)$/); - if (!match) { + const parsed = parseFunction(field); + if (!parsed) { return null; } - - const args = match[1] - .split(",") - .map((arg) => arg.trim()) - .filter((arg) => arg.length > 0); - - if (args.length < 4) { + const args = parsed.arguments; + const offset = parsed.filter === undefined ? 0 : 1; + if (args.length !== 4 + offset) { return null; } return normalizeTraceMetric({ - name: args[1]!, - type: args[2]!, - unit: args[3], + name: args[1 + offset]!, + type: args[2 + offset]!, + unit: args[3 + offset], }); } @@ -135,6 +143,48 @@ function extractTraceMetricFromQuery( }); } +function exactTraceMetricFromQuery( + query: string, +): TraceMetricIdentifier | null { + const tokens = parseSearch(query); + if ( + !tokens || + tokens.some( + (token) => + token.type === Token.LOGIC_BOOLEAN && + token.value === BooleanOperator.OR, + ) + ) { + return null; + } + const values = new Map(); + for (const token of tokens) { + if ( + token.type !== Token.FILTER || + token.key.type !== Token.KEY_SIMPLE || + !["metric.name", "metric.type", "metric.unit"].includes(token.key.value) + ) { + continue; + } + if ( + token.negated || + (token.operator !== TermOperator.DEFAULT && + token.operator !== TermOperator.EQUAL) || + token.value.type !== Token.VALUE_TEXT || + /[*,()]/.test(token.value.value) || + values.has(token.key.value) + ) { + return null; + } + values.set(token.key.value, token.value.value); + } + return normalizeTraceMetric({ + name: values.get("metric.name") ?? "", + type: values.get("metric.type") ?? "", + unit: values.get("metric.unit"), + }); +} + function dedupeTraceMetrics( metrics: Array, ): TraceMetricIdentifier[] { @@ -189,6 +239,7 @@ export function getTraceMetricsExploreUrl( aggregateFunctions, groupByFields, traceMetrics, + timeSeries, } = options; const urlParams = new URLSearchParams(); @@ -211,7 +262,44 @@ export function getTraceMetricsExploreUrl( const sortField = sort?.startsWith("-") ? sort.slice(1) : sort; const sortKind = sort?.startsWith("-") ? ("desc" as const) : ("asc" as const); - if (isAggregateQuery) { + if (timeSeries) { + if (timeSeries.interval) { + urlParams.set("interval", timeSeries.interval); + } + for (const chart of timeSeriesCharts(timeSeries)) { + const yAxis = chart.yAxes[0]!; + const parsed = parseFunction(yAxis); + const axisMetric = extractTraceMetricFromAggregate(yAxis); + const metric = axisMetric ?? exactTraceMetricFromQuery(query); + const offset = parsed?.filter === undefined ? 0 : 1; + if ( + !metric || + !parsed || + (!axisMetric && parsed.arguments.length > offset + 1) + ) { + throw new UserInputError( + "A metrics time series Explore link needs one exact metric identity per axis. Use an aggregate such as p95(value,http.duration,distribution,millisecond), or include exact metric.name and metric.type filters in query.", + ); + } + const args = + parsed.arguments.length > offset + ? parsed.arguments + : [...parsed.arguments, "value"]; + const qualifiedYAxis = axisMetric + ? yAxis + : `${parsed.name}(${[...args, metric.name, metric.type, metric.unit ?? "none"].join(",")})`; + urlParams.append( + "metric", + JSON.stringify({ + metric, + query, + aggregateFields: [{ ...chart, yAxes: [qualifiedYAxis] }], + aggregateSortBys: [], + mode: "aggregate", + }), + ); + } + } else if (isAggregateQuery) { const metricQueries = new Map< string, { From d542541cd0ad6cccef491f1b2dd152b2c75eb5de Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 6 Nov 2026 10:48:40 -0500 Subject: [PATCH 2/4] refactor(mcp): keep time-series schema additions within EXP-1408 Remove sampling and scan declarations and the detailed grouped-response schema. Keep the comparisons and transformation flags required by the issue; response shape checks can use the existing passthrough fields. Co-Authored-By: GPT-6.1 Sol --- packages/mcp-core/src/api-client/schema.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index a8d62383d..ae3c79026 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2516,9 +2516,6 @@ export const EventsTimeSeriesValueSchema = z smoothed: z.boolean().optional(), comparisonFilled: z.boolean().optional(), comparisonSmoothed: z.boolean().optional(), - sampleCount: z.number().nullish(), - sampleRate: z.number().nullish(), - confidence: z.enum(["low", "high"]).nullish(), }) .passthrough(); @@ -2532,21 +2529,12 @@ export const EventsTimeSeriesResponseSchema = z .object({ yAxis: z.string(), values: z.array(EventsTimeSeriesValueSchema), - groupBy: z - .array( - z.object({ - key: z.string(), - value: z.union([z.string(), z.number()]).nullable(), - }), - ) - .optional(), meta: z .object({ // Bucket width in milliseconds interval: z.number(), valueType: z.string().optional(), valueUnit: z.string().nullish(), - dataScanned: z.enum(["partial", "full"]).optional(), }) .passthrough(), }) From 4748c7884e4d6264c683dd9283670951ddafebc5 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 6 Nov 2026 15:42:14 -0500 Subject: [PATCH 3/4] fix(mcp): handle parenthesized metric filters Resolve exact metric identities inside parenthesized conjunctions while continuing to reject ambiguous or negated filters. Match interval validation to the Explore URL's 24-hour default when no time range is supplied. Co-Authored-By: GPT-6.1 Sol --- .../mcp-core/src/utils/time-series.test.ts | 22 ++++++++++++++++++- packages/mcp-core/src/utils/time-series.ts | 2 +- packages/mcp-core/src/utils/url-utils.test.ts | 19 ++++++++++++++++ packages/mcp-core/src/utils/url-utils.ts | 9 +++++--- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/mcp-core/src/utils/time-series.test.ts b/packages/mcp-core/src/utils/time-series.test.ts index 65c63d850..ebda6f4e4 100644 --- a/packages/mcp-core/src/utils/time-series.test.ts +++ b/packages/mcp-core/src/utils/time-series.test.ts @@ -1,7 +1,27 @@ import { describe, expect, it } from "vitest"; -import { TimeSeriesConfigSchema } from "./time-series"; +import { + getTimeSeriesExploreInterval, + TimeSeriesConfigSchema, +} from "./time-series"; describe("time series configuration", () => { + it("uses the Explore URL's 24h default when checking interval bounds", () => { + expect( + getTimeSeriesExploreInterval( + { axes: [{ yAxis: "count()" }], interval: "10m" }, + undefined, + {}, + ), + ).toBe("10m"); + expect(() => + getTimeSeriesExploreInterval( + { axes: [{ yAxis: "count()" }], interval: "2d" }, + undefined, + {}, + ), + ).toThrow("Explore cannot preserve this bucket interval"); + }); + it("accepts automatic EMA factor and ASAP window selection", () => { expect( TimeSeriesConfigSchema.parse({ diff --git a/packages/mcp-core/src/utils/time-series.ts b/packages/mcp-core/src/utils/time-series.ts index eb24283e6..82cb86869 100644 --- a/packages/mcp-core/src/utils/time-series.ts +++ b/packages/mcp-core/src/utils/time-series.ts @@ -134,7 +134,7 @@ export function getTimeSeriesExploreInterval( const rangeSeconds = timeRange.start && timeRange.end ? (Date.parse(timeRange.end) - Date.parse(timeRange.start)) / 1000 - : durationSeconds(timeRange.statsPeriod ?? "14d"); + : durationSeconds(timeRange.statsPeriod ?? "24h"); // Explore's useChartInterval.tsx replaces intervals outside this range. const minimum = EXPLORE_INTERVAL_MINIMUMS.find(([range]) => rangeSeconds >= range)?.[1] ?? diff --git a/packages/mcp-core/src/utils/url-utils.test.ts b/packages/mcp-core/src/utils/url-utils.test.ts index ef1c8aace..19639cfb6 100644 --- a/packages/mcp-core/src/utils/url-utils.test.ts +++ b/packages/mcp-core/src/utils/url-utils.test.ts @@ -556,6 +556,22 @@ describe("url-utils", () => { }); describe("getTraceMetricsExploreUrl", () => { + it("resolves exact metric filters inside nested parentheses", () => { + const query = + "(metric.name:tokens AND (metric.type:counter)) environment:production"; + const url = new URL( + getTraceMetricsExploreUrl("sentry.io", "myorg", { + query, + timeSeries: { axes: [{ yAxis: "sum(value)" }], interval: "1h" }, + }), + ); + expect(JSON.parse(url.searchParams.get("metric")!)).toMatchObject({ + metric: { name: "tokens", type: "counter" }, + query, + aggregateFields: [{ yAxes: ["sum(value,tokens,counter,none)"] }], + }); + }); + it("preserves axis order across metric identities and qualifies bare value aggregates", () => { const url = new URL( getTraceMetricsExploreUrl("sentry.io", "myorg", { @@ -641,6 +657,9 @@ describe("url-utils", () => { "metric.name:[a,b] metric.type:distribution", "metric.name:a metric.name:b metric.type:distribution", "metric.name:a metric.type:distribution OR environment:production", + "(metric.name:a OR metric.name:b) metric.type:distribution", + "NOT (metric.name:a metric.type:distribution)", + "!(metric.name:a metric.type:distribution)", ])("rejects an ambiguous metric identity in %s", (query) => { expect(() => getTraceMetricsExploreUrl("sentry.io", "myorg", { diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index 422291bac..141792fd5 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -146,13 +146,16 @@ function extractTraceMetricFromQuery( function exactTraceMetricFromQuery( query: string, ): TraceMetricIdentifier | null { - const tokens = parseSearch(query); + const tokens = parseSearch(query, { config: { flattenParenGroups: true } }); if ( !tokens || tokens.some( (token) => - token.type === Token.LOGIC_BOOLEAN && - token.value === BooleanOperator.OR, + (token.type === Token.LOGIC_BOOLEAN && + token.value === BooleanOperator.OR) || + (token.type === Token.FREE_TEXT && + !token.quoted && + /^(?:NOT|!)\s*$/i.test(token.value)), ) ) { return null; From b2102bb24edb59179fac62b7ce3df1a35d8b796f Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 6 Nov 2026 17:23:00 -0500 Subject: [PATCH 4/4] refactor(mcp): rename the ordered transformations schema Use TransformationsSchema consistently and describe the ordered operations as transformations. Exercise the renamed schema in its validation tests. Co-Authored-By: GPT-6.1 Sol --- packages/mcp-core/src/utils/time-series.test.ts | 11 +++++------ packages/mcp-core/src/utils/time-series.ts | 6 +++--- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/mcp-core/src/utils/time-series.test.ts b/packages/mcp-core/src/utils/time-series.test.ts index ebda6f4e4..b8bc02daa 100644 --- a/packages/mcp-core/src/utils/time-series.test.ts +++ b/packages/mcp-core/src/utils/time-series.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getTimeSeriesExploreInterval, TimeSeriesConfigSchema, + TransformationsSchema, } from "./time-series"; describe("time series configuration", () => { @@ -48,12 +49,10 @@ describe("time series configuration", () => { ["smooth(asap,0)"], ["smooth(asap,2,0.5)"], ["fill(missing)"], - ])("rejects invalid operation pipeline %j", (...operations) => { - expect( - TimeSeriesConfigSchema.safeParse({ - axes: [{ yAxis: "count()", transformations: operations }], - }).success, - ).toBe(false); + ])("rejects invalid transformations %j", (...transformations) => { + expect(TransformationsSchema.safeParse(transformations).success).toBe( + false, + ); }); it("rejects unknown chart shape properties instead of discarding them", () => { diff --git a/packages/mcp-core/src/utils/time-series.ts b/packages/mcp-core/src/utils/time-series.ts index 82cb86869..97613a963 100644 --- a/packages/mcp-core/src/utils/time-series.ts +++ b/packages/mcp-core/src/utils/time-series.ts @@ -36,7 +36,7 @@ function isTransformation(value: string): boolean { ); } -export const TransformationPipelineSchema = z +export const TransformationsSchema = z .array( z .string() @@ -59,7 +59,7 @@ export const TransformationPipelineSchema = z }, ) .describe( - "Ordered pipeline with at most one fill and one smooth operation. An empty array requests raw values.", + "Ordered transformations with at most one fill and one smooth operation. An empty array requests raw values.", ); export const TimeSeriesConfigSchema = z @@ -76,7 +76,7 @@ export const TimeSeriesConfigSchema = z /^[\w]+\(.*\)$/, "Use an aggregate such as count() or avg(span.duration).", ), - transformations: TransformationPipelineSchema.optional(), + transformations: TransformationsSchema.optional(), }) .strict(), )