diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 115a3687c..bdf933a22 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -57,6 +57,7 @@ import { DashboardSchema, DeployListSchema, DetectorSchema, + DroppedEventsResponseSchema, ErrorsSearchResponseSchema, EventAttachmentListSchema, EventSchema, @@ -5207,6 +5208,44 @@ export class SentryApiService { return EventsStatsResponseSchema.parse(body); } + async getDroppedEvents( + { + organizationSlug, + interval, + projectId, + dataset = "spans", + statsPeriod, + start, + end, + }: { + organizationSlug: string; + interval?: string; + projectId?: string; + dataset?: EventsDataset; + statsPeriod?: string; + start?: string; + end?: string; + }, + opts?: RequestOptions, + ) { + const queryParams = new URLSearchParams(); + queryParams.set("dataset", normalizeEventsDataset(dataset)); + if (interval) { + queryParams.set("interval", interval); + } + this.applyTimeParams(queryParams, statsPeriod, start, end); + if (projectId) { + queryParams.set("project", projectId); + } + queryParams.set("referrer", SENTRY_MCP_SEARCH_EVENTS_REFERRER); + + const apiUrl = + apiPath`/organizations/${organizationSlug}/events-dropped/` + + `?${queryParams.toString()}`; + const body = await this.requestJSON(apiUrl, undefined, opts); + return DroppedEventsResponseSchema.parse(body); + } + // POST https://us.sentry.io/api/0/issues/5485083130/autofix/ async startAutofix( { diff --git a/packages/mcp-core/src/api-client/schema.ts b/packages/mcp-core/src/api-client/schema.ts index cccc58224..66bc33a20 100644 --- a/packages/mcp-core/src/api-client/schema.ts +++ b/packages/mcp-core/src/api-client/schema.ts @@ -2454,3 +2454,30 @@ export const EventsStatsResponseSchema = z end: z.number().optional(), }) .passthrough(); + +export const DroppedEventsBucketSchema = z + .object({ + type: z.string(), + category: z.string(), + outcome: z.string(), + reason: z.string(), + start: z.number(), + end: z.number(), + count: z.number(), + }) + .passthrough(); + +export const DroppedEventsResponseSchema = z + .object({ + meta: z + .object({ + dataset: z.string(), + start: z.number(), + end: z.number(), + interval: z.number(), + }) + .passthrough(), + droppedEvents: z.array(DroppedEventsBucketSchema), + acceptedEvents: z.array(DroppedEventsBucketSchema), + }) + .passthrough(); diff --git a/packages/mcp-core/src/server.test.ts b/packages/mcp-core/src/server.test.ts index 57c0cb4de..bce1ba369 100644 --- a/packages/mcp-core/src/server.test.ts +++ b/packages/mcp-core/src/server.test.ts @@ -119,6 +119,7 @@ async function callRegisteredTool( const DEFAULT_DIRECT_TOOL_NAMES = [ "analyze_issue_with_seer", "execute_sentry_tool", + "find_dropped_events", "find_organizations", "find_projects", "get_sentry_resource", @@ -1152,7 +1153,7 @@ describe("buildServer", () => { const result = await callRegisteredTool(server, "search_sentry_tools", { query: "event stacktrace", - limit: 5, + limit: 8, }); const payload = getStructuredContent<{ results: Array<{ name: string }>; diff --git a/packages/mcp-core/src/skillDefinitions.json b/packages/mcp-core/src/skillDefinitions.json index 44b41ac87..6ef639c96 100644 --- a/packages/mcp-core/src/skillDefinitions.json +++ b/packages/mcp-core/src/skillDefinitions.json @@ -5,7 +5,7 @@ "description": "Read-only access to core Sentry data: issues, events, traces, replays, releases, cron monitors, uptime monitors, metric monitors, profiles, documentation, and project metadata", "defaultEnabled": true, "order": 1, - "toolCount": 46, + "toolCount": 47, "tools": [ { "name": "find_alert_rules", @@ -17,6 +17,11 @@ "description": "Find Sentry dashboards in an organization.\n\nUse this tool when you need to:\n- List dashboards in an organization\n- Find a dashboard ID before calling get_dashboard_details\n- Search dashboards by title\n\n\nfind_dashboards(organizationSlug='my-organization')\nfind_dashboards(organizationSlug='my-organization', titleQuery='errors')\n\n\n\n- Dashboard IDs are organization-scoped.\n- Use `get_dashboard_details` after finding the correct dashboard ID.\n", "requiredScopes": ["org:read"] }, + { + "name": "find_dropped_events", + "description": "Find events dropped before they were stored in Sentry — ground-truth data-fidelity information about what was and wasn't captured.\n\nEvents can be dropped client-side in the SDK (sample_rate, before_send) or\nserver-side at ingest (rate limited, over quota, filtered, invalid, abuse/spike\nprotection, cardinality limited). Accepted-only views (searches, aggregates,\ncharts) can't show this, so the data may be incomplete in ways they don't reveal\n— for example, a flat or spiky chart caused entirely by drops.\n\nUse this tool when you need to:\n- Explain why a chart is flat, lower than expected, or doesn't match what the user is sending\n- Confirm the data you need is actually in Sentry (not dropped) before trusting a query, aggregate, or dashboard\n- Attribute a volume anomaly to a specific drop reason (quota, spike protection, sampling, filters)\n- Tell the user why their data is missing and what to do about it (raise quota, fix sampling, etc.)\n\nReturns dropped event volume bucketed over time, with the drop `outcome` and `reason`\nfor each bucket, plus the accepted volume per bucket so you can compute the dropped share.\n\n\nfind_dropped_events(organizationSlug='my-org', dataset='spans', projectSlug='my-project')\nfind_dropped_events(organizationSlug='my-org', dataset='logs', statsPeriod='30d')\n\n\n\n- This is independent of any search query — it reports drops for the whole project/time range.\n- `outcome` is the drop kind (e.g. rate_limited, filtered); `reason` is the sub-cause (e.g. key_quota, sample_rate).\n- An empty `droppedEvents` list means no drops in the window — the data can be trusted.\n", + "requiredScopes": ["event:read"] + }, { "name": "find_metric_monitors", "description": "Find Sentry Metric Monitors that evaluate errors, performance, logs, metrics or crash rates.\nUse this tool to find a monitor ID before inspecting its query and detection conditions with get_metric_monitor_details.\nResults contain native monitor IDs, separate from legacy metric alert IDs. Alerts connected through workflowIds control notifications; inspect them with get_alert_rule(kind='issue').\nOmit projectSlug to search all accessible projects. Pass nextCursor as cursor with the same filters to retrieve more results.\nfind_metric_monitors(organizationSlug='my-org', projectSlug='backend', query='latency')", diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 9fa64538c..0a8fdbaf4 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -2221,6 +2221,184 @@ "skills": ["inspect"], "surface": "catalog" }, + { + "name": "find_dropped_events", + "description": "Find events dropped before they were stored in Sentry — ground-truth data-fidelity information about what was and wasn't captured.\n\nEvents can be dropped client-side in the SDK (sample_rate, before_send) or\nserver-side at ingest (rate limited, over quota, filtered, invalid, abuse/spike\nprotection, cardinality limited). Accepted-only views (searches, aggregates,\ncharts) can't show this, so the data may be incomplete in ways they don't reveal\n— for example, a flat or spiky chart caused entirely by drops.\n\nUse this tool when you need to:\n- Explain why a chart is flat, lower than expected, or doesn't match what the user is sending\n- Confirm the data you need is actually in Sentry (not dropped) before trusting a query, aggregate, or dashboard\n- Attribute a volume anomaly to a specific drop reason (quota, spike protection, sampling, filters)\n- Tell the user why their data is missing and what to do about it (raise quota, fix sampling, etc.)\n\nReturns dropped event volume bucketed over time, with the drop `outcome` and `reason`\nfor each bucket, plus the accepted volume per bucket so you can compute the dropped share.\n\n\nfind_dropped_events(organizationSlug='my-org', dataset='spans', projectSlug='my-project')\nfind_dropped_events(organizationSlug='my-org', dataset='logs', statsPeriod='30d')\n\n\n\n- This is independent of any search query — it reports drops for the whole project/time range.\n- `outcome` is the drop kind (e.g. rate_limited, filtered); `reason` is the sub-cause (e.g. key_quota, sample_rate).\n- An empty `droppedEvents` list means no drops in the window — the data can be trusted.\n", + "inputSchema": { + "type": "object", + "properties": { + "organizationSlug": { + "type": "string", + "description": "The organization's slug. You can find a existing list of organizations you have access to using the `find_organizations()` tool." + }, + "regionUrl": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The region URL for the organization you're querying, if known. For Sentry's Cloud Service (sentry.io), this is typically the region-specific URL like 'https://us.sentry.io'. For self-hosted Sentry installations, this parameter is usually not needed and should be omitted. You can find the correct regionUrl from the organization details using the `find_organizations()` tool." + }, + { + "type": "null" + } + ] + }, + "dataset": { + "default": "spans", + "type": "string", + "enum": ["spans", "logs", "metrics"], + "description": "Which data type to report drops for." + }, + "projectSlug": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "The project's slug. You can find a list of existing projects in an organization using the `find_projects()` tool." + }, + { + "type": "null" + } + ] + }, + "statsPeriod": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "Relative time range, e.g. '24h', '7d', '30d'. Mutually exclusive with start/end." + }, + { + "type": "null" + } + ] + }, + "start": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "Absolute start (ISO 8601). Must be paired with end." + }, + { + "type": "null" + } + ] + }, + "end": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "Absolute end (ISO 8601). Must be paired with start." + }, + { + "type": "null" + } + ] + }, + "interval": { + "default": null, + "anyOf": [ + { + "type": "string", + "description": "Bucket size, e.g. '1h', '1d'. Omit to let Sentry pick for the range." + }, + { + "type": "null" + } + ] + } + }, + "required": ["organizationSlug"] + }, + "outputSchema": { + "type": "object", + "properties": { + "dataset": { + "type": "string" + }, + "interval": { + "type": "number" + }, + "droppedEvents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "outcome": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "category": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "count": { + "type": "number" + } + }, + "required": [ + "outcome", + "reason", + "category", + "start", + "end", + "count" + ], + "additionalProperties": false + } + }, + "acceptedEvents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "outcome": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "category": { + "type": "string" + }, + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "count": { + "type": "number" + } + }, + "required": [ + "outcome", + "reason", + "category", + "start", + "end", + "count" + ], + "additionalProperties": false + } + } + }, + "required": ["dataset", "interval", "droppedEvents", "acceptedEvents"], + "additionalProperties": false + }, + "requiredScopes": ["event:read"], + "skills": ["inspect"], + "surface": "direct" + }, { "name": "find_dsns", "description": "List all Sentry DSNs for a specific project.\n\nUse this tool when you need to:\n- Retrieve a SENTRY_DSN for a specific project\n\n\n- If the user passes a parameter in the form of name/otherName, its likely in the format of /.\n- If only one parameter is provided, and it could be either `organizationSlug` or `projectSlug`, its probably `organizationSlug`, but if you're really uncertain you might want to call `find_organizations()` first.\n", diff --git a/packages/mcp-core/src/tools/catalog/find-dropped-events.test.ts b/packages/mcp-core/src/tools/catalog/find-dropped-events.test.ts new file mode 100644 index 000000000..d48efe1f0 --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-dropped-events.test.ts @@ -0,0 +1,107 @@ +import { mswServer } from "@sentry/mcp-server-mocks"; +import { HttpResponse, http } from "msw"; +import { describe, expect, it } from "vitest"; +import { + assertStructuredOnlyResult, + getStructuredContent, +} from "../../test-utils/structured-content.js"; +import findDroppedEvents, { + findDroppedEventsOutputSchema, +} from "./find-dropped-events.js"; + +const context = { + constraints: { + organizationSlug: null, + }, + accessToken: "access-token", + userId: "1", +}; + +const DROPPED_EVENTS_RESPONSE = { + meta: { + dataset: "spans", + start: 1_700_000_000_000, + end: 1_700_003_600_000, + interval: 3_600_000, + }, + droppedEvents: [ + { + type: "system", + category: "span", + outcome: "rate_limited", + reason: "key_quota", + start: 1_700_000_000_000, + end: 1_700_003_600_000, + count: 400, + }, + ], + acceptedEvents: [ + { + type: "system", + category: "span", + outcome: "accepted", + reason: "accepted", + start: 1_700_000_000_000, + end: 1_700_003_600_000, + count: 1000, + }, + ], +}; + +describe("find_dropped_events", () => { + it("returns dropped and accepted buckets for an org", async () => { + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/sentry-mcp-evals/events-dropped/", + () => HttpResponse.json(DROPPED_EVENTS_RESPONSE), + ), + ); + + const result = await findDroppedEvents.handler( + { + organizationSlug: "sentry-mcp-evals", + regionUrl: null, + dataset: "spans", + projectSlug: null, + statsPeriod: "24h", + start: null, + end: null, + interval: null, + }, + context, + ); + + assertStructuredOnlyResult(result); + const structuredContent = getStructuredContent(result); + expect(findDroppedEvents.outputSchema).toBe(findDroppedEventsOutputSchema); + expect(findDroppedEventsOutputSchema.parse(structuredContent)).toEqual( + structuredContent, + ); + expect(structuredContent).toMatchInlineSnapshot(` + { + "acceptedEvents": [ + { + "category": "span", + "count": 1000, + "end": 1700003600000, + "outcome": "accepted", + "reason": "accepted", + "start": 1700000000000, + }, + ], + "dataset": "spans", + "droppedEvents": [ + { + "category": "span", + "count": 400, + "end": 1700003600000, + "outcome": "rate_limited", + "reason": "key_quota", + "start": 1700000000000, + }, + ], + "interval": 3600000, + } + `); + }); +}); diff --git a/packages/mcp-core/src/tools/catalog/find-dropped-events.ts b/packages/mcp-core/src/tools/catalog/find-dropped-events.ts new file mode 100644 index 000000000..e4eaa31bc --- /dev/null +++ b/packages/mcp-core/src/tools/catalog/find-dropped-events.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; +import { apiServiceFromContext } from "../../internal/tool-helpers/api"; +import { defineTool } from "../../internal/tool-helpers/define"; +import { structuredResult } from "../../internal/tool-helpers/results"; +import { + ParamOrganizationSlug, + ParamProjectSlug, + ParamRegionUrl, +} from "../../schema"; +import { setTargetTagsAndAttributes } from "../../telem/scope"; +import type { ServerContext } from "../../types"; + +const DROPPED_EVENTS_DATASETS = ["spans", "logs", "metrics"] as const; + +const droppedBucketSchema = z.object({ + outcome: z.string(), + reason: z.string(), + category: z.string(), + start: z.number(), + end: z.number(), + count: z.number(), +}); + +export const findDroppedEventsOutputSchema = z.object({ + dataset: z.string(), + interval: z.number(), + droppedEvents: z.array(droppedBucketSchema), + acceptedEvents: z.array(droppedBucketSchema), +}); + +export default defineTool({ + name: "find_dropped_events", + skills: ["inspect"], + requiredScopes: ["event:read"], + description: [ + "Find events dropped before they were stored in Sentry — ground-truth data-fidelity information about what was and wasn't captured.", + "", + "Events can be dropped client-side in the SDK (sample_rate, before_send) or", + "server-side at ingest (rate limited, over quota, filtered, invalid, abuse/spike", + "protection, cardinality limited). Accepted-only views (searches, aggregates,", + "charts) can't show this, so the data may be incomplete in ways they don't reveal", + "— for example, a flat or spiky chart caused entirely by drops.", + "", + "Use this tool when you need to:", + "- Explain why a chart is flat, lower than expected, or doesn't match what the user is sending", + "- Confirm the data you need is actually in Sentry (not dropped) before trusting a query, aggregate, or dashboard", + "- Attribute a volume anomaly to a specific drop reason (quota, spike protection, sampling, filters)", + "- Tell the user why their data is missing and what to do about it (raise quota, fix sampling, etc.)", + "", + "Returns dropped event volume bucketed over time, with the drop `outcome` and `reason`", + "for each bucket, plus the accepted volume per bucket so you can compute the dropped share.", + "", + "", + "find_dropped_events(organizationSlug='my-org', dataset='spans', projectSlug='my-project')", + "find_dropped_events(organizationSlug='my-org', dataset='logs', statsPeriod='30d')", + "", + "", + "", + "- This is independent of any search query — it reports drops for the whole project/time range.", + "- `outcome` is the drop kind (e.g. rate_limited, filtered); `reason` is the sub-cause (e.g. key_quota, sample_rate).", + "- An empty `droppedEvents` list means no drops in the window — the data can be trusted.", + "", + ].join("\n"), + inputSchema: { + organizationSlug: ParamOrganizationSlug, + regionUrl: ParamRegionUrl.nullable().default(null), + dataset: z + .enum(DROPPED_EVENTS_DATASETS) + .describe("Which data type to report drops for.") + .default("spans"), + projectSlug: ParamProjectSlug.nullable().default(null), + statsPeriod: z + .string() + .trim() + .describe( + "Relative time range, e.g. '24h', '7d', '30d'. Mutually exclusive with start/end.", + ) + .nullable() + .default(null), + start: z + .string() + .trim() + .describe("Absolute start (ISO 8601). Must be paired with end.") + .nullable() + .default(null), + end: z + .string() + .trim() + .describe("Absolute end (ISO 8601). Must be paired with start.") + .nullable() + .default(null), + interval: z + .string() + .trim() + .describe( + "Bucket size, e.g. '1h', '1d'. Omit to let Sentry pick for the range.", + ) + .nullable() + .default(null), + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: true, + }, + outputSchema: findDroppedEventsOutputSchema, + async handler(params, context: ServerContext) { + const apiService = apiServiceFromContext(context, { + regionUrl: params.regionUrl ?? undefined, + }); + const organizationSlug = params.organizationSlug; + + setTargetTagsAndAttributes({ + organizationSlug, + projectSlug: params.projectSlug ?? undefined, + }); + + let projectId: string | undefined; + if (params.projectSlug) { + const project = await apiService.getProject({ + organizationSlug, + projectSlugOrId: params.projectSlug, + }); + projectId = String(project.id); + } + + const response = await apiService.getDroppedEvents({ + organizationSlug, + dataset: params.dataset, + projectId, + interval: params.interval ?? undefined, + statsPeriod: params.statsPeriod ?? undefined, + start: params.start ?? undefined, + end: params.end ?? undefined, + }); + + const toBucket = (bucket: { + outcome: string; + reason: string; + category: string; + start: number; + end: number; + count: number; + }) => ({ + outcome: bucket.outcome, + reason: bucket.reason, + category: bucket.category, + start: bucket.start, + end: bucket.end, + count: bucket.count, + }); + + return structuredResult({ + dataset: response.meta.dataset, + interval: response.meta.interval, + droppedEvents: response.droppedEvents.map(toBucket), + acceptedEvents: response.acceptedEvents.map(toBucket), + }); + }, +}); diff --git a/packages/mcp-core/src/tools/catalog/index.ts b/packages/mcp-core/src/tools/catalog/index.ts index dbe714170..be6697cb3 100644 --- a/packages/mcp-core/src/tools/catalog/index.ts +++ b/packages/mcp-core/src/tools/catalog/index.ts @@ -45,6 +45,7 @@ import searchTraces from "./search-traces"; import searchMetrics from "./search-metrics"; import searchProfiles from "./search-profiles"; import searchReplays from "./search-replays"; +import findDroppedEvents from "./find-dropped-events"; import createTeam from "./create-team"; import createProject from "./create-project"; import updateProject from "./update-project"; @@ -161,6 +162,7 @@ const catalogTools = { get_doc: getDoc, search_issues: searchIssues, search_issue_events: searchIssueEvents, + find_dropped_events: findDroppedEvents, get_profile: getProfile, get_profile_details: getProfileDetails, get_sentry_mcp_info: getSentryMcpInfo, diff --git a/packages/mcp-core/src/tools/surfaces.ts b/packages/mcp-core/src/tools/surfaces.ts index 571459f2f..dd78a7cb7 100644 --- a/packages/mcp-core/src/tools/surfaces.ts +++ b/packages/mcp-core/src/tools/surfaces.ts @@ -24,6 +24,7 @@ export const TOP_LEVEL_TOOL_NAMES = [ "search_metrics", "search_profiles", "search_replays", + "find_dropped_events", "analyze_issue_with_seer", "search_issues", "get_sentry_resource", diff --git a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md index 37705a4f0..eebfb87b7 100644 --- a/plugins/sentry-mcp-experimental/agents/sentry-mcp.md +++ b/plugins/sentry-mcp-experimental/agents/sentry-mcp.md @@ -12,6 +12,7 @@ mcpServers: allowedTools: - analyze_issue_with_seer - execute_sentry_tool + - find_dropped_events - find_organizations - find_projects - get_sentry_resource diff --git a/plugins/sentry-mcp/agents/sentry-mcp.md b/plugins/sentry-mcp/agents/sentry-mcp.md index 37705a4f0..eebfb87b7 100644 --- a/plugins/sentry-mcp/agents/sentry-mcp.md +++ b/plugins/sentry-mcp/agents/sentry-mcp.md @@ -12,6 +12,7 @@ mcpServers: allowedTools: - analyze_issue_with_seer - execute_sentry_tool + - find_dropped_events - find_organizations - find_projects - get_sentry_resource