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/6] 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/6] 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/6] 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, From abf43a2ae4b61a46a31a0e6c27cfd8763f8ccdb3 Mon Sep 17 00:00:00 2001 From: betegon <28983162+betegon@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:56:59 +0000 Subject: [PATCH 4/6] fix(mcp): Support string-valued timeseries counts and preserve identifier strings Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com> --- packages/mcp-core/src/api-client/schema.ts | 4 +- .../src/tools/catalog/search-events.test.ts | 259 ++++++++++++++++++ .../tools/support/search-events/formatters.ts | 54 +++- 3 files changed, 306 insertions(+), 11 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..4c41e8e47 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,265 @@ 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({ + 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("omits Peak when all count values are opaque identifier strings (e.g. latest_event())", async () => { + // A zero-filled bucket followed by a populated bucket with an event ID. + // Before the fix, both coerced to 0 so the empty bucket won as "Peak". + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "latest_event()", interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "Latest event ID 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); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + () => + HttpResponse.json({ + data: [ + // Empty bucket first — must not win as Peak. + [1757548800, [{ count: 0 }]], + [1757635200, [{ count: "abc1234567890abcdef" }]], + [1757721600, [{ count: "def9876543210fedcba" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "latest event ID 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("latest_event() over time"); + // Event IDs must appear literally in the table. + expect(result).toContain("abc1234567890abcdef"); + expect(result).toContain("def9876543210fedcba"); + // Peak must be omitted — no meaningful numeric/date peak for identifier series. + expect(result).not.toContain("**Peak**"); + expect(result).not.toContain("NaN"); + }); + + it("preserves leading-zero numeric-looking string identifiers without coercing them to numbers", async () => { + // "00123" is a numeric-looking identifier. Number("00123") = 123, so the + // old code would display "123" and silently change the value. + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "latest_event()", interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "Leading-zero identifier", + }; + 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-stats/", + () => + HttpResponse.json({ + data: [ + [1757548800, [{ count: "00123" }]], + [1757635200, [{ count: "00456" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "leading-zero identifiers", + 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("latest_event() over time"); + // Strings must appear literally — "00123" must NOT appear as "123". + expect(result).toContain("00123"); + expect(result).toContain("00456"); + expect(result).not.toContain("| 123 |"); + 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..5dc950d23 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,18 +963,52 @@ 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; + const time = new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); + + // Direct number from the API: format normally. + if (typeof raw === "number") { + return { time, sortValue: raw as number | null, display: raw.toLocaleString() }; + } + + // String value from the API. Only treat as numeric when the round-trip is + // exact — this prevents leading-zero identifiers such as "00123" from being + // silently changed to 123. + const asNumber = Number(raw); + if (!isNaN(asNumber) && String(asNumber) === raw) { + return { time, sortValue: asNumber as number | null, display: asNumber.toLocaleString() }; + } + + // Non-numeric string: may be an ISO datetime (max(timestamp)) or an opaque + // identifier (latest_event()). Preserve the raw string for display. Use the + // parsed date in milliseconds as the sort key so date series still order + // correctly; null for identifiers that are neither numeric nor valid dates. + const dateParsed = Date.parse(raw); + return { + time, + sortValue: isNaN(dateParsed) ? null : (dateParsed as number | null), + display: raw, + }; + }); // 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) + ? points.reduce((sum, p) => sum + (p.sortValue ?? 0), 0) : null; - const peak = points.reduce<(typeof points)[number] | undefined>( - (max, p) => (max === undefined || p.value > max.value ? p : max), + // Detect whether this is a string-identifier series (e.g. latest_event()). + // When it is, numeric zeros come from empty-bucket markers in the API, not + // real values, and must be excluded so they don't falsely win as Peak. + const hasIdentifierPoints = points.some((p) => p.sortValue === null); + // Only consider points that have a meaningful sort value (numeric or date); + // omit Peak entirely when all values are opaque identifiers. + const peakablePoints = points.filter( + (p): p is typeof p & { sortValue: number } => + p.sortValue !== null && !(hasIdentifierPoints && p.sortValue === 0), + ); + const peak = peakablePoints.reduce<(typeof peakablePoints)[number] | undefined>( + (max, p) => (max === undefined || p.sortValue > max.sortValue ? p : max), undefined, ); @@ -1000,7 +1034,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 +1046,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 51fa3c6f2b19fe51e7d42012860c2dd638ca12f7 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 20 Oct 2026 14:27:57 +0200 Subject: [PATCH 5/6] fix(mcp-core): preserve string timeseries values --- docs/specs/search-events.md | 1 + .../src/tools/catalog/search-events.test.ts | 75 +++++++++++++++++++ .../tools/support/search-events/formatters.ts | 14 ++-- 3 files changed, 85 insertions(+), 5 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 4ca2a6c99..02b3b6881 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -138,6 +138,7 @@ Requests for a metric over time ("per hour", "per day", "trend", "over time") re - 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`. +- Series declared as `valueType: "string"` preserve raw values, including identifiers with leading zeros, and omit total and peak. Numeric series retain their formatting and summaries; ISO datetime strings are displayed as returned and ordered by their parsed timestamp for the peak. - Buckets Sentry flags as `incomplete` are marked in the table. Those still receiving data (`NOT_ELAPSED`, `INGESTION_PENDING`) get `*`, are excluded from the peak, and label the total "so far". Those that start before the retention window (`OUTSIDE_RETENTION`) get `†` and stay in the peak, since their value is final. 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/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index f9ae785eb..b3be7bf1b 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -497,6 +497,81 @@ describe("search_events", () => { expect(result).not.toContain("NaN"); }); + it.each([ + { + yAxis: "latest_event()", + value: "abcdef0123456789abcdef0123456789", + }, + { yAxis: "any(user.id)", value: "00123" }, + ])( + "preserves string values and omits Peak for $yAxis timeseries", + async ({ yAxis, value }) => { + const response = mockAIResponse("errors", "", []); + const output = { + ...response.experimental_output, + timeSeries: { yAxis, interval: "1h" }, + }; + mockGenerateText.mockResolvedValueOnce({ + ...response, + text: JSON.stringify(output), + experimental_output: output, + }); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", + () => + HttpResponse.json({ + timeSeries: [ + { + yAxis, + values: [ + // String aggregates also have numeric zero-filled buckets. + { timestamp: 1757548800000, value: 0, incomplete: false }, + { timestamp: 1757552400000, value, incomplete: false }, + ], + meta: { + interval: 3600000, + valueType: "string", + valueUnit: null, + }, + }, + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "representative identifiers 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", + }, + ); + + expect(result).toContain(`${yAxis} over time`); + expect(result).toContain("| 2025-09-11 00:00 | 0 |"); + expect(result).toContain(`| 2025-09-11 01:00 | ${value} |`); + expect(result).not.toContain("**Peak**"); + expect(result).not.toContain("**Total**"); + }, + ); + it("marks incomplete buckets and reports ingestion delay", async () => { const output = { dataset: "errors" as const, 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 12ebe1daa..32943e225 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -997,6 +997,8 @@ export function formatTimeSeriesResults(params: { url, } = params; + // String aggregates can return identifiers, even when their values look numeric. + const isStringSeries = series.timeSeries[0]?.meta.valueType === "string"; const points = (series.timeSeries[0]?.values ?? []).map((bucket) => { // OUTSIDE_RETENTION buckets start before the retention window: their data // is permanently partial. Every other reason means data is still arriving. @@ -1011,7 +1013,8 @@ export function formatTimeSeriesResults(params: { return { time: formatBucketTime(bucket.timestamp), value: isNumeric ? numeric : Date.parse(String(raw)) || 0, - display: isNumeric ? numeric.toLocaleString() : String(raw), + display: + !isStringSeries && isNumeric ? numeric.toLocaleString() : String(raw), filling: bucket.incomplete && !outsideRetention, outsideRetention, }; @@ -1021,13 +1024,14 @@ export function formatTimeSeriesResults(params: { // 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 total = + !isStringSeries && isAdditiveAggregate(yAxis) + ? points.reduce((sum, p) => sum + p.value, 0) + : null; // Buckets still filling can't be the peak yet. Partial retention buckets are // final, so if one still tops the rest it is a real peak. const peak = points - .filter((p) => !p.filling) + .filter((p) => !isStringSeries && !p.filling) .reduce<(typeof points)[number] | undefined>( (max, p) => (max === undefined || p.value > max.value ? p : max), undefined, From 20f3de4d8a92370ce3c7792af2f957d2a3fd1d4d Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:41:41 +0000 Subject: [PATCH 6/6] fix(mcp): Support string count values and preserve non-numeric series in events-stats --- packages/mcp-core/src/api-client/schema.ts | 4 +- .../src/tools/catalog/search-events.test.ts | 326 ++++++++++++++++++ .../tools/support/search-events/formatters.ts | 62 +++- 3 files changed, 381 insertions(+), 11 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..c7bf4256f 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,332 @@ 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({ + 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("omits Peak when all count values are opaque identifier strings (e.g. latest_event())", async () => { + // A zero-filled bucket followed by a populated bucket with an event ID. + // Before the fix, both coerced to 0 so the empty bucket won as "Peak". + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "latest_event()", interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "Latest event ID 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); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + () => + HttpResponse.json({ + data: [ + // Empty bucket first — must not win as Peak. + [1757548800, [{ count: 0 }]], + [1757635200, [{ count: "abc1234567890abcdef" }]], + [1757721600, [{ count: "def9876543210fedcba" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "latest event ID 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("latest_event() over time"); + // Event IDs must appear literally in the table. + expect(result).toContain("abc1234567890abcdef"); + expect(result).toContain("def9876543210fedcba"); + // Peak must be omitted — no meaningful numeric/date peak for identifier series. + expect(result).not.toContain("**Peak**"); + expect(result).not.toContain("NaN"); + }); + + it("preserves leading-zero numeric-looking string identifiers without coercing them to numbers", async () => { + // "00123" is a numeric-looking identifier. Number("00123") = 123, so the + // old code would display "123" and silently change the value. + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "latest_event()", interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "Leading-zero identifier", + }; + 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-stats/", + () => + HttpResponse.json({ + data: [ + [1757548800, [{ count: "00123" }]], + [1757635200, [{ count: "00456" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "leading-zero identifiers", + 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("latest_event() over time"); + // Strings must appear literally — "00123" must NOT appear as "123". + expect(result).toContain("00123"); + expect(result).toContain("00456"); + expect(result).not.toContain("| 123 |"); + expect(result).not.toContain("NaN"); + }); + + it("date series: zero-filled empty buckets do not win Peak over populated date buckets", async () => { + // A date series (max(timestamp)) mixes numeric zeros for empty buckets + // with ISO datetime strings for populated buckets. The most-recent date + // must be Peak; a zero must never win. + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "max(timestamp)", interval: "1d" }, + timeRange: { statsPeriod: "7d" }, + explanation: "Latest event 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); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-stats/", + () => + HttpResponse.json({ + data: [ + // Empty bucket (no events that day) — must NOT win Peak. + [1757548800, [{ count: 0 }]], + [1757635200, [{ count: "2026-07-12T08:00:00" }]], + [1757721600, [{ count: "2026-07-13T14:30:00" }]], + ], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "most recent event 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"); + // The latest date (2026-07-13) must be Peak — not the numeric zero. + expect(result).toContain("**Peak**: 2026-07-13T14:30:00"); + expect(result).not.toContain("**Peak**: 0"); + 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..d9fa9ed96 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,18 +963,60 @@ 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; + const time = new Date(ts * 1000).toISOString().slice(0, 16).replace("T", " "); + + // Direct number from the API: format normally. + if (typeof raw === "number") { + return { time, sortValue: raw as number | null, display: raw.toLocaleString() }; + } + + // String value from the API. Only treat as numeric when the round-trip is + // exact — this prevents leading-zero identifiers such as "00123" from being + // silently changed to 123. + const asNumber = Number(raw); + if (!isNaN(asNumber) && String(asNumber) === raw) { + return { time, sortValue: asNumber as number | null, display: asNumber.toLocaleString() }; + } + + // Non-numeric string: may be an ISO datetime (max(timestamp)) or an opaque + // identifier (latest_event()). Preserve the raw string for display. Use the + // parsed date in milliseconds as the sort key so date series still order + // correctly; null for identifiers that are neither numeric nor valid dates. + const dateParsed = Date.parse(raw); + return { + time, + sortValue: isNaN(dateParsed) ? null : (dateParsed as number | null), + display: raw, + }; + }); // 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) + ? points.reduce((sum, p) => sum + (p.sortValue ?? 0), 0) : null; - const peak = points.reduce<(typeof points)[number] | undefined>( - (max, p) => (max === undefined || p.value > max.value ? p : max), + // Detect whether this is a string-identifier series (e.g. latest_event()). + // When it is, numeric zeros come from empty-bucket markers in the API, not + // real values, and must be excluded so they don't falsely win as Peak. + const hasIdentifierPoints = points.some((p) => p.sortValue === null); + // Only consider points that have a meaningful sort value (numeric or date); + // omit Peak entirely when all values are opaque identifiers. + const withSortValue = points.filter( + (p): p is typeof p & { sortValue: number } => + p.sortValue !== null && !(hasIdentifierPoints && p.sortValue === 0), + ); + // Exclude empty-bucket zeros from peak whenever real (non-zero) values exist. + // This prevents a zero placeholder from winning over a date or count bucket + // that happens to be the only populated point. If every bucket is zero the + // zero is kept so the Peak line still appears. + const hasNonZeroSortValue = withSortValue.some((p) => p.sortValue !== 0); + const peakablePoints = hasNonZeroSortValue + ? withSortValue.filter((p) => p.sortValue !== 0) + : withSortValue; + const peak = peakablePoints.reduce<(typeof peakablePoints)[number] | undefined>( + (max, p) => (max === undefined || p.sortValue > max.sortValue ? p : max), undefined, ); @@ -1000,7 +1042,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 +1054,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.");