diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index ff31c8ca9..d942cff05 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/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index 66fedef47..3408e0ae5 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 33766f851..df8615d6a 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,252 @@ 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.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 78fb7fa7a..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,14 +997,24 @@ 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. 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: + !isStringSeries && isNumeric ? numeric.toLocaleString() : String(raw), filling: bucket.incomplete && !outsideRetention, outsideRetention, }; @@ -1014,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, @@ -1050,7 +1061,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 +1077,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.