Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/specs/search-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 5 additions & 6 deletions packages/mcp-core/src/api-client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
ErrorsSearchResponseSchema,
EventAttachmentListSchema,
EventSchema,
EventsStatsResponseSchema,
EventsTimeSeriesResponseSchema,
ExternalIssueListSchema,
ExternalIssueSchema,
FlamegraphSchema,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 32 additions & 11 deletions packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
72 changes: 55 additions & 17 deletions packages/mcp-core/src/tools/catalog/search-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
],
});
},
Expand Down Expand Up @@ -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,
},
},
],
}),
),
Expand Down Expand Up @@ -3531,17 +3551,23 @@ 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()");
expect(url.searchParams.get("interval")).toBe("1d");
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 },
},
],
});
},
Expand Down Expand Up @@ -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 },
},
],
});
},
),
Expand Down Expand Up @@ -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 },
),
Expand Down
14 changes: 9 additions & 5 deletions packages/mcp-core/src/tools/support/search-events/formatters.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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 /
Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-core/src/tools/support/search-events/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading