From edf216cabae236ca9735ab61bb18c725b4ede8ca Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 7 Oct 2026 15:07:01 -0400 Subject: [PATCH 1/3] feat(search-events): Mark incomplete buckets and report ingestion delay --- docs/specs/search-events.md | 1 + packages/mcp-core/src/api-client/client.ts | 3 +- packages/mcp-core/src/api-client/schema.ts | 29 ++++++- .../src/tools/catalog/search-events.test.ts | 86 +++++++++++++++++++ .../tools/support/search-events/formatters.ts | 70 ++++++++++++--- 5 files changed, 176 insertions(+), 13 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 14037b114..7b2b16b1d 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`. +- Buckets Sentry flags as `incomplete` (still receiving data) are marked with `*` in the table and excluded from the peak; the total is labelled "so far". 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/client.ts b/packages/mcp-core/src/api-client/client.ts index 63de04ea0..780bd1aa5 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -5141,7 +5141,8 @@ export class SentryApiService { /** * Fetch a timeseries (events-timeseries) for a single yAxis, bucketed over - * time. + * time. Buckets that may still receive data are flagged `incomplete`, and + * `meta.ingestion` reports the measured ingestion delay when available. * * `interval` is optional: omit it to let Sentry pick a sensible bucket size * for the range (mirrors get_interval_from_range in the Sentry source). diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index aaefdd6ce..fa0cfeb4e 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2437,15 +2437,32 @@ export const AgenticOnboardingRunSchema = z.object({ stages: z.array(AgenticOnboardingStageStateSchema), }); +/** + * Measured ingestion delay for the queried dataset. Only present for EAP + * datasets (spans, logs, trace metrics) on orgs with the feature enabled. + * `completeThrough` is the time (ms) up to which data is considered complete. + */ +export const IngestionMetaSchema = z + .object({ + status: z.enum(["healthy", "stalled", "idle", "unknown"]), + delaySeconds: z.number().optional(), + completeThrough: z.number().optional(), + }) + .passthrough(); + +export type IngestionMeta = z.infer; + /** * One bucket of an events-timeseries series. `timestamp` is in milliseconds. - * `incomplete` marks buckets that may still receive data. + * `incomplete` marks buckets that may still receive data (the current bucket, + * or anything after `meta.ingestion.completeThrough`). */ export const EventsTimeSeriesValueSchema = z .object({ timestamp: z.number(), value: z.number().nullish(), incomplete: z.boolean(), + incompleteReason: z.string().optional(), }) .passthrough(); @@ -2464,11 +2481,21 @@ export const EventsTimeSeriesResponseSchema = z .object({ // Bucket width in milliseconds interval: z.number(), + valueType: z.string().optional(), + valueUnit: z.string().nullish(), }) .passthrough(), }) .passthrough(), ), + meta: z + .object({ + start: z.number().optional(), + end: z.number().optional(), + ingestion: IngestionMetaSchema.optional(), + }) + .passthrough() + .optional(), }) .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 231cfcc6f..512c5c5fd 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,92 @@ describe("search_events", () => { expect(result).toContain("**Peak**: 8"); }); + it("marks incomplete buckets and reports ingestion delay", async () => { + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "count()", interval: "1h" }, + timeRange: { statsPeriod: "24h" }, + explanation: "", + }; + 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: "count()", + values: [ + { timestamp: 1757548800000, value: 5, incomplete: false }, + { timestamp: 1757552400000, value: 8, incomplete: false }, + { timestamp: 1757556000000, value: 9, incomplete: true }, + ], + meta: { + interval: 3600000, + valueType: "integer", + valueUnit: null, + }, + }, + ], + meta: { + dataset: "errors", + start: 1757462400000, + end: 1757548800000, + ingestion: { + status: "healthy", + delaySeconds: 95, + completeThrough: 1757556600000, + }, + }, + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "errors 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", + }, + ); + + // The incomplete bucket is larger, but it is still filling so it is not the peak. + expect(result).toContain("**Peak**: 8"); + expect(result).toContain("**Total**: 22 (so far)"); + expect(result).toContain("| 2025-09-11 02:00 | 9 * |"); + expect(result).toContain("Incomplete bucket"); + expect(result).toContain( + "**Ingestion**: healthy (~1m 35s behind, data complete through 2025-09-11 02:10 UTC)", + ); + }); + 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 49bd11b30..7f59810a1 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -1,5 +1,8 @@ import type { SentryApiService } from "../../../api-client"; -import type { EventsTimeSeriesResponse } from "../../../api-client/schema"; +import type { + EventsTimeSeriesResponse, + IngestionMeta, +} from "../../../api-client/schema"; import { formatToolCallInstruction } from "../../../internal/tool-helpers/tool-call-formatting"; import { formatUserGeoSummary } from "../../../internal/user-formatting"; import { logInfo } from "../../../telem/logging"; @@ -939,9 +942,38 @@ function isAdditiveAggregate(yAxis: string): boolean { return fn === "count()" || fn.startsWith("sum("); } +function formatBucketTime(timestampMs: number): string { + return new Date(timestampMs).toISOString().slice(0, 16).replace("T", " "); +} + +/** + * One line describing the measured ingestion delay, so the caller knows how + * far behind the data is before reading a trailing dip as a real drop. + */ +function formatIngestionStatus(ingestion: IngestionMeta): string { + const parts: string[] = []; + if (ingestion.delaySeconds !== undefined) { + const seconds = Math.round(ingestion.delaySeconds); + const delay = + seconds >= 60 + ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` + : `${seconds}s`; + parts.push(`~${delay} behind`); + } + if (ingestion.completeThrough !== undefined) { + parts.push( + `data complete through ${formatBucketTime(ingestion.completeThrough)} UTC`, + ); + } + const detail = parts.length > 0 ? ` (${parts.join(", ")})` : ""; + return `- **Ingestion**: ${ingestion.status}${detail}`; +} + /** * Format an events-timeseries result: a metric bucketed over time. * `interval` is null when Sentry chose the bucket size for the range. + * Buckets flagged `incomplete` by Sentry may still receive data, so they are + * marked in the table and excluded from the peak. */ export function formatTimeSeriesResults(params: { series: EventsTimeSeriesResponse; @@ -965,22 +997,25 @@ export function formatTimeSeriesResults(params: { } = params; const points = (series.timeSeries[0]?.values ?? []).map((bucket) => ({ - time: new Date(bucket.timestamp) - .toISOString() - .slice(0, 16) - .replace("T", " "), + time: formatBucketTime(bucket.timestamp), value: bucket.value ?? 0, + incomplete: bucket.incomplete, })); + const hasIncomplete = points.some((p) => p.incomplete); + const ingestion = series.meta?.ingestion; // 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 peak = points.reduce<(typeof points)[number] | undefined>( - (max, p) => (max === undefined || p.value > max.value ? p : max), - undefined, - ); + // Incomplete buckets are still filling, so they can't be the peak yet. + const peak = points + .filter((p) => !p.incomplete) + .reduce<(typeof points)[number] | undefined>( + (max, p) => (max === undefined || p.value > max.value ? p : max), + undefined, + ); const MAX_ROWS = 48; const shown = points.length > MAX_ROWS ? points.slice(-MAX_ROWS) : points; @@ -1001,11 +1036,16 @@ export function formatTimeSeriesResults(params: { ); lines.push(`- **Time range**: ${formatExecutedTimeRange(timeRange)}`); if (total !== null) { - lines.push(`- **Total**: ${total.toLocaleString()}`); + lines.push( + `- **Total**: ${total.toLocaleString()}${hasIncomplete ? " (so far)" : ""}`, + ); } if (peak) { lines.push(`- **Peak**: ${peak.value.toLocaleString()} at ${peak.time}`); } + if (ingestion) { + lines.push(formatIngestionStatus(ingestion)); + } if (shown.length > 0) { lines.push( @@ -1016,7 +1056,15 @@ export function formatTimeSeriesResults(params: { "| --- | --- |", ); for (const p of shown) { - lines.push(`| ${p.time} | ${p.value.toLocaleString()} |`); + lines.push( + `| ${p.time} | ${p.value.toLocaleString()}${p.incomplete ? " *" : ""} |`, + ); + } + if (hasIncomplete) { + lines.push( + "", + "\\* Incomplete bucket: data is still arriving, so the value may rise.", + ); } } else { lines.push("", "No data points in this range."); From db776d8040242650e017560b46e5bec44c091242 Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 7 Oct 2026 17:07:08 -0400 Subject: [PATCH 2/3] fix(search-events): Treat retention-partial buckets as final, not still filling --- docs/specs/search-events.md | 2 +- .../src/tools/catalog/search-events.test.ts | 27 ++++++++--- .../tools/support/search-events/formatters.ts | 46 +++++++++++++------ 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/docs/specs/search-events.md b/docs/specs/search-events.md index 7b2b16b1d..4ca2a6c99 100644 --- a/docs/specs/search-events.md +++ b/docs/specs/search-events.md @@ -138,7 +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`. -- Buckets Sentry flags as `incomplete` (still receiving data) are marked with `*` in the table and excluded from the peak; the total is labelled "so far". When the response carries `meta.ingestion`, an **Ingestion** line reports the measured delay and the time data is complete through. +- 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 512c5c5fd..2541243b0 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -354,9 +354,19 @@ describe("search_events", () => { { yAxis: "count()", values: [ - { timestamp: 1757548800000, value: 5, incomplete: false }, + { + timestamp: 1757548800000, + value: 10, + incomplete: true, + incompleteReason: "OUTSIDE_RETENTION", + }, { timestamp: 1757552400000, value: 8, incomplete: false }, - { timestamp: 1757556000000, value: 9, incomplete: true }, + { + timestamp: 1757556000000, + value: 9, + incomplete: true, + incompleteReason: "NOT_ELAPSED", + }, ], meta: { interval: 3600000, @@ -402,11 +412,16 @@ describe("search_events", () => { }, ); - // The incomplete bucket is larger, but it is still filling so it is not the peak. - expect(result).toContain("**Peak**: 8"); - expect(result).toContain("**Total**: 22 (so far)"); + // The still-filling bucket can't be the peak yet. The retention-partial + // bucket is final, so it still counts and wins here. + expect(result).toContain("**Peak**: 10 at 2025-09-11 00:00"); + expect(result).toContain("**Total**: 27 (so far)"); + expect(result).toContain("| 2025-09-11 00:00 | 10 † |"); expect(result).toContain("| 2025-09-11 02:00 | 9 * |"); - expect(result).toContain("Incomplete bucket"); + expect(result).toContain("Incomplete bucket: data is still arriving"); + expect(result).toContain( + "Partial bucket: it starts before the retention window", + ); expect(result).toContain( "**Ingestion**: healthy (~1m 35s behind, data complete through 2025-09-11 02:10 UTC)", ); 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 7f59810a1..6f8084c81 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -972,8 +972,9 @@ function formatIngestionStatus(ingestion: IngestionMeta): string { /** * Format an events-timeseries result: a metric bucketed over time. * `interval` is null when Sentry chose the bucket size for the range. - * Buckets flagged `incomplete` by Sentry may still receive data, so they are - * marked in the table and excluded from the peak. + * Buckets flagged `incomplete` by Sentry are marked in the table. Those still + * receiving data are also excluded from the peak; those that start before the + * retention window are permanently partial and will not change. */ export function formatTimeSeriesResults(params: { series: EventsTimeSeriesResponse; @@ -996,12 +997,20 @@ export function formatTimeSeriesResults(params: { url, } = params; - const points = (series.timeSeries[0]?.values ?? []).map((bucket) => ({ - time: formatBucketTime(bucket.timestamp), - value: bucket.value ?? 0, - incomplete: bucket.incomplete, - })); - const hasIncomplete = points.some((p) => p.incomplete); + 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"; + return { + time: formatBucketTime(bucket.timestamp), + value: bucket.value ?? 0, + filling: bucket.incomplete && !outsideRetention, + outsideRetention, + }; + }); + const hasFilling = points.some((p) => p.filling); + const hasOutsideRetention = points.some((p) => p.outsideRetention); const ingestion = series.meta?.ingestion; // Total is only meaningful for additive aggregates; summing count_unique / @@ -1009,9 +1018,10 @@ export function formatTimeSeriesResults(params: { const total = isAdditiveAggregate(yAxis) ? points.reduce((sum, p) => sum + p.value, 0) : null; - // Incomplete buckets are still filling, so they can't be the peak yet. + // 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.incomplete) + .filter((p) => !p.filling) .reduce<(typeof points)[number] | undefined>( (max, p) => (max === undefined || p.value > max.value ? p : max), undefined, @@ -1037,7 +1047,7 @@ export function formatTimeSeriesResults(params: { lines.push(`- **Time range**: ${formatExecutedTimeRange(timeRange)}`); if (total !== null) { lines.push( - `- **Total**: ${total.toLocaleString()}${hasIncomplete ? " (so far)" : ""}`, + `- **Total**: ${total.toLocaleString()}${hasFilling ? " (so far)" : ""}`, ); } if (peak) { @@ -1056,14 +1066,20 @@ export function formatTimeSeriesResults(params: { "| --- | --- |", ); for (const p of shown) { + const marker = p.filling ? " *" : p.outsideRetention ? " †" : ""; + lines.push(`| ${p.time} | ${p.value.toLocaleString()}${marker} |`); + } + if (hasFilling || hasOutsideRetention) { + lines.push(""); + } + if (hasFilling) { lines.push( - `| ${p.time} | ${p.value.toLocaleString()}${p.incomplete ? " *" : ""} |`, + "\\* Incomplete bucket: data is still arriving, so the value may rise.", ); } - if (hasIncomplete) { + if (hasOutsideRetention) { lines.push( - "", - "\\* Incomplete bucket: data is still arriving, so the value may rise.", + "† Partial bucket: it starts before the retention window, so older data is missing and the value will not change.", ); } } else { From 47a06bd18a21ea4ad68d63fa8216ffa54e628a2f Mon Sep 17 00:00:00 2001 From: Edward Gou Date: Wed, 7 Oct 2026 17:15:29 -0400 Subject: [PATCH 3/3] fix(search-events): Only footnote markers that appear in the visible rows --- .../src/tools/catalog/search-events.test.ts | 66 +++++++++++++++++++ .../tools/support/search-events/formatters.ts | 11 ++-- 2 files changed, 73 insertions(+), 4 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 2541243b0..04fcced9f 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -427,6 +427,72 @@ describe("search_events", () => { ); }); + it("omits the retention footnote when its buckets are cut from the table", async () => { + const output = { + dataset: "errors" as const, + query: "", + fields: [] as string[], + sort: "-timestamp", + environment: null, + timeSeries: { yAxis: "count()", interval: "1d" }, + timeRange: { statsPeriod: "100d" }, + explanation: "", + }; + 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); + + // 60 daily buckets: the first is outside retention, but only the latest + // 48 rows are rendered, so no † row is visible. + const day = 86400000; + const values = Array.from({ length: 60 }, (_, i) => ({ + timestamp: 1752364800000 + i * day, + value: i + 1, + incomplete: i === 0, + ...(i === 0 ? { incompleteReason: "OUTSIDE_RETENTION" } : {}), + })); + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events-timeseries/", + () => + HttpResponse.json({ + timeSeries: [{ yAxis: "count()", values, meta: { interval: day } }], + }), + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "errors", + query: "errors per day", + fields: null, + sort: null, + period: "100d", + limit: 10, + includeExplanation: false, + }, + { + accessToken: "test-token", + userId: "user-123", + clientId: "client-123", + grantedSkills: new Set(), + constraints: {}, + sentryHost: "sentry.io", + }, + ); + + expect(result).toContain("## Buckets (most recent 48 of 60)"); + expect(result).not.toContain("†"); + expect(result).not.toContain("so far"); + }); + 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 6f8084c81..78fb7fa7a 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -1010,7 +1010,6 @@ export function formatTimeSeriesResults(params: { }; }); const hasFilling = points.some((p) => p.filling); - const hasOutsideRetention = points.some((p) => p.outsideRetention); const ingestion = series.meta?.ingestion; // Total is only meaningful for additive aggregates; summing count_unique / @@ -1069,15 +1068,19 @@ export function formatTimeSeriesResults(params: { const marker = p.filling ? " *" : p.outsideRetention ? " †" : ""; lines.push(`| ${p.time} | ${p.value.toLocaleString()}${marker} |`); } - if (hasFilling || hasOutsideRetention) { + // Footnotes describe markers in the visible rows only; older retention + // buckets may have been cut by MAX_ROWS. + const shownFilling = shown.some((p) => p.filling); + const shownOutsideRetention = shown.some((p) => p.outsideRetention); + if (shownFilling || shownOutsideRetention) { lines.push(""); } - if (hasFilling) { + if (shownFilling) { lines.push( "\\* Incomplete bucket: data is still arriving, so the value may rise.", ); } - if (hasOutsideRetention) { + if (shownOutsideRetention) { lines.push( "† Partial bucket: it starts before the retention window, so older data is missing and the value will not change.", );