Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
131 changes: 131 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
25 changes: 18 additions & 7 deletions packages/mcp-core/src/tools/support/search-events/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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.");
Expand Down
Loading