From 6e9c19541962c7efa4db960b6f149f293e1386de Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Tue, 6 Oct 2026 16:00:36 -0400 Subject: [PATCH] ref(mcp): Use events-timeseries for search_events time series Switches getEventsTimeSeries from events-stats to events-timeseries so the tool can mark incomplete buckets and report measured ingestion delay. --- docs/specs/search-events.md | 3 +- packages/mcp-core/src/api-client/client.ts | 52 +++--- packages/mcp-core/src/api-client/schema.ts | 70 ++++++-- .../src/tools/catalog/search-events.test.ts | 158 ++++++++++++++++-- .../tools/support/search-events/formatters.ts | 74 ++++++-- .../src/tools/support/search-events/search.ts | 4 +- 6 files changed, 290 insertions(+), 71 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 93201f494..7b2b16b1d 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -133,11 +133,12 @@ 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. - The handler routes `timeSeries` to `SentryApiService.getEventsTimeSeries` and renders the buckets (with total and peak) via `formatTimeSeriesResults`. +- Buckets Sentry flags as `incomplete` (still receiving data) are marked with `*` in the table and excluded from the peak; the total is labelled "so far". When the response carries `meta.ingestion`, an **Ingestion** line reports the measured delay and the time data is complete through. ### Key Technical Constraints diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 4533b7a76..9e8097fe9 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -61,7 +61,7 @@ import { ErrorsSearchResponseSchema, EventAttachmentListSchema, EventSchema, - EventsStatsResponseSchema, + EventsTimeSeriesResponseSchema, ExternalIssueListSchema, ExternalIssueSchema, FlamegraphSchema, @@ -423,12 +423,10 @@ const EventsValidationIssueSchema = z valid: z.boolean(), error: ValidationErrorSchema, }) - .transform( - ({ valid, error }): EventsValidationIssue => ({ - valid, - ...(error ? { error } : {}), - }), - ); + .transform(({ valid, error }): EventsValidationIssue => ({ + valid, + ...(error ? { error } : {}), + })); const EventsNamedValidationIssueSchema = z .object({ @@ -436,13 +434,11 @@ const EventsNamedValidationIssueSchema = z valid: z.boolean(), error: ValidationErrorSchema, }) - .transform( - ({ name, valid, error }): EventsNamedValidationIssue => ({ - name, - valid, - ...(error ? { error } : {}), - }), - ); + .transform(({ name, valid, error }): EventsNamedValidationIssue => ({ + name, + valid, + ...(error ? { error } : {}), + })); const EventsAttributeValidationSchema = z .object({ @@ -473,13 +469,11 @@ const EventsQueryValidationSchema = z error: ValidationErrorSchema, fields: EventsAttributeValidationListSchema, }) - .transform( - ({ valid, error, fields }): EventsQueryValidation => ({ - valid, - fields, - ...(error ? { error } : {}), - }), - ); + .transform(({ valid, error, fields }): EventsQueryValidation => ({ + valid, + fields, + ...(error ? { error } : {}), + })); const EventsValidationResponseSchema = z .object({ @@ -1330,9 +1324,9 @@ export class SentryApiService { private isAggregateExplorerQuery(params: ExplorerAggregateParams): boolean { return Boolean( params.aggregateFunctions?.length || - params.fields?.some( - (field) => field.includes("(") && field.includes(")"), - ), + params.fields?.some( + (field) => field.includes("(") && field.includes(")"), + ), ); } @@ -5159,7 +5153,9 @@ 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. Buckets that may still receive data are flagged `incomplete`, and + * `meta.ingestion` reports the measured ingestion delay when available. * * `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). @@ -5201,15 +5197,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); } // POST https://us.sentry.io/api/0/issues/5485083130/autofix/ diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index cccc58224..7b6122e44 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2438,19 +2438,67 @@ 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. + * Measured ingestion delay for the queried dataset. Only present for EAP + * datasets (spans, logs, trace metrics) on orgs with the feature enabled. + * `completeThrough` is the time (ms) up to which data is considered complete. */ -export const EventsStatsResponseSchema = z +export const IngestionMetaSchema = z .object({ - data: z.array( - z.tuple([ - z.number(), - z.array(z.object({ count: z.number().nullish() }).passthrough()), - ]), + status: z.enum(["healthy", "stalled", "idle", "unknown"]), + delaySeconds: z.number().optional(), + completeThrough: z.number().optional(), + }) + .passthrough(); + +export type IngestionMeta = z.infer; + +/** + * One bucket of an events-timeseries series. `timestamp` is in milliseconds. + * `incomplete` marks buckets that may still receive data (the current bucket, + * or anything after `meta.ingestion.completeThrough`). + */ +export const EventsTimeSeriesValueSchema = z + .object({ + timestamp: z.number(), + value: z.number().nullish(), + incomplete: z.boolean(), + incompleteReason: z.string().optional(), + }) + .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(), + valueType: z.string().optional(), + valueUnit: z.string().nullish(), + }) + .passthrough(), + }) + .passthrough(), ), - start: z.number().optional(), - end: z.number().optional(), + meta: z + .object({ + start: z.number().optional(), + end: z.number().optional(), + ingestion: IngestionMetaSchema.optional(), + }) + .passthrough() + .optional(), }) .passthrough(); + +export type EventsTimeSeriesResponse = z.infer< + typeof EventsTimeSeriesResponseSchema +>; 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..512c5c5fd 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, + }, + }, ], }), ), @@ -306,6 +326,92 @@ describe("search_events", () => { expect(result).toContain("**Peak**: 8"); }); + it("marks incomplete buckets and reports ingestion delay", async () => { + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "count()", interval: "1h" }, + timeRange: { statsPeriod: "24h" }, + explanation: "", + }; + mockGenerateText.mockResolvedValueOnce({ + text: JSON.stringify(output), + experimental_output: output, + finishReason: "stop" as const, + usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, + warnings: [] as const, + } as any); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", + () => + HttpResponse.json({ + timeSeries: [ + { + yAxis: "count()", + values: [ + { timestamp: 1757548800000, value: 5, incomplete: false }, + { timestamp: 1757552400000, value: 8, incomplete: false }, + { timestamp: 1757556000000, value: 9, incomplete: true }, + ], + meta: { + interval: 3600000, + valueType: "integer", + valueUnit: null, + }, + }, + ], + meta: { + dataset: "errors", + start: 1757462400000, + end: 1757548800000, + ingestion: { + status: "healthy", + delaySeconds: 95, + completeThrough: 1757556600000, + }, + }, + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "errors per hour", + fields: null, + sort: null, + period: "24h", + limit: 10, + includeExplanation: false, + }, + { + accessToken: "test-token", + userId: "user-123", + clientId: "client-123", + grantedSkills: new Set(), + constraints: {}, + sentryHost: "sentry.io", + }, + ); + + // The incomplete bucket is larger, but it is still filling so it is not the peak. + expect(result).toContain("**Peak**: 8"); + expect(result).toContain("**Total**: 22 (so far)"); + expect(result).toContain("| 2025-09-11 02:00 | 9 * |"); + expect(result).toContain("Incomplete bucket"); + expect(result).toContain( + "**Ingestion**: healthy (~1m 35s behind, data complete through 2025-09-11 02:10 UTC)", + ); + }); + it("should handle spans dataset queries", async () => { // Mock AI response for spans dataset mockGenerateText.mockResolvedValueOnce( @@ -3531,7 +3637,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 +3645,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 +3730,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 +4009,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..7f59810a1 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,8 @@ import type { SentryApiService } from "../../../api-client"; +import type { + EventsTimeSeriesResponse, + IngestionMeta, +} from "../../../api-client/schema"; import { formatToolCallInstruction } from "../../../internal/tool-helpers/tool-call-formatting"; import { formatUserGeoSummary } from "../../../internal/user-formatting"; import { logInfo } from "../../../telem/logging"; @@ -938,12 +942,41 @@ function isAdditiveAggregate(yAxis: string): boolean { return fn === "count()" || fn.startsWith("sum("); } +function formatBucketTime(timestampMs: number): string { + return new Date(timestampMs).toISOString().slice(0, 16).replace("T", " "); +} + +/** + * One line describing the measured ingestion delay, so the caller knows how + * far behind the data is before reading a trailing dip as a real drop. + */ +function formatIngestionStatus(ingestion: IngestionMeta): string { + const parts: string[] = []; + if (ingestion.delaySeconds !== undefined) { + const seconds = Math.round(ingestion.delaySeconds); + const delay = + seconds >= 60 + ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` + : `${seconds}s`; + parts.push(`~${delay} behind`); + } + if (ingestion.completeThrough !== undefined) { + parts.push( + `data complete through ${formatBucketTime(ingestion.completeThrough)} UTC`, + ); + } + const detail = parts.length > 0 ? ` (${parts.join(", ")})` : ""; + return `- **Ingestion**: ${ingestion.status}${detail}`; +} + /** - * 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. + * Buckets flagged `incomplete` by Sentry may still receive data, so they are + * marked in the table and excluded from the peak. */ export function formatTimeSeriesResults(params: { - series: { data: Array<[number, Array<{ count?: number | null }>]> }; + series: EventsTimeSeriesResponse; yAxis: string; interval: string | null; inputQuery: string; @@ -963,20 +996,26 @@ 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: formatBucketTime(bucket.timestamp), + value: bucket.value ?? 0, + incomplete: bucket.incomplete, })); + const hasIncomplete = points.some((p) => p.incomplete); + const ingestion = series.meta?.ingestion; // Total is only meaningful for additive aggregates; summing count_unique / // avg / percentile buckets would be wrong, so omit it for those. const total = isAdditiveAggregate(yAxis) ? points.reduce((sum, p) => sum + p.value, 0) : null; - const peak = points.reduce<(typeof points)[number] | undefined>( - (max, p) => (max === undefined || p.value > max.value ? p : max), - undefined, - ); + // Incomplete buckets are still filling, so they can't be the peak yet. + const peak = points + .filter((p) => !p.incomplete) + .reduce<(typeof points)[number] | undefined>( + (max, p) => (max === undefined || p.value > max.value ? p : max), + undefined, + ); const MAX_ROWS = 48; const shown = points.length > MAX_ROWS ? points.slice(-MAX_ROWS) : points; @@ -997,11 +1036,16 @@ export function formatTimeSeriesResults(params: { ); lines.push(`- **Time range**: ${formatExecutedTimeRange(timeRange)}`); if (total !== null) { - lines.push(`- **Total**: ${total.toLocaleString()}`); + lines.push( + `- **Total**: ${total.toLocaleString()}${hasIncomplete ? " (so far)" : ""}`, + ); } if (peak) { lines.push(`- **Peak**: ${peak.value.toLocaleString()} at ${peak.time}`); } + if (ingestion) { + lines.push(formatIngestionStatus(ingestion)); + } if (shown.length > 0) { lines.push( @@ -1012,7 +1056,15 @@ export function formatTimeSeriesResults(params: { "| --- | --- |", ); for (const p of shown) { - lines.push(`| ${p.time} | ${p.value.toLocaleString()} |`); + lines.push( + `| ${p.time} | ${p.value.toLocaleString()}${p.incomplete ? " *" : ""} |`, + ); + } + if (hasIncomplete) { + lines.push( + "", + "\\* Incomplete bucket: data is still arriving, so the value may rise.", + ); } } else { lines.push("", "No data points in this range."); 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,