Skip to content
Closed
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
92 changes: 92 additions & 0 deletions packages/mcp-core/src/tools/catalog/search-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
) => {
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 () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/mcp-core/src/tools/support/search-events/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -985,6 +987,7 @@ export function formatTimeSeriesResults(params: {
explanation?: string;
timeRange?: SearchTimeRange;
url?: string;
droppedEventsInstruction?: string | null;
}): string {
const {
series,
Expand All @@ -995,6 +998,7 @@ export function formatTimeSeriesResults(params: {
explanation,
timeRange,
url,
droppedEventsInstruction,
} = params;

// String aggregates can return identifiers, even when their values look numeric.
Expand Down Expand Up @@ -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());
}
Expand Down
52 changes: 52 additions & 0 deletions packages/mcp-core/src/tools/support/search-events/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, string> = { 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";
}
Expand Down Expand Up @@ -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,
Expand All @@ -1046,6 +1097,7 @@ async function executeSearchEvents(
explanation,
timeRange: timeParams,
url: statsUrl,
droppedEventsInstruction,
}),
);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/mcp-core/src/utils/events-datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading