diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 93201f494..14037b114 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -133,7 +133,7 @@ absent. Existing final validation and unknown-environment notices remain in plac ### Time Series -Requests for a metric over time ("per hour", "per day", "trend", "over time") return a bucketed series via the `events-stats` endpoint instead of failing. +Requests for a metric over time ("per hour", "per day", "trend", "over time") return a bucketed series via the `events-timeseries` endpoint instead of failing. - The embedded agent sets `timeSeries: { yAxis, interval }` on its output. `yAxis` is the aggregate to plot (e.g. `count()`); the query, environment, and time range are reused from the normal translation. - **Interval is agent-decided, never a required input.** It is set only when the user names a granularity ("per hour" → `1h`); otherwise it is left `null` so Sentry picks a sensible bucket for the range (mirrors `get_interval_from_range`). Sentry rejects an interval that would produce too many buckets. diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index b8d7a0004..63de04ea0 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -63,7 +63,7 @@ import { ErrorsSearchResponseSchema, EventAttachmentListSchema, EventSchema, - EventsStatsResponseSchema, + EventsTimeSeriesResponseSchema, ExternalIssueListSchema, ExternalIssueSchema, FlamegraphSchema, @@ -5140,7 +5140,8 @@ export class SentryApiService { } /** - * Fetch a timeseries (events-stats) for a single yAxis, bucketed over time. + * Fetch a timeseries (events-timeseries) for a single yAxis, bucketed over + * time. * * `interval` is optional: omit it to let Sentry pick a sensible bucket size * for the range (mirrors get_interval_from_range in the Sentry source). @@ -5182,15 +5183,13 @@ export class SentryApiService { if (projectId) { queryParams.set("project", projectId); } - // partial=1 keeps the current (in-progress) bucket, matching Sentry's charts. - queryParams.set("partial", "1"); queryParams.set("referrer", SENTRY_MCP_SEARCH_EVENTS_REFERRER); const apiUrl = - apiPath`/organizations/${organizationSlug}/events-stats/` + + apiPath`/organizations/${organizationSlug}/events-timeseries/` + `?${queryParams.toString()}`; const body = await this.requestJSON(apiUrl, undefined, opts); - return EventsStatsResponseSchema.parse(body); + return EventsTimeSeriesResponseSchema.parse(body); } async getDroppedEvents( diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 66bc33a20..aaefdd6ce 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2438,23 +2438,44 @@ export const AgenticOnboardingRunSchema = z.object({ }); /** - * Response from the events-stats (timeseries) endpoint for a single yAxis: - * a series of `[unixTimestampSeconds, [{ count }]]` buckets. `count` holds the - * yAxis value for that bucket regardless of the aggregate function. + * One bucket of an events-timeseries series. `timestamp` is in milliseconds. + * `incomplete` marks buckets that may still receive data. */ -export const EventsStatsResponseSchema = z +export const EventsTimeSeriesValueSchema = z .object({ - data: z.array( - z.tuple([ - z.number(), - z.array(z.object({ count: z.number().nullish() }).passthrough()), - ]), + timestamp: z.number(), + value: z.number().nullish(), + incomplete: z.boolean(), + }) + .passthrough(); + +/** + * Response from the events-timeseries endpoint. The MCP always requests a + * single yAxis without topEvents, so `timeSeries` holds exactly one series. + */ +export const EventsTimeSeriesResponseSchema = z + .object({ + timeSeries: z.array( + z + .object({ + yAxis: z.string(), + values: z.array(EventsTimeSeriesValueSchema), + meta: z + .object({ + // Bucket width in milliseconds + interval: z.number(), + }) + .passthrough(), + }) + .passthrough(), ), - start: z.number().optional(), - end: z.number().optional(), }) .passthrough(); +export type EventsTimeSeriesResponse = z.infer< + typeof EventsTimeSeriesResponseSchema +>; + export const DroppedEventsBucketSchema = z .object({ type: z.string(), diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 683ba068b..231cfcc6f 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -198,17 +198,27 @@ describe("search_events", () => { mswServer.use( http.get( - "https://sentry.io/api/0/organizations/test-org/events-stats/", + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", ({ request }) => { const url = new URL(request.url); expect(url.searchParams.get("yAxis")).toBe("count()"); expect(url.searchParams.get("interval")).toBe("1h"); expect(url.searchParams.get("dataset")).toBe("errors"); return HttpResponse.json({ - data: [ - [1757548800, [{ count: 5 }]], - [1757552400, [{ count: 8 }]], - [1757556000, [{ count: 3 }]], + timeSeries: [ + { + yAxis: "count()", + values: [ + { timestamp: 1757548800000, value: 5, incomplete: false }, + { timestamp: 1757552400000, value: 8, incomplete: false }, + { timestamp: 1757556000000, value: 3, incomplete: false }, + ], + meta: { + interval: 3600000, + valueType: "integer", + valueUnit: null, + }, + }, ], }); }, @@ -266,12 +276,22 @@ describe("search_events", () => { mswServer.use( http.get( - "https://sentry.io/api/0/organizations/test-org/events-stats/", + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", () => HttpResponse.json({ - data: [ - [1757548800, [{ count: 5 }]], - [1757552400, [{ count: 8 }]], + timeSeries: [ + { + yAxis: "count_unique(user)", + values: [ + { timestamp: 1757548800000, value: 5, incomplete: false }, + { timestamp: 1757552400000, value: 8, incomplete: false }, + ], + meta: { + interval: 3600000, + valueType: "integer", + valueUnit: null, + }, + }, ], }), ), @@ -3531,7 +3551,7 @@ describe("search_events", () => { }, }), http.get( - "https://sentry.io/api/0/organizations/test-org/events-stats/", + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", ({ request }) => { const url = new URL(request.url); expect(url.searchParams.get("yAxis")).toBe("count()"); @@ -3539,9 +3559,15 @@ describe("search_events", () => { expect(url.searchParams.get("dataset")).toBe("spans"); expect(url.searchParams.get("statsPeriod")).toBe("7d"); return HttpResponse.json({ - data: [ - [1757548800, [{ count: 5 }]], - [1757635200, [{ count: 8 }]], + timeSeries: [ + { + yAxis: "count()", + values: [ + { timestamp: 1757548800000, value: 5, incomplete: false }, + { timestamp: 1757635200000, value: 8, incomplete: false }, + ], + meta: { interval: 86400000 }, + }, ], }); }, @@ -3618,14 +3644,26 @@ describe("search_events", () => { }, }), http.get( - "https://sentry.io/api/0/organizations/test-org/events-stats/", + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", ({ request }) => { const url = new URL(request.url); expect(url.searchParams.has("spanQuery")).toBe(false); expect(url.searchParams.has("logQuery")).toBe(false); expect(url.searchParams.has("metricQuery")).toBe(false); return HttpResponse.json({ - data: [[1757548800, [{ count: 100 }]]], + timeSeries: [ + { + yAxis: "count()", + values: [ + { + timestamp: 1757548800000, + value: 100, + incomplete: false, + }, + ], + meta: { interval: 3600000 }, + }, + ], }); }, ), @@ -3885,10 +3923,10 @@ describe("search_events", () => { }, }), http.get( - "https://sentry.io/api/0/organizations/test-org/events-stats/", + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", ({ request }) => { expect(new URL(request.url).searchParams.get("project")).toBe("-1"); - return HttpResponse.json({ data: [] }); + return HttpResponse.json({ timeSeries: [] }); }, { once: true }, ), diff --git a/packages/mcp-core/src/tools/support/search-events/formatters.ts b/packages/mcp-core/src/tools/support/search-events/formatters.ts index 624559bde..49bd11b30 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -1,4 +1,5 @@ import type { SentryApiService } from "../../../api-client"; +import type { EventsTimeSeriesResponse } from "../../../api-client/schema"; import { formatToolCallInstruction } from "../../../internal/tool-helpers/tool-call-formatting"; import { formatUserGeoSummary } from "../../../internal/user-formatting"; import { logInfo } from "../../../telem/logging"; @@ -939,11 +940,11 @@ function isAdditiveAggregate(yAxis: string): boolean { } /** - * Format an events-stats (timeseries) result: a metric bucketed over time. + * Format an events-timeseries result: a metric bucketed over time. * `interval` is null when Sentry chose the bucket size for the range. */ export function formatTimeSeriesResults(params: { - series: { data: Array<[number, Array<{ count?: number | null }>]> }; + series: EventsTimeSeriesResponse; yAxis: string; interval: string | null; inputQuery: string; @@ -963,9 +964,12 @@ export function formatTimeSeriesResults(params: { url, } = params; - const points = series.data.map(([ts, values]) => ({ - time: new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "), - value: values[0]?.count ?? 0, + const points = (series.timeSeries[0]?.values ?? []).map((bucket) => ({ + time: new Date(bucket.timestamp) + .toISOString() + .slice(0, 16) + .replace("T", " "), + value: bucket.value ?? 0, })); // Total is only meaningful for additive aggregates; summing count_unique / diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 9925d3cf9..508dd4513 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -930,8 +930,8 @@ export async function runSearchEvents( ); // No validateEventsSearch here: it validates the /events/ (discover) // request shape — fields + orderby — which is not what a timeseries - // sends (yAxis + interval, no fields/sort). events-stats validates the - // query server-side, so a bad query still surfaces as an API error. + // sends (yAxis + interval, no fields/sort). events-timeseries validates + // the query server-side, so a bad query still surfaces as an API error. const series = await apiService.getEventsTimeSeries({ organizationSlug, query: timeSeriesQuery,