From 8b75072165801846cf8e84128d0990b2a57c186f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:33:53 +0000 Subject: [PATCH] feat(mcp): Hint find_dropped_events when a time series has empty buckets Time series results from search_errors, search_logs, search_traces, and search_metrics now point at find_dropped_events for the same org, project, and time range when complete buckets are empty. A gap can mean Sentry dropped the events (quota, rate limits, sampling, filters) rather than none occurring. The hint is omitted when the tool is unavailable. Co-Authored-By: David Cramer --- .../src/tools/catalog/find-dropped-events.ts | 3 +- .../src/tools/catalog/search-events.test.ts | 92 +++++++++++++++++++ .../tools/support/search-events/formatters.ts | 17 ++++ .../src/tools/support/search-events/search.ts | 52 +++++++++++ .../mcp-core/src/utils/events-datasets.ts | 17 ++++ 5 files changed, 179 insertions(+), 2 deletions(-) diff --git a/packages/mcp-core/src/tools/catalog/find-dropped-events.ts b/packages/mcp-core/src/tools/catalog/find-dropped-events.ts index 0ba40458d..1ecc6d825 100644 --- a/packages/mcp-core/src/tools/catalog/find-dropped-events.ts +++ b/packages/mcp-core/src/tools/catalog/find-dropped-events.ts @@ -9,8 +9,7 @@ import { } from "../../schema"; import { setTargetTagsAndAttributes } from "../../telem/scope"; import type { ServerContext } from "../../types"; - -const DROPPED_EVENTS_DATASETS = ["spans", "logs", "metrics", "errors"] as const; +import { DROPPED_EVENTS_DATASETS } from "../../utils/events-datasets"; const DROP_OUTCOMES = [ "rate_limited", 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 8e18abadc..38befd335 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -263,6 +263,98 @@ describe("search_events", () => { expect(result).toContain("**Total**: 16"); expect(result).toContain("**Peak**: 8"); expect(result).toContain("| Time (UTC) | Value |"); + expect(result).not.toContain("find_dropped_events"); + }); + + describe("dropped events hint on empty buckets", () => { + const runLogsSeries = async ( + values: { timestamp: number; value: number | null }[], + availableToolNames?: ReadonlySet, + ) => { + const output = { + dataset: "logs" 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: values.map((v) => ({ ...v, incomplete: false })), + meta: { + interval: 3600000, + valueType: "integer", + valueUnit: null, + }, + }, + ], + }), + ), + ); + return await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + dataset: "logs", + query: "logs 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", + availableToolNames, + }, + ); + }; + + const gappedSeries = [ + { timestamp: 1757548800000, value: 5 }, + { timestamp: 1757552400000, value: 0 }, + { timestamp: 1757556000000, value: null }, + ]; + + it("points at find_dropped_events for the same scope", async () => { + const result = await runLogsSeries(gappedSeries); + + expect(result).toContain("## Next Steps"); + expect(result).toContain( + "Use the Sentry tool `find_dropped_events` to check whether Sentry dropped events in this window: `find_dropped_events(organizationSlug='test-org', dataset='logs', statsPeriod='24h')`", + ); + }); + + it("omits the hint when find_dropped_events is unavailable", async () => { + const result = await runLogsSeries( + gappedSeries, + new Set(["search_logs", "search_sentry_tools", "execute_sentry_tool"]), + ); + + expect(result).not.toContain("find_dropped_events"); + }); }); it("omits Total for non-additive timeseries aggregates", async () => { 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 32943e225..db82e4feb 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -975,6 +975,8 @@ function formatIngestionStatus(ingestion: IngestionMeta): string { * 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. + * `droppedEventsInstruction` is shown only when complete buckets are empty, + * since a gap can mean Sentry dropped the events rather than none occurred. */ export function formatTimeSeriesResults(params: { series: EventsTimeSeriesResponse; @@ -985,6 +987,7 @@ export function formatTimeSeriesResults(params: { explanation?: string; timeRange?: SearchTimeRange; url?: string; + droppedEventsInstruction?: string | null; }): string { const { series, @@ -995,6 +998,7 @@ export function formatTimeSeriesResults(params: { explanation, timeRange, url, + droppedEventsInstruction, } = params; // String aggregates can return identifiers, even when their values look numeric. @@ -1100,6 +1104,19 @@ export function formatTimeSeriesResults(params: { lines.push("", "No data points in this range."); } + const hasEmptyBuckets = + points.length === 0 || + (!isStringSeries && + points.some((p) => !p.filling && !p.outsideRetention && p.value === 0)); + if (droppedEventsInstruction && hasEmptyBuckets) { + lines.push( + "", + "## Next Steps", + "", + `- Empty buckets may mean events were dropped before storage (quota, rate limits, sampling, or filters), not that none occurred. ${droppedEventsInstruction}.`, + ); + } + if (url) { lines.push("", formatSentryDashboardLink(url).trimEnd()); } diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 029a02406..cf44b4940 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -8,6 +8,10 @@ import { UserInputError } from "../../../errors"; import { hasAgentProvider } from "../../../internal/agents/provider-factory"; import { withProviderFallback } from "../../../internal/agents/provider-fallback"; import { apiServiceFromContext } from "../../../internal/tool-helpers/api"; +import { + formatAvailableToolCallInstruction, + formatToolCall, +} from "../../../internal/tool-helpers/tool-call-formatting"; import { ParamOrganizationSlug, ParamPeriod, @@ -20,6 +24,7 @@ import type { Scope } from "../../../permissions"; import type { Skill } from "../../../skills"; import type { ServerContext } from "../../../types"; import { + isDroppedEventsDataset, isMetricsDataset, normalizeEventsDataset, PUBLIC_EVENTS_DATASETS, @@ -135,6 +140,45 @@ function buildRequestFields( : fields; } +/** + * Points time series callers at `find_dropped_events` for the same scope, so a + * gap in the chart can be checked against drops (quota, rate limits, sampling, + * filters). Returns null when the dataset or tool is unavailable. + */ +function formatDroppedEventsInstruction({ + organizationSlug, + dataset, + projectSlug, + timeParams, + context, +}: { + organizationSlug: string; + dataset: PublicEventsDataset; + projectSlug: string | null; + timeParams: { statsPeriod?: string; start?: string; end?: string }; + context: ServerContext; +}): string | null { + if (!isDroppedEventsDataset(dataset)) { + return null; + } + const args: Record = { organizationSlug, dataset }; + if (projectSlug) { + args.projectSlug = projectSlug; + } + for (const [key, value] of Object.entries(timeParams)) { + if (value) { + args[key] = value; + } + } + return formatAvailableToolCallInstruction({ + toolName: "find_dropped_events", + experimentalMode: context.experimentalMode ?? false, + availableToolNames: context.availableToolNames, + directToolNames: context.directToolNames, + purpose: `to check whether Sentry dropped events in this window: \`${formatToolCall({ toolName: "find_dropped_events", arguments: args })}\``, + }); +} + function isTraceItemDataset(dataset: PublicEventsDataset | "replays"): boolean { return dataset === "spans" || dataset === "logs" || dataset === "metrics"; } @@ -1036,6 +1080,13 @@ async function executeSearchEvents( timeParams.start, timeParams.end, ); + const droppedEventsInstruction = formatDroppedEventsInstruction({ + organizationSlug, + dataset, + projectSlug: params.projectSlug, + timeParams, + context, + }); return withLeadingNote( formatTimeSeriesResults({ series, @@ -1046,6 +1097,7 @@ async function executeSearchEvents( explanation, timeRange: timeParams, url: statsUrl, + droppedEventsInstruction, }), ); } diff --git a/packages/mcp-core/src/utils/events-datasets.ts b/packages/mcp-core/src/utils/events-datasets.ts index 9e6b5b811..65373426b 100644 --- a/packages/mcp-core/src/utils/events-datasets.ts +++ b/packages/mcp-core/src/utils/events-datasets.ts @@ -7,6 +7,23 @@ export const PUBLIC_EVENTS_DATASETS = [ ] as const; export type PublicEventsDataset = (typeof PUBLIC_EVENTS_DATASETS)[number]; + +/** Datasets the events-dropped endpoint (`find_dropped_events`) reports on. */ +export const DROPPED_EVENTS_DATASETS = [ + "spans", + "logs", + "metrics", + "errors", +] as const; + +export type DroppedEventsDataset = (typeof DROPPED_EVENTS_DATASETS)[number]; + +export function isDroppedEventsDataset( + dataset: string, +): dataset is DroppedEventsDataset { + return (DROPPED_EVENTS_DATASETS as readonly string[]).includes(dataset); +} + export type EventsApiDataset = | "spans" | "errors"