diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index cccc58224..e5b80b098 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2447,7 +2447,11 @@ 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..44d0e042d 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,137 @@ describe("search_events", () => { expect(result).toContain("**Peak**: 8"); }); + 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: "", + 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; 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/", + () => + 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"); + 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({ + // Empty buckets are zero-filled with a numeric 0 by Sentry. + data: [ + [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" }]], + ], + }), + ), + ); + + 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+00:00"); + expect(result).toContain( + "**Peak**: 2026-07-13T00:00:00+00:00 at 2025-09-13 00:00", + ); + expect(result).not.toContain("NaN"); + }); + 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..4796e019a 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; @@ -963,10 +963,21 @@ 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.data.map(([ts, values]) => { + const raw = values[0]?.count ?? 0; + // 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); + 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, + display, + }; + }); // Total is only meaningful for additive aggregates; summing count_unique / // avg / percentile buckets would be wrong, so omit it for those. @@ -1000,7 +1011,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 +1023,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.");