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
4 changes: 3 additions & 1 deletion packages/mcp-core/src/api-client/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2456,11 +2456,13 @@ export type IngestionMeta = z.infer<typeof IngestionMetaSchema>;
* 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(),
})
Expand Down
171 changes: 171 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 @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions packages/mcp-core/src/tools/support/search-events/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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));
Expand All @@ -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.
Expand Down
Loading