From d6bfafc246fd7b08a7b69d794e7f38e7331616bc Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:49:41 +0000 Subject: [PATCH 1/3] fix(mcp-core): Allow string 'count' in EventsStatsResponseSchema --- packages/mcp-core/src/api-client/schema.ts | 4 +- .../src/tools/catalog/search-events.test.ts | 60 +++++++++++++++++++ .../tools/support/search-events/formatters.ts | 4 +- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index cccc58224..e19e6d5e5 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2447,7 +2447,9 @@ export const EventsStatsResponseSchema = z data: z.array( z.tuple([ z.number(), - z.array(z.object({ count: z.number().nullish() }).passthrough()), + z.array( + z.object({ count: z.union([z.string(), z.number()]).nullish() }).passthrough(), + ), ]), ), start: z.number().optional(), 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 7de937048..2ad3458b6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -306,6 +306,66 @@ describe("search_events", () => { expect(result).toContain("**Peak**: 8"); }); + it("handles timeseries when the API returns count as a string (e.g. max(timestamp))", async () => { + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "max(timestamp)", interval: "1d" }, + timeRange: { statsPeriod: "45d" }, + explanation: "Max timestamp per day", + }; + 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); + + // The Sentry API returns `count` as a string for non-count aggregates like max(timestamp). + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + () => + HttpResponse.json({ + data: [ + [1757548800, [{ count: "1757548800" }]], + [1757635200, [{ count: "1757635200" }]], + [1757721600, [{ count: "1757721600" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "latest event timestamp per day", + fields: null, + sort: null, + period: "45d", + limit: 10, + includeExplanation: false, + }, + { + accessToken: "test-token", + userId: "user-123", + clientId: "client-123", + grantedSkills: new Set(), + constraints: {}, + sentryHost: "sentry.io", + }, + ); + + expect(result).toContain("max(timestamp) over time"); + }); + it("should handle spans dataset queries", async () => { // Mock AI response for spans dataset mockGenerateText.mockResolvedValueOnce( 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..ba23ea351 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -943,7 +943,7 @@ function isAdditiveAggregate(yAxis: string): boolean { * `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: { data: Array<[number, Array<{ count?: string | number | null }>]> }; yAxis: string; interval: string | null; inputQuery: string; @@ -965,7 +965,7 @@ export function formatTimeSeriesResults(params: { const points = series.data.map(([ts, values]) => ({ time: new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "), - value: values[0]?.count ?? 0, + value: Number(values[0]?.count ?? 0), })); // Total is only meaningful for additive aggregates; summing count_unique / From 0935b1f82b1d28557a89c97365168e54032d8156 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:06:57 +0000 Subject: [PATCH 2/3] fix(mcp-core): Handle string 'count' values in events-stats API response --- .../src/tools/catalog/search-events.test.ts | 70 ++++++++++++++++++- .../tools/support/search-events/formatters.ts | 27 +++++-- 2 files changed, 89 insertions(+), 8 deletions(-) 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 2ad3458b6..028d1a6c6 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -306,7 +306,7 @@ describe("search_events", () => { expect(result).toContain("**Peak**: 8"); }); - it("handles timeseries when the API returns count as a string (e.g. max(timestamp))", async () => { + it("handles timeseries when the API returns count as a numeric string (e.g. max(timestamp) epoch)", async () => { const output = { dataset: "errors" as const, query: "", @@ -325,7 +325,8 @@ describe("search_events", () => { warnings: [] as const, } as any); - // The Sentry API returns `count` as a string for non-count aggregates like max(timestamp). + // The Sentry API returns `count` as a string for non-count aggregates; numeric + // epoch strings should be parsed correctly and never render as "NaN". mswServer.use( http.get( "https://sentry.io/api/0/organizations/test-org/events-stats/", @@ -364,6 +365,71 @@ describe("search_events", () => { ); expect(result).toContain("max(timestamp) over time"); + expect(result).not.toContain("NaN"); + }); + + it("handles timeseries when the API returns count as an ISO datetime string", async () => { + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "max(timestamp)", interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "Max timestamp per day", + }; + 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); + + // When the API returns ISO datetime strings, the output must display the raw + // string rather than "NaN". + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + () => + HttpResponse.json({ + data: [ + [1757548800, [{ count: "2026-07-11T00:00:00" }]], + [1757635200, [{ count: "2026-07-12T00:00:00" }]], + [1757721600, [{ count: "2026-07-13T00:00:00" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "latest event timestamp per day", + fields: null, + sort: null, + period: "7d", + limit: 10, + includeExplanation: false, + }, + { + accessToken: "test-token", + userId: "user-123", + clientId: "client-123", + grantedSkills: new Set(), + constraints: {}, + sentryHost: "sentry.io", + }, + ); + + expect(result).toContain("max(timestamp) over time"); + // ISO datetime strings must appear in the output as-is, never as "NaN". + expect(result).toContain("2026-07-13T00:00:00"); + expect(result).not.toContain("NaN"); }); it("should handle spans dataset queries", async () => { 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 ba23ea351..1cd985422 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -963,10 +963,25 @@ 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: Number(values[0]?.count ?? 0), - })); + const points = series.data.map(([ts, values]) => { + const raw = values[0]?.count ?? 0; + // Number() coerces the whole string (unlike parseFloat which stops at the + // first non-numeric char), so ISO datetime strings like "2026-07-13T00:00:00" + // correctly yield NaN rather than silently truncating to the year. + const numeric = typeof raw === "number" ? raw : Number(raw); + // Use 0 as a fallback when the value can't be coerced (e.g. ISO datetime + // string returned by aggregates like max(timestamp)). The original raw + // string is preserved for display so we never render "NaN" to the user. + const value = isNaN(numeric) ? 0 : numeric; + const display = isNaN(numeric) + ? String(raw) + : numeric.toLocaleString(); + return { + time: new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "), + value, + display, + }; + }); // Total is only meaningful for additive aggregates; summing count_unique / // avg / percentile buckets would be wrong, so omit it for those. @@ -1000,7 +1015,7 @@ export function formatTimeSeriesResults(params: { lines.push(`- **Total**: ${total.toLocaleString()}`); } if (peak) { - lines.push(`- **Peak**: ${peak.value.toLocaleString()} at ${peak.time}`); + lines.push(`- **Peak**: ${peak.display} at ${peak.time}`); } if (shown.length > 0) { @@ -1012,7 +1027,7 @@ export function formatTimeSeriesResults(params: { "| --- | --- |", ); for (const p of shown) { - lines.push(`| ${p.time} | ${p.value.toLocaleString()} |`); + lines.push(`| ${p.time} | ${p.display} |`); } } else { lines.push("", "No data points in this range."); From 6ede0d4570f4a3a4cfcee40603a4eda225a17a0b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:07:22 +0000 Subject: [PATCH 3/3] fix(search-events): Order datetime time-series values for peak Date-typed aggregates such as max(timestamp) return ISO datetime strings from events-stats, while empty buckets are zero-filled with 0. Coercing the strings to 0 made Peak report an empty bucket. Order them by parsed time instead, and cover the zero-filled production shape. --- packages/mcp-core/src/api-client/schema.ts | 4 +++- .../src/tools/catalog/search-events.test.ts | 13 +++++++++---- .../tools/support/search-events/formatters.ts | 16 ++++++---------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index e19e6d5e5..e5b80b098 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2448,7 +2448,9 @@ export const EventsStatsResponseSchema = z z.tuple([ z.number(), z.array( - z.object({ count: z.union([z.string(), z.number()]).nullish() }).passthrough(), + z + .object({ count: z.union([z.string(), z.number()]).nullish() }) + .passthrough(), ), ]), ), 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 028d1a6c6..44d0e042d 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -394,10 +394,12 @@ describe("search_events", () => { "https://sentry.io/api/0/organizations/test-org/events-stats/", () => HttpResponse.json({ + // Empty buckets are zero-filled with a numeric 0 by Sentry. data: [ - [1757548800, [{ count: "2026-07-11T00:00:00" }]], - [1757635200, [{ count: "2026-07-12T00:00:00" }]], - [1757721600, [{ count: "2026-07-13T00:00:00" }]], + [1757462400, [{ count: 0 }]], + [1757548800, [{ count: "2026-07-11T00:00:00+00:00" }]], + [1757635200, [{ count: "2026-07-12T00:00:00+00:00" }]], + [1757721600, [{ count: "2026-07-13T00:00:00+00:00" }]], ], }), ), @@ -428,7 +430,10 @@ describe("search_events", () => { expect(result).toContain("max(timestamp) over time"); // ISO datetime strings must appear in the output as-is, never as "NaN". - expect(result).toContain("2026-07-13T00:00:00"); + expect(result).toContain("2026-07-13T00:00:00+00:00"); + expect(result).toContain( + "**Peak**: 2026-07-13T00:00:00+00:00 at 2025-09-13 00:00", + ); expect(result).not.toContain("NaN"); }); 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 1cd985422..4796e019a 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -965,17 +965,13 @@ export function formatTimeSeriesResults(params: { const points = series.data.map(([ts, values]) => { const raw = values[0]?.count ?? 0; - // Number() coerces the whole string (unlike parseFloat which stops at the - // first non-numeric char), so ISO datetime strings like "2026-07-13T00:00:00" - // correctly yield NaN rather than silently truncating to the year. + // Date-typed aggregates such as max(timestamp) return ISO datetime + // strings. Number() rejects those whole (parseFloat would keep the year), + // so order them by parsed time and display the raw string. const numeric = typeof raw === "number" ? raw : Number(raw); - // Use 0 as a fallback when the value can't be coerced (e.g. ISO datetime - // string returned by aggregates like max(timestamp)). The original raw - // string is preserved for display so we never render "NaN" to the user. - const value = isNaN(numeric) ? 0 : numeric; - const display = isNaN(numeric) - ? String(raw) - : numeric.toLocaleString(); + const isNumeric = !Number.isNaN(numeric); + const value = isNumeric ? numeric : Date.parse(String(raw)) || 0; + const display = isNumeric ? numeric.toLocaleString() : String(raw); return { time: new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "), value,