Skip to content
Merged
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
3 changes: 1 addition & 2 deletions packages/mcp-core/src/tools/catalog/find-dropped-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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("");
});
});
Original file line number Diff line number Diff line change
@@ -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<string>(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<string> {
if (!DROPPED_EVENTS_DATASET_SET.has(dataset)) {
return "";
}

let response: Awaited<ReturnType<typeof apiService.getDroppedEvents>>;
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<number, number>();
for (const b of response.acceptedEvents) {
acceptedByStart.set(b.start, (acceptedByStart.get(b.start) ?? 0) + b.count);
}
const droppedByStart = new Map<number, number>();
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<string, number>();
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");
}
57 changes: 38 additions & 19 deletions packages/mcp-core/src/tools/support/search-events/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
);
}

Expand Down
Loading
Loading