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/support/search-events/dropped-events-note.test.ts b/packages/mcp-core/src/tools/support/search-events/dropped-events-note.test.ts new file mode 100644 index 000000000..2bd988bbd --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/dropped-events-note.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import type { SentryApiService } from "../../../api-client"; +import { buildDroppedEventsNote } from "./dropped-events-note"; + +function bucket( + over: Partial<{ + outcome: string; + reason: string; + start: number; + count: number; + tags: string[]; + }>, +) { + return { + type: "system", + category: "span", + outcome: "rate_limited", + reason: "key_quota", + start: 1_700_000_000_000, + end: 1_700_003_600_000, + count: 0, + tags: [] as string[], + ...over, + }; +} + +function stubApi( + response: unknown, + opts: { throws?: boolean } = {}, +): SentryApiService { + return { + getDroppedEvents: async () => { + if (opts.throws) { + throw new Error("boom"); + } + return response; + }, + } as unknown as SentryApiService; +} + +const base = { + organizationSlug: "org", + projectId: "1", + dataset: "errors" as const, + interval: "1h" as string | null, + timeParams: { statsPeriod: "24h" }, + toolAvailable: true, +}; + +describe("buildDroppedEventsNote", () => { + it("emits a note when unintentional drops are material", async () => { + const apiService = stubApi({ + meta: { dataset: "errors", start: 0, end: 0, interval: 3_600_000 }, + droppedEvents: [bucket({ count: 400, tags: ["unintentional"] })], + acceptedEvents: [bucket({ outcome: "accepted", count: 600 })], + }); + const note = await buildDroppedEventsNote({ ...base, apiService }); + // 400 / (400 + 600) = 40% >> 5% threshold + expect(note).toContain("## Dropped Events"); + expect(note).toContain("40.0%"); + expect(note).toContain("find_dropped_events"); + // A real project id scopes both the copy and the hint to the project. + expect(note).toContain("in this project"); + expect(note).toContain("org, project, dataset, and time range"); + }); + + it("says organization and drops project from the hint when no project is selected", async () => { + const apiService = stubApi({ + meta: { dataset: "errors", start: 0, end: 0, interval: 3_600_000 }, + droppedEvents: [bucket({ count: 400, tags: ["unintentional"] })], + acceptedEvents: [bucket({ outcome: "accepted", count: 600 })], + }); + const note = await buildDroppedEventsNote({ + ...base, + apiService, + projectId: undefined, + }); + expect(note).toContain("in this organization"); + expect(note).not.toContain("in this project"); + expect(note).toContain("org, dataset, and time range"); + expect(note).not.toContain("org, project, dataset"); + }); + + it("treats the -1 all-projects sentinel as org-wide", async () => { + const apiService = stubApi({ + meta: { dataset: "errors", start: 0, end: 0, interval: 3_600_000 }, + droppedEvents: [bucket({ count: 400, tags: ["unintentional"] })], + acceptedEvents: [bucket({ outcome: "accepted", count: 600 })], + }); + const note = await buildDroppedEventsNote({ + ...base, + apiService, + projectId: "-1", + }); + expect(note).toContain("in this organization"); + expect(note).toContain("org, dataset, and time range"); + }); + + it("stays silent below the materiality threshold", async () => { + const apiService = stubApi({ + meta: { dataset: "errors", start: 0, end: 0, interval: 3_600_000 }, + droppedEvents: [bucket({ count: 10, tags: ["unintentional"] })], + acceptedEvents: [bucket({ outcome: "accepted", count: 10_000 })], + }); + // 10 / 10_010 ≈ 0.1% < 5% + expect(await buildDroppedEventsNote({ ...base, apiService })).toBe(""); + }); + + it("ignores intentional drops", async () => { + const apiService = stubApi({ + meta: { dataset: "errors", start: 0, end: 0, interval: 3_600_000 }, + droppedEvents: [bucket({ count: 5000, tags: ["intentional"] })], + acceptedEvents: [bucket({ outcome: "accepted", count: 100 })], + }); + // Huge drop share, but all intentional (sampling/filters) → nothing to say. + expect(await buildDroppedEventsNote({ ...base, apiService })).toBe(""); + }); + + it("omits the tool hint when the tool is unavailable", async () => { + const apiService = stubApi({ + meta: { dataset: "errors", start: 0, end: 0, interval: 3_600_000 }, + droppedEvents: [bucket({ count: 400, tags: ["unintentional"] })], + acceptedEvents: [bucket({ outcome: "accepted", count: 600 })], + }); + const note = await buildDroppedEventsNote({ + ...base, + apiService, + toolAvailable: false, + }); + expect(note).toContain("## Dropped Events"); + expect(note).not.toContain("find_dropped_events"); + }); + + it("returns empty for datasets the endpoint does not cover", async () => { + const apiService = stubApi({}); + expect( + await buildDroppedEventsNote({ + ...base, + apiService, + dataset: "profiles" as never, + }), + ).toBe(""); + }); + + it("never throws into the search path when the fetch fails", async () => { + const apiService = stubApi({}, { throws: true }); + expect(await buildDroppedEventsNote({ ...base, apiService })).toBe(""); + }); +}); diff --git a/packages/mcp-core/src/tools/support/search-events/dropped-events-note.ts b/packages/mcp-core/src/tools/support/search-events/dropped-events-note.ts new file mode 100644 index 000000000..121e480c1 --- /dev/null +++ b/packages/mcp-core/src/tools/support/search-events/dropped-events-note.ts @@ -0,0 +1,153 @@ +import { getActiveSpan } from "@sentry/core"; +import type { SentryApiService } from "../../../api-client"; +import { logWarn } from "../../../telem/logging"; +import { + DROPPED_EVENTS_DATASETS, + type PublicEventsDataset, +} from "../../../utils/events-datasets"; + +const DROPPED_EVENTS_DATASET_SET = new Set(DROPPED_EVENTS_DATASETS); + +// A bucket/window is "material" once unintentional drops are at least this +// share of what Sentry accepted + dropped. +const MATERIAL_DROP_SHARE = 0.05; + +const DETAIL_TOOL = "find_dropped_events"; + +interface DroppedBucket { + reason: string; + outcome: string; + start: number; + count: number; + tags?: string[]; +} + +function isUnintentional(bucket: DroppedBucket): boolean { + return !(bucket.tags ?? []).includes("intentional"); +} + +function sumCount(buckets: { count: number }[]): number { + return buckets.reduce((total, b) => total + b.count, 0); +} + +/** + * Fetch drops for the SAME window/granularity as a just-returned timeseries and + * render a markdown note — but only when unintentional drops are material. + * + * Runs in parallel with the search at the call site, swallows its own errors, + * and returns "" whenever there is nothing material to say. It never throws + * into the search path: a drop-fetch failure must not break the search result. + */ +export async function buildDroppedEventsNote({ + apiService, + organizationSlug, + projectId, + dataset, + interval, + timeParams, + toolAvailable, +}: { + apiService: SentryApiService; + organizationSlug: string; + projectId?: string; + dataset: PublicEventsDataset; + interval: string | null; + timeParams: { statsPeriod?: string; start?: string; end?: string }; + toolAvailable: boolean; +}): Promise { + if (!DROPPED_EVENTS_DATASET_SET.has(dataset)) { + return ""; + } + + let response: Awaited>; + try { + response = await apiService.getDroppedEvents({ + organizationSlug, + dataset, + projectId, + interval: interval ?? undefined, + statsPeriod: timeParams.statsPeriod, + start: timeParams.start, + end: timeParams.end, + }); + } catch (error) { + logWarn("Dropped-events note fetch failed; omitting note", { + loggerScope: ["mcp", "search", "dropped-events-note"], + extra: { error: error instanceof Error ? error.message : String(error) }, + }); + return ""; + } + + const dropped = response.droppedEvents.filter(isUnintentional); + const totalDropped = sumCount(dropped); + if (totalDropped === 0) { + return ""; + } + + const totalAccepted = sumCount(response.acceptedEvents); + const denom = totalDropped + totalAccepted; + const share = denom > 0 ? totalDropped / denom : 0; + if (share < MATERIAL_DROP_SHARE) { + return ""; + } + + // Accepted volume per bucket start, to compute a per-bucket share. Catches a + // partial drop that caps a chart without ever leaving a bucket empty — the + // case an empty-bucket-only heuristic misses. + const acceptedByStart = new Map(); + for (const b of response.acceptedEvents) { + acceptedByStart.set(b.start, (acceptedByStart.get(b.start) ?? 0) + b.count); + } + const droppedByStart = new Map(); + for (const b of dropped) { + droppedByStart.set(b.start, (droppedByStart.get(b.start) ?? 0) + b.count); + } + const affectedBuckets = [...droppedByStart.entries()] + .filter(([start, dropCount]) => { + const accepted = acceptedByStart.get(start) ?? 0; + const bucketDenom = dropCount + accepted; + return bucketDenom > 0 && dropCount / bucketDenom >= MATERIAL_DROP_SHARE; + }) + .map(([start]) => start) + .sort((a, b) => a - b) + .map((start) => new Date(start).toISOString()); + + // Dominant drop reasons, highest-volume first. + const byReason = new Map(); + for (const b of dropped) { + byReason.set(b.reason, (byReason.get(b.reason) ?? 0) + b.count); + } + const dominantReasons = [...byReason.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([reason]) => reason); + + getActiveSpan()?.setAttribute("mcp.dropped_events.note_share", share); + + const scopedToProject = projectId !== undefined && projectId !== "-1"; + + const pct = (share * 100).toFixed(1); + const lines = [ + "## Dropped Events", + "", + `⚠️ Sentry dropped **${totalDropped.toLocaleString()}** ${dataset} ` + + `(**${pct}%** of volume) in this ${scopedToProject ? "project" : "organization"} ` + + "over this window, independent of this search's filter. A dip or flatline in " + + "the chart above may be dropped data rather than an actual change.", + `- **Dominant reasons**: ${dominantReasons.join(", ")}`, + ]; + if (affectedBuckets.length > 0) { + lines.push(`- **Affected buckets (UTC)**: ${affectedBuckets.join(", ")}`); + } + if (toolAvailable) { + const scopeHint = scopedToProject + ? "org, project, dataset, and time range" + : "org, dataset, and time range"; + lines.push( + "", + `Call \`${DETAIL_TOOL}\` with the same ${scopeHint} for the full ` + + "breakdown by outcome and reason.", + ); + } + return lines.join("\n"); +} 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..892120636 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -35,6 +35,7 @@ import { RECOMMENDED_FIELDS, TRACE_METRICS_SAMPLE_IDENTITY_FIELDS, } from "./config"; +import { buildDroppedEventsNote } from "./dropped-events-note"; import { getFieldRecoveryHints } from "./field-hints"; import { formatErrorResults, @@ -1006,15 +1007,30 @@ async function executeSearchEvents( // request shape — fields + orderby — which is not what a timeseries // sends (yAxis + interval, no fields/sort). events-timeseries validates // the query server-side, so a bad query still surfaces as an API error. - const series = await apiService.getEventsTimeSeries({ - organizationSlug, - query: timeSeriesQuery, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval ?? undefined, - projectId, - dataset, - ...timeParams, - }); + // Fetch drops for the same window in parallel with the series: wall-clock + // cost is max(series, drops), not the sum. The note is appended only when + // unintentional drops are material. + const [series, droppedEventsNote] = await Promise.all([ + apiService.getEventsTimeSeries({ + organizationSlug, + query: timeSeriesQuery, + yAxis: timeSeries.yAxis, + interval: timeSeries.interval ?? undefined, + projectId, + dataset, + ...timeParams, + }), + buildDroppedEventsNote({ + apiService, + organizationSlug, + projectId, + dataset: dataset as PublicEventsDataset, + interval: timeSeries.interval, + timeParams, + toolAvailable: + context.availableToolNames?.has("find_dropped_events") ?? false, + }), + ]); // A series always has buckets, so count the ones with data. trackSeerOutcome( seerOutcome, @@ -1036,17 +1052,20 @@ async function executeSearchEvents( timeParams.start, timeParams.end, ); + const timeSeriesOutput = formatTimeSeriesResults({ + series, + yAxis: timeSeries.yAxis, + interval: timeSeries.interval, + inputQuery: params.query || timeSeriesQuery, + includeExplanation: params.includeExplanation, + explanation, + timeRange: timeParams, + url: statsUrl, + }); return withLeadingNote( - formatTimeSeriesResults({ - series, - yAxis: timeSeries.yAxis, - interval: timeSeries.interval, - inputQuery: params.query || timeSeriesQuery, - includeExplanation: params.includeExplanation, - explanation, - timeRange: timeParams, - url: statsUrl, - }), + droppedEventsNote + ? `${timeSeriesOutput}\n\n${droppedEventsNote}` + : timeSeriesOutput, ); } diff --git a/packages/mcp-core/src/utils/events-datasets.ts b/packages/mcp-core/src/utils/events-datasets.ts index 9e6b5b811..8a21be32d 100644 --- a/packages/mcp-core/src/utils/events-datasets.ts +++ b/packages/mcp-core/src/utils/events-datasets.ts @@ -7,6 +7,15 @@ export const PUBLIC_EVENTS_DATASETS = [ ] as const; export type PublicEventsDataset = (typeof PUBLIC_EVENTS_DATASETS)[number]; + +export const DROPPED_EVENTS_DATASETS = [ + "spans", + "logs", + "metrics", + "errors", +] as const; + +export type DroppedEventsDataset = (typeof DROPPED_EVENTS_DATASETS)[number]; export type EventsApiDataset = | "spans" | "errors"