Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -292,6 +298,7 @@ type ExplorerAggregateParams = {
fields?: string[];
aggregateFunctions?: string[];
groupByFields?: string[];
timeSeries?: TimeSeriesConfig;
};

type ExplorerUrlParams = ExplorerAggregateParams & {
Expand Down Expand Up @@ -1082,6 +1089,7 @@ export class SentryApiService {

private isAggregateExplorerQuery(params: ExplorerAggregateParams): boolean {
return Boolean(
params.timeSeries ||
params.aggregateFunctions?.length ||
params.fields?.some(isAggregateFieldOrEquation),
);
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1285,6 +1307,7 @@ export class SentryApiService {
start?: string,
end?: string,
eventData?: Record<string, unknown>[],
timeSeries?: TimeSeriesConfig,
): string {
if (dataset === "errors") {
// Route to legacy Discover API
Expand Down Expand Up @@ -1316,6 +1339,7 @@ export class SentryApiService {
aggregateFunctions,
groupByFields,
traceMetrics: this.extractTraceMetricsFromResults(eventData),
timeSeries,
},
this.protocol,
);
Expand Down Expand Up @@ -1351,6 +1375,7 @@ export class SentryApiService {
end,
aggregateFunctions,
groupByFields,
timeSeries,
};

return dataset === "logs"
Expand Down Expand Up @@ -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.
*
Expand All @@ -4558,6 +4583,7 @@ export class SentryApiService {
query,
yAxis,
interval,
timeSeries,
projectId,
dataset = "errors",
statsPeriod,
Expand All @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2511,12 +2511,16 @@ 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(),
})
.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({
Expand Down
69 changes: 69 additions & 0 deletions packages/mcp-core/src/utils/time-series.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
getTimeSeriesExploreInterval,
TimeSeriesConfigSchema,
TransformationsSchema,
} 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({
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 transformations %j", (...transformations) => {
expect(TransformationsSchema.safeParse(transformations).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);
});
});
Loading
Loading