diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index fa0cfeb4e..d4d7ec689 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2456,11 +2456,13 @@ 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`). + * `value` is passed through from the query unchanged, so date-typed + * aggregates such as `max(timestamp)` return ISO datetime strings. */ export const EventsTimeSeriesValueSchema = z .object({ timestamp: z.number(), - value: z.number().nullish(), + value: z.union([z.number(), z.string()]).nullish(), incomplete: z.boolean(), incompleteReason: z.string().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 04fcced9f..f9ae785eb 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -326,6 +326,177 @@ describe("search_events", () => { expect(result).toContain("**Peak**: 8"); }); + it("handles timeseries when the API returns a numeric string value (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); + + // Sentry passes non-count aggregate values through unchanged; numeric + // strings should be parsed and never render as "NaN". + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", + () => + HttpResponse.json({ + timeSeries: [ + { + yAxis: "max(timestamp)", + values: [ + { + timestamp: 1757548800000, + value: "1757548800", + incomplete: false, + }, + { + timestamp: 1757635200000, + value: "1757635200", + incomplete: false, + }, + ], + meta: { + interval: 86400000, + valueType: "date", + valueUnit: null, + }, + }, + ], + }), + ), + ); + + 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).toContain("**Peak**: 1,757,635,200 at 2025-09-12 00:00"); + expect(result).not.toContain("NaN"); + }); + + it("handles timeseries when the API returns ISO datetime string values", 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); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", + () => + HttpResponse.json({ + timeSeries: [ + { + yAxis: "max(timestamp)", + values: [ + // Empty buckets are zero-filled with a numeric 0 by Sentry. + { timestamp: 1757462400000, value: 0, incomplete: false }, + { + timestamp: 1757548800000, + value: "2026-07-11T00:00:00+00:00", + incomplete: false, + }, + { + timestamp: 1757635200000, + value: "2026-07-12T00:00:00+00:00", + incomplete: false, + }, + { + timestamp: 1757721600000, + value: "2026-07-13T00:00:00+00:00", + incomplete: false, + }, + ], + meta: { + interval: 86400000, + valueType: "date", + valueUnit: null, + }, + }, + ], + }), + ), + ); + + 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 appear as-is, and the peak is the latest time. + expect(result).toContain( + "| 2025-09-13 00:00 | 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("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 78fb7fa7a..12ebe1daa 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -1002,9 +1002,16 @@ export function formatTimeSeriesResults(params: { // is permanently partial. Every other reason means data is still arriving. const outsideRetention = bucket.incomplete && bucket.incompleteReason === "OUTSIDE_RETENTION"; + const raw = bucket.value ?? 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); return { time: formatBucketTime(bucket.timestamp), - value: bucket.value ?? 0, + value: isNumeric ? numeric : Date.parse(String(raw)) || 0, + display: isNumeric ? numeric.toLocaleString() : String(raw), filling: bucket.incomplete && !outsideRetention, outsideRetention, }; @@ -1050,7 +1057,7 @@ export function formatTimeSeriesResults(params: { ); } if (peak) { - lines.push(`- **Peak**: ${peak.value.toLocaleString()} at ${peak.time}`); + lines.push(`- **Peak**: ${peak.display} at ${peak.time}`); } if (ingestion) { lines.push(formatIngestionStatus(ingestion)); @@ -1066,7 +1073,7 @@ export function formatTimeSeriesResults(params: { ); for (const p of shown) { const marker = p.filling ? " *" : p.outsideRetention ? " †" : ""; - lines.push(`| ${p.time} | ${p.value.toLocaleString()}${marker} |`); + lines.push(`| ${p.time} | ${p.display}${marker} |`); } // Footnotes describe markers in the visible rows only; older retention // buckets may have been cut by MAX_ROWS.