From 8d0ce49b589740ebb0f21130b6db56bd22a4c787 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 2 Mar 2026 10:58:50 -0800 Subject: [PATCH 1/7] feat(events): Add cursor pagination to search_events and list_events Both tools shared a 100-result cap with no way to page through large result sets. This adds cursor-based pagination using Sentry's Link headers so users can iterate through all matching events. Key changes: - Add parseLinkCursor() to extract next cursor from Link headers - Add requestJSONWithPagination() in the API client - searchEvents() now accepts cursor param and returns { body, nextCursor } - Raise limit max from 100 to 1000 on both tools - Add pagination section to all three formatters (errors, logs, spans) - Mock server returns Link headers to simulate pagination Co-Authored-By: Claude --- .../mcp-core/src/api-client/client.test.ts | 161 +++++++++++++++++- packages/mcp-core/src/api-client/client.ts | 54 +++++- packages/mcp-core/src/toolDefinitions.json | 16 +- .../mcp-core/src/tools/list-events/index.ts | 15 +- .../src/tools/list-events/list-events.test.ts | 45 +++++ .../mcp-core/src/tools/search-events.test.ts | 10 ++ .../tools/search-events/formatters.test.ts | 105 ++++++++++++ .../src/tools/search-events/formatters.ts | 18 ++ .../src/tools/search-events/handler.ts | 13 +- packages/mcp-server-mocks/src/index.ts | 33 +++- 10 files changed, 455 insertions(+), 15 deletions(-) create mode 100644 packages/mcp-core/src/tools/search-events/formatters.test.ts diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index 109c311d5..fa9bbc56b 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -1,5 +1,5 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; -import { SentryApiService } from "./client"; +import { SentryApiService, parseLinkCursor } from "./client"; import { ConfigurationError } from "../errors"; describe("getIssueUrl", () => { @@ -1164,3 +1164,162 @@ describe("API query builders", () => { }); }); }); + +describe("parseLinkCursor", () => { + it("should return cursor when next has results=true", () => { + const header = [ + '; rel="previous"; results="false"; cursor="0:0:1"', + '; rel="next"; results="true"; cursor="1735689600:0:0"', + ].join(", "); + expect(parseLinkCursor(header)).toBe("1735689600:0:0"); + }); + + it("should return null when next has results=false", () => { + const header = [ + '; rel="previous"; results="true"; cursor="0:0:1"', + '; rel="next"; results="false"; cursor="0:10:0"', + ].join(", "); + expect(parseLinkCursor(header)).toBeNull(); + }); + + it("should return null when there is no next rel", () => { + const header = + '; rel="previous"; results="false"; cursor="0:0:1"'; + expect(parseLinkCursor(header)).toBeNull(); + }); + + it("should return null for null input", () => { + expect(parseLinkCursor(null)).toBeNull(); + }); + + it("should return null for empty string", () => { + expect(parseLinkCursor("")).toBeNull(); + }); + + it("should return null for malformed header without cursor", () => { + const header = '; rel="next"; results="true"'; + expect(parseLinkCursor(header)).toBeNull(); + }); +}); + +describe("searchEvents pagination", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("should pass cursor parameter to API URL", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + "content-type": "application/json", + }), + json: () => Promise.resolve({ data: [] }), + }); + + await apiService.searchEvents({ + organizationSlug: "test-org", + query: "level:error", + fields: ["title"], + dataset: "errors", + cursor: "1735689600:0:0", + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("cursor=1735689600%3A0%3A0"), + expect.any(Object), + ); + }); + + it("should not include cursor param when not provided", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + "content-type": "application/json", + }), + json: () => Promise.resolve({ data: [] }), + }); + + await apiService.searchEvents({ + organizationSlug: "test-org", + query: "level:error", + fields: ["title"], + dataset: "errors", + }); + + const calledUrl = (globalThis.fetch as ReturnType).mock + .calls[0][0] as string; + expect(calledUrl).not.toContain("cursor="); + }); + + it("should return nextCursor from Link header", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + const linkHeader = [ + '; rel="previous"; results="false"; cursor="0:0:1"', + '; rel="next"; results="true"; cursor="1735689600:0:0"', + ].join(", "); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + "content-type": "application/json", + Link: linkHeader, + }), + json: () => Promise.resolve({ data: [] }), + }); + + const result = await apiService.searchEvents({ + organizationSlug: "test-org", + query: "level:error", + fields: ["title"], + dataset: "errors", + }); + + expect(result.nextCursor).toBe("1735689600:0:0"); + expect(result.body).toEqual({ data: [] }); + }); + + it("should return null nextCursor when no more pages", async () => { + const apiService = new SentryApiService({ + host: "sentry.io", + accessToken: "test-token", + }); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + "content-type": "application/json", + }), + json: () => Promise.resolve({ data: [] }), + }); + + const result = await apiService.searchEvents({ + organizationSlug: "test-org", + query: "level:error", + fields: ["title"], + dataset: "errors", + }); + + expect(result.nextCursor).toBeNull(); + expect(result.body).toEqual({ data: [] }); + }); +}); diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 57e7c2113..1ec5c8ec0 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -99,6 +99,34 @@ type RequestOptions = { host?: string; }; +/** + * Parses Sentry's Link header to extract the next page cursor. + * + * Sentry uses Link headers in the format: + * ; rel="previous"; results="false"; cursor="..." + * ; rel="next"; results="true"; cursor="..." + * + * Returns the cursor when rel="next" and results="true", else null. + */ +export function parseLinkCursor(linkHeader: string | null): string | null { + if (!linkHeader) return null; + + // Split on comma to get individual link entries + const parts = linkHeader.split(","); + for (const part of parts) { + // Check for rel="next" and results="true" + if (!part.includes('rel="next"') || !part.includes('results="true"')) { + continue; + } + // Extract cursor value + const cursorMatch = part.match(/cursor="([^"]+)"/); + if (cursorMatch) { + return cursorMatch[1]; + } + } + return null; +} + /** * Sentry API client service for interacting with Sentry's REST API. * @@ -472,6 +500,22 @@ export class SentryApiService { return this.parseJsonResponse(response); } + /** + * Makes a request to the Sentry API, parses the JSON response, + * and extracts the next pagination cursor from the Link header. + */ + private async requestJSONWithPagination( + path: string, + options: RequestInit = {}, + requestOptions?: { host?: string }, + ): Promise<{ body: unknown; nextCursor: string | null }> { + const response = await this.request(path, options, requestOptions); + const body = await this.parseJsonResponse(response); + const linkHeader = response.headers?.get("link"); + const nextCursor = parseLinkCursor(linkHeader); + return { body, nextCursor }; + } + /** * Generates a Sentry issue URL for browser navigation. * @@ -2174,6 +2218,7 @@ export class SentryApiService { start, end, sort = "-timestamp", + cursor, }: { organizationSlug: string; query: string; @@ -2185,9 +2230,10 @@ export class SentryApiService { start?: string; end?: string; sort?: string; + cursor?: string; }, opts?: RequestOptions, - ) { + ): Promise<{ body: unknown; nextCursor: string | null }> { let queryParams: URLSearchParams; if (dataset === "errors") { @@ -2217,8 +2263,12 @@ export class SentryApiService { }); } + if (cursor) { + queryParams.set("cursor", cursor); + } + const apiUrl = `/organizations/${organizationSlug}/events/?${queryParams.toString()}`; - return await this.requestJSON(apiUrl, undefined, opts); + return await this.requestJSONWithPagination(apiUrl, undefined, opts); } // POST https://us.sentry.io/api/0/issues/5485083130/autofix/ diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 83910377e..8e9407602 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -646,9 +646,14 @@ "limit": { "type": "number", "minimum": 1, - "maximum": 100, + "maximum": 1000, "default": 10, - "description": "Maximum number of results to return (1-100)" + "description": "Maximum number of results to return (1-1000)" + }, + "cursor": { + "type": ["string", "null"], + "default": null, + "description": "Pagination cursor from a previous response to fetch the next page of results." }, "regionUrl": { "anyOf": [ @@ -1010,10 +1015,15 @@ "limit": { "type": "number", "minimum": 1, - "maximum": 100, + "maximum": 1000, "default": 10, "description": "Maximum number of results to return" }, + "cursor": { + "type": ["string", "null"], + "default": null, + "description": "Pagination cursor from a previous response to fetch the next page of results." + }, "includeExplanation": { "type": "boolean", "default": false, diff --git a/packages/mcp-core/src/tools/list-events/index.ts b/packages/mcp-core/src/tools/list-events/index.ts index 25439dd24..48bb3318b 100644 --- a/packages/mcp-core/src/tools/list-events/index.ts +++ b/packages/mcp-core/src/tools/list-events/index.ts @@ -95,9 +95,16 @@ export default defineTool({ limit: z .number() .min(1) - .max(100) + .max(1000) .default(10) - .describe("Maximum number of results to return (1-100)"), + .describe("Maximum number of results to return (1-1000)"), + cursor: z + .string() + .nullable() + .default(null) + .describe( + "Pagination cursor from a previous response to fetch the next page of results.", + ), regionUrl: ParamRegionUrl.nullable().default(null), }, annotations: { @@ -125,7 +132,7 @@ export default defineTool({ // Use provided fields or defaults for the dataset const fields = params.fields ?? DEFAULT_FIELDS[params.dataset]; - const eventsResponse = await apiService.searchEvents({ + const { body: eventsResponse, nextCursor } = await apiService.searchEvents({ organizationSlug: params.organizationSlug, query: params.query, fields, @@ -134,6 +141,7 @@ export default defineTool({ dataset: params.dataset, sort: params.sort, statsPeriod: params.statsPeriod, + cursor: params.cursor ?? undefined, }); // Type validation @@ -190,6 +198,7 @@ export default defineTool({ explorerUrl, sentryQuery: params.query, fields, + nextCursor, }; switch (params.dataset) { diff --git a/packages/mcp-core/src/tools/list-events/list-events.test.ts b/packages/mcp-core/src/tools/list-events/list-events.test.ts index b1a9d37e2..fef608827 100644 --- a/packages/mcp-core/src/tools/list-events/list-events.test.ts +++ b/packages/mcp-core/src/tools/list-events/list-events.test.ts @@ -18,6 +18,7 @@ describe("list_events", () => { projectSlug: null, statsPeriod: "14d", limit: 10, + cursor: null, regionUrl: null, }, getServerContext(), @@ -41,6 +42,7 @@ describe("list_events", () => { projectSlug: null, statsPeriod: "7d", limit: 10, + cursor: null, regionUrl: null, }, getServerContext(), @@ -51,4 +53,47 @@ describe("list_events", () => { expect(typeof result).toBe("string"); expect(result).toContain("Search Results"); }); + + it("includes pagination section when more results are available", async () => { + // No cursor = first page, mock returns a next cursor + const result = await listEvents.handler( + { + organizationSlug: "sentry-mcp-evals", + dataset: "errors", + query: "", + fields: ["issue", "title", "project", "last_seen()", "count()"], + sort: "-count", + projectSlug: null, + statsPeriod: "14d", + limit: 10, + cursor: null, + regionUrl: null, + }, + getServerContext(), + ); + + expect(result).toContain("More results available"); + expect(result).toContain("cursor"); + }); + + it("does not include pagination section on last page", async () => { + // Passing a cursor = simulates fetching second page, mock returns no next cursor + const result = await listEvents.handler( + { + organizationSlug: "sentry-mcp-evals", + dataset: "errors", + query: "", + fields: ["issue", "title", "project", "last_seen()", "count()"], + sort: "-count", + projectSlug: null, + statsPeriod: "14d", + limit: 10, + cursor: "1735689600:0:0", + regionUrl: null, + }, + getServerContext(), + ); + + expect(result).not.toContain("More results available"); + }); }); diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index 945980279..8373b25bd 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -119,6 +119,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "database queries", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -177,6 +178,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "database errors", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -240,6 +242,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "recent errors with user data", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -296,6 +299,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "error logs", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -327,6 +331,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "some impossible query !@#$%", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -362,6 +367,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "show me errors over time", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -408,6 +414,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "any query", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -447,6 +454,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "any query", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -504,6 +512,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "recent errors", limit: 10, + cursor: null, includeExplanation: false, }, { @@ -578,6 +587,7 @@ describe("search_events", () => { naturalLanguageQuery: "which user agents have the most tool calls yesterday", limit: 10, + cursor: null, includeExplanation: false, }, { diff --git a/packages/mcp-core/src/tools/search-events/formatters.test.ts b/packages/mcp-core/src/tools/search-events/formatters.test.ts new file mode 100644 index 000000000..ef6be1e91 --- /dev/null +++ b/packages/mcp-core/src/tools/search-events/formatters.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { + formatErrorResults, + formatLogResults, + formatSpanResults, +} from "./formatters"; +import type { FormatEventResultsParams } from "./formatters"; +import { SentryApiService } from "../../api-client/client"; + +function makeParams( + overrides: Partial = {}, +): FormatEventResultsParams { + return { + eventData: [{ title: "Test Error", project: "test" }], + naturalLanguageQuery: "test query", + apiService: new SentryApiService({ host: "sentry.io" }), + organizationSlug: "test-org", + explorerUrl: "https://test-org.sentry.io/explore/traces/", + sentryQuery: "level:error", + fields: ["title", "project"], + ...overrides, + }; +} + +describe("formatErrorResults pagination", () => { + it("includes pagination section when nextCursor is present", () => { + const result = formatErrorResults( + makeParams({ nextCursor: "1735689600:0:0" }), + ); + expect(result).toContain("More results available"); + expect(result).toContain('cursor: "1735689600:0:0"'); + }); + + it("does not include pagination section when nextCursor is null", () => { + const result = formatErrorResults(makeParams({ nextCursor: null })); + expect(result).not.toContain("More results available"); + }); + + it("does not include pagination section when nextCursor is undefined", () => { + const result = formatErrorResults(makeParams()); + expect(result).not.toContain("More results available"); + }); +}); + +describe("formatLogResults pagination", () => { + it("includes pagination section when nextCursor is present", () => { + const params = makeParams({ + nextCursor: "1735689600:0:0", + eventData: [ + { message: "Test log", severity: "error", timestamp: "2025-01-01" }, + ], + fields: ["message", "severity", "timestamp"], + }); + const result = formatLogResults(params); + expect(result).toContain("More results available"); + expect(result).toContain('cursor: "1735689600:0:0"'); + }); + + it("does not include pagination section when nextCursor is null", () => { + const params = makeParams({ + nextCursor: null, + eventData: [ + { message: "Test log", severity: "error", timestamp: "2025-01-01" }, + ], + fields: ["message", "severity", "timestamp"], + }); + const result = formatLogResults(params); + expect(result).not.toContain("More results available"); + }); +}); + +describe("formatSpanResults pagination", () => { + it("includes pagination section when nextCursor is present", () => { + const params = makeParams({ + nextCursor: "1735689600:0:0", + eventData: [ + { + "span.op": "http.client", + "span.description": "GET /api", + "span.duration": 120, + }, + ], + fields: ["span.op", "span.description", "span.duration"], + }); + const result = formatSpanResults(params); + expect(result).toContain("More results available"); + expect(result).toContain('cursor: "1735689600:0:0"'); + }); + + it("does not include pagination section when nextCursor is null", () => { + const params = makeParams({ + nextCursor: null, + eventData: [ + { + "span.op": "http.client", + "span.description": "GET /api", + "span.duration": 120, + }, + ], + fields: ["span.op", "span.description", "span.duration"], + }); + const result = formatSpanResults(params); + expect(result).not.toContain("More results available"); + }); +}); diff --git a/packages/mcp-core/src/tools/search-events/formatters.ts b/packages/mcp-core/src/tools/search-events/formatters.ts index cf020e14c..9b1adb461 100644 --- a/packages/mcp-core/src/tools/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/search-events/formatters.ts @@ -27,6 +27,15 @@ export interface FormatEventResultsParams { sentryQuery: string; fields: string[]; explanation?: string; + nextCursor?: string | null; +} + +/** + * Format a pagination section when more results are available. + */ +function formatPaginationSection(nextCursor?: string | null): string { + if (!nextCursor) return ""; + return `\n---\n**More results available.** Use \`cursor\` parameter to fetch the next page:\n\`cursor: "${nextCursor}"\`\n\n`; } /** @@ -43,6 +52,7 @@ export function formatErrorResults(params: FormatEventResultsParams): string { sentryQuery, fields, explanation, + nextCursor, } = params; let output = `# Search Results for "${naturalLanguageQuery}"\n\n`; @@ -145,6 +155,8 @@ export function formatErrorResults(params: FormatEventResultsParams): string { } } + output += formatPaginationSection(nextCursor); + output += "## Next Steps\n\n"; output += "- Get more details about a specific error: Use the Issue ID\n"; output += "- View error groups: Navigate to the Issues page in Sentry\n"; @@ -167,6 +179,7 @@ export function formatLogResults(params: FormatEventResultsParams): string { sentryQuery, fields, explanation, + nextCursor, } = params; let output = `# Search Results for "${naturalLanguageQuery}"\n\n`; @@ -290,6 +303,8 @@ export function formatLogResults(params: FormatEventResultsParams): string { } } + output += formatPaginationSection(nextCursor); + output += "## Next Steps\n\n"; output += "- View related traces: Click on the Trace URL if available\n"; output += @@ -313,6 +328,7 @@ export function formatSpanResults(params: FormatEventResultsParams): string { sentryQuery, fields, explanation, + nextCursor, } = params; let output = `# Search Results for "${naturalLanguageQuery}"\n\n`; @@ -414,6 +430,8 @@ export function formatSpanResults(params: FormatEventResultsParams): string { } } + output += formatPaginationSection(nextCursor); + output += "## Next Steps\n\n"; output += "- View the full trace: Click on the Trace URL above\n"; output += diff --git a/packages/mcp-core/src/tools/search-events/handler.ts b/packages/mcp-core/src/tools/search-events/handler.ts index 543092a95..c7f6d1071 100644 --- a/packages/mcp-core/src/tools/search-events/handler.ts +++ b/packages/mcp-core/src/tools/search-events/handler.ts @@ -71,9 +71,16 @@ export default defineTool({ limit: z .number() .min(1) - .max(100) + .max(1000) .default(10) .describe("Maximum number of results to return"), + cursor: z + .string() + .nullable() + .default(null) + .describe( + "Pagination cursor from a previous response to fetch the next page of results.", + ), includeExplanation: z .boolean() .default(false) @@ -177,7 +184,7 @@ export default defineTool({ timeParams.statsPeriod = "14d"; } - const eventsResponse = await apiService.searchEvents({ + const { body: eventsResponse, nextCursor } = await apiService.searchEvents({ organizationSlug, query: sentryQuery, fields, @@ -185,6 +192,7 @@ export default defineTool({ projectId, // API requires numeric project ID, not slug dataset, // API now accepts "logs" directly (no longer needs "ourlogs") sort: sortParam, + cursor: params.cursor ?? undefined, ...timeParams, // Spread the time parameters }); @@ -247,6 +255,7 @@ export default defineTool({ sentryQuery, fields, explanation: parsed.explanation, + nextCursor, }; switch (dataset) { diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index f21a34e47..972b9a377 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -602,11 +602,30 @@ export const restHandlers = buildHandlers([ const dataset = url.searchParams.get("dataset"); const query = url.searchParams.get("query"); const fields = url.searchParams.getAll("field"); + const cursor = url.searchParams.get("cursor"); + + // Build Link header for pagination simulation + // When no cursor: include a next cursor (simulating first page) + // When cursor is present: no next cursor (simulating last page) + const linkHeaders: Record = {}; + if (!cursor) { + linkHeaders.Link = [ + `<${request.url}&cursor=0:10:0>; rel="previous"; results="false"; cursor="0:0:1"`, + `<${request.url}&cursor=0:10:0>; rel="next"; results="true"; cursor="1735689600:0:0"`, + ].join(", "); + } else { + linkHeaders.Link = [ + `<${request.url}&cursor=0:0:1>; rel="previous"; results="true"; cursor="0:0:1"`, + `<${request.url}&cursor=0:20:0>; rel="next"; results="false"; cursor="0:20:0"`, + ].join(", "); + } if (dataset === "spans") { //[sentryApi] GET https://sentry.io/api/0/organizations/sentry-mcp-evals/events/?dataset=spans&per_page=10&referrer=sentry-mcp&sort=-span.duration&allowAggregateConditions=0&useRpc=1&field=id&field=trace&field=span.op&field=span.description&field=span.duration&field=transaction&field=project&field=timestamp&query=is_transaction%3Atrue if (query !== "is_transaction:true") { - return HttpResponse.json(EmptyEventsSpansPayload); + return HttpResponse.json(EmptyEventsSpansPayload, { + headers: linkHeaders, + }); } if (url.searchParams.get("useRpc") !== "1") { @@ -622,7 +641,9 @@ export const restHandlers = buildHandlers([ ) { return HttpResponse.json("Invalid fields", { status: 400 }); } - return HttpResponse.json(EventsSpansPayload); + return HttpResponse.json(EventsSpansPayload, { + headers: linkHeaders, + }); } if (dataset === "errors") { //https://sentry.io/api/0/organizations/sentry-mcp-evals/events/?dataset=errors&per_page=10&referrer=sentry-mcp&sort=-count&statsPeriod=1w&field=issue&field=title&field=project&field=last_seen%28%29&field=count%28%29&query= @@ -660,10 +681,14 @@ export const restHandlers = buildHandlers([ "user.email:david@sentry.io", ].includes(sortedQuery) ) { - return HttpResponse.json(EmptyEventsErrorsPayload); + return HttpResponse.json(EmptyEventsErrorsPayload, { + headers: linkHeaders, + }); } - return HttpResponse.json(EventsErrorsPayload); + return HttpResponse.json(EventsErrorsPayload, { + headers: linkHeaders, + }); } return HttpResponse.json("Invalid dataset", { status: 400 }); From 88f47e376e0dc67945cbd45ce636423e5bd692a5 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 2 Mar 2026 11:07:32 -0800 Subject: [PATCH 2/7] feat(traces): Show list_events pagination example in get_trace_details Replace the generic "use search_events" hint with a concrete list_events call showing the trace query, dataset, and pagination parameters so AI assistants can page through all spans in a trace. Co-Authored-By: Claude --- .../mcp-core/src/tools/get-trace-details.test.ts | 14 ++++++-------- packages/mcp-core/src/tools/get-trace-details.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/mcp-core/src/tools/get-trace-details.test.ts b/packages/mcp-core/src/tools/get-trace-details.test.ts index 216c60669..5dac9fffc 100644 --- a/packages/mcp-core/src/tools/get-trace-details.test.ts +++ b/packages/mcp-core/src/tools/get-trace-details.test.ts @@ -59,12 +59,11 @@ describe("get_trace_details", () => { ## Find Related Events - Use this search query to find all events in this trace: + To list all spans in this trace, use \`list_events\` with cursor pagination: \`\`\` - trace:a4d1aae7216b47ff8117cf4e09ce9d0a + list_events(organizationSlug='sentry-mcp-evals', dataset='spans', query='trace:a4d1aae7216b47ff8117cf4e09ce9d0a', sort='-timestamp', limit=100) \`\`\` - - You can use this query with the \`search_events\` tool to get detailed event data from this trace." + Use the returned \`cursor\` value to fetch subsequent pages until all spans are retrieved." `); }); @@ -361,12 +360,11 @@ describe("get_trace_details", () => { ## Find Related Events - Use this search query to find all events in this trace: + To list all spans in this trace, use \`list_events\` with cursor pagination: \`\`\` - trace:b4d1aae7216b47ff8117cf4e09ce9d0b + list_events(organizationSlug='sentry-mcp-evals', dataset='spans', query='trace:b4d1aae7216b47ff8117cf4e09ce9d0b', sort='-timestamp', limit=100) \`\`\` - - You can use this query with the \`search_events\` tool to get detailed event data from this trace." + Use the returned \`cursor\` value to fetch subsequent pages until all spans are retrieved." `); }); }); diff --git a/packages/mcp-core/src/tools/get-trace-details.ts b/packages/mcp-core/src/tools/get-trace-details.ts index 03cd80e2a..ff1b2820f 100644 --- a/packages/mcp-core/src/tools/get-trace-details.ts +++ b/packages/mcp-core/src/tools/get-trace-details.ts @@ -471,13 +471,16 @@ function formatTraceOutput({ sections.push(""); sections.push("## Find Related Events"); sections.push(""); - sections.push(`Use this search query to find all events in this trace:`); + sections.push( + "To list all spans in this trace, use `list_events` with cursor pagination:", + ); sections.push("```"); - sections.push(`trace:${traceId}`); + sections.push( + `list_events(organizationSlug='${organizationSlug}', dataset='spans', query='trace:${traceId}', sort='-timestamp', limit=100)`, + ); sections.push("```"); - sections.push(""); sections.push( - "You can use this query with the `search_events` tool to get detailed event data from this trace.", + "Use the returned `cursor` value to fetch subsequent pages until all spans are retrieved.", ); return sections.join("\n"); From 1af1cc764f5fd8e568907d7f1744733ffd4505b0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 2 Mar 2026 11:27:38 -0800 Subject: [PATCH 3/7] fix(events): Remove cursor pagination from search_events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_events and list_events are mutually exclusive — only one is registered depending on whether AI keys are configured. Since search_events re-runs the LLM agent on every call, adding cursor pagination to it would be wasteful and non-deterministic. Cursor pagination only applies to list_events. Co-Authored-By: Claude --- packages/mcp-core/src/toolDefinitions.json | 7 +------ .../mcp-core/src/tools/search-events.test.ts | 20 +++++++++---------- .../src/tools/search-events/handler.ts | 13 ++---------- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 8e9407602..7c8545285 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -1015,15 +1015,10 @@ "limit": { "type": "number", "minimum": 1, - "maximum": 1000, + "maximum": 100, "default": 10, "description": "Maximum number of results to return" }, - "cursor": { - "type": ["string", "null"], - "default": null, - "description": "Pagination cursor from a previous response to fetch the next page of results." - }, "includeExplanation": { "type": "boolean", "default": false, diff --git a/packages/mcp-core/src/tools/search-events.test.ts b/packages/mcp-core/src/tools/search-events.test.ts index 8373b25bd..c710b1e7d 100644 --- a/packages/mcp-core/src/tools/search-events.test.ts +++ b/packages/mcp-core/src/tools/search-events.test.ts @@ -119,7 +119,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "database queries", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -178,7 +178,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "database errors", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -242,7 +242,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "recent errors with user data", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -299,7 +299,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "error logs", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -331,7 +331,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "some impossible query !@#$%", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -367,7 +367,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "show me errors over time", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -414,7 +414,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "any query", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -454,7 +454,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "any query", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -512,7 +512,7 @@ describe("search_events", () => { projectSlug: null, naturalLanguageQuery: "recent errors", limit: 10, - cursor: null, + includeExplanation: false, }, { @@ -587,7 +587,7 @@ describe("search_events", () => { naturalLanguageQuery: "which user agents have the most tool calls yesterday", limit: 10, - cursor: null, + includeExplanation: false, }, { diff --git a/packages/mcp-core/src/tools/search-events/handler.ts b/packages/mcp-core/src/tools/search-events/handler.ts index c7f6d1071..119de2726 100644 --- a/packages/mcp-core/src/tools/search-events/handler.ts +++ b/packages/mcp-core/src/tools/search-events/handler.ts @@ -71,16 +71,9 @@ export default defineTool({ limit: z .number() .min(1) - .max(1000) + .max(100) .default(10) .describe("Maximum number of results to return"), - cursor: z - .string() - .nullable() - .default(null) - .describe( - "Pagination cursor from a previous response to fetch the next page of results.", - ), includeExplanation: z .boolean() .default(false) @@ -184,7 +177,7 @@ export default defineTool({ timeParams.statsPeriod = "14d"; } - const { body: eventsResponse, nextCursor } = await apiService.searchEvents({ + const { body: eventsResponse } = await apiService.searchEvents({ organizationSlug, query: sentryQuery, fields, @@ -192,7 +185,6 @@ export default defineTool({ projectId, // API requires numeric project ID, not slug dataset, // API now accepts "logs" directly (no longer needs "ourlogs") sort: sortParam, - cursor: params.cursor ?? undefined, ...timeParams, // Spread the time parameters }); @@ -255,7 +247,6 @@ export default defineTool({ sentryQuery, fields, explanation: parsed.explanation, - nextCursor, }; switch (dataset) { From 4e77b7a2c28aa453413bb72659da4106722dfa39 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 2 Mar 2026 13:54:33 -0800 Subject: [PATCH 4/7] feat(tools): Add spans resource type and fix dynamic tool name references - Add `spans` resource type to `get_sentry_resource` that renders the complete span tree (up to 1000 spans) for a trace, unlike `get_trace_details` which shows only ~20 selected spans. - Fix hardcoded tool name references (`search_events`, `search_issues`, `list_events`, `list_issues`) in handler output text. These tool pairs are mutually exclusive based on agent provider config, so referencing the wrong one causes the LLM to call a non-existent tool. Added `getEventsToolName()` and `getIssuesToolName()` helpers that use `hasAgentProvider()` to resolve the correct name at runtime. - Extract shared trace rendering utilities (`SelectedSpan`, `renderSpanTree`, `selectInterestingSpans`, `buildFullSpanTree`, etc.) into `trace-rendering.ts` to enable reuse between `get_trace_details` and the new `spans` resource type. - When `get_sentry_resource` handles a `trace` type, it now suggests `get_sentry_resource(resourceType='spans', ...)` for the complete span tree instead of referencing `list_events`/`search_events`. Co-Authored-By: Claude Opus 4.6 --- .../src/internal/tool-helpers/tool-names.ts | 9 + .../internal/tool-helpers/trace-rendering.ts | 246 +++++++++++++++ packages/mcp-core/src/toolDefinitions.json | 6 +- packages/mcp-core/src/tools/get-profile.ts | 5 +- .../src/tools/get-sentry-resource.test.ts | 43 ++- .../mcp-core/src/tools/get-sentry-resource.ts | 131 +++++++- .../src/tools/get-trace-details.test.ts | 8 +- .../mcp-core/src/tools/get-trace-details.ts | 295 ++---------------- .../src/tools/search-issues/formatters.ts | 4 +- 9 files changed, 459 insertions(+), 288 deletions(-) create mode 100644 packages/mcp-core/src/internal/tool-helpers/tool-names.ts create mode 100644 packages/mcp-core/src/internal/tool-helpers/trace-rendering.ts diff --git a/packages/mcp-core/src/internal/tool-helpers/tool-names.ts b/packages/mcp-core/src/internal/tool-helpers/tool-names.ts new file mode 100644 index 000000000..519d18689 --- /dev/null +++ b/packages/mcp-core/src/internal/tool-helpers/tool-names.ts @@ -0,0 +1,9 @@ +import { hasAgentProvider } from "../agents/provider-factory"; + +export function getEventsToolName(): string { + return hasAgentProvider() ? "search_events" : "list_events"; +} + +export function getIssuesToolName(): string { + return hasAgentProvider() ? "search_issues" : "list_issues"; +} diff --git a/packages/mcp-core/src/internal/tool-helpers/trace-rendering.ts b/packages/mcp-core/src/internal/tool-helpers/trace-rendering.ts new file mode 100644 index 000000000..627b1957c --- /dev/null +++ b/packages/mcp-core/src/internal/tool-helpers/trace-rendering.ts @@ -0,0 +1,246 @@ +// Constants for span filtering and tree rendering +export const MAX_DEPTH = 2; +export const MINIMUM_DURATION_THRESHOLD_MS = 10; +export const MIN_MEANINGFUL_CHILD_DURATION = 5; +export const MIN_AVG_DURATION_MS = 5; + +/** + * Filters out non-span items (e.g. issues) from trace data. + * Spans must have a `children` array and a `duration` field. + */ +function filterActualSpans(spans: unknown[]): any[] { + return spans.filter( + (item) => + item && + typeof item === "object" && + "children" in item && + Array.isArray((item as any).children) && + "duration" in item, + ); +} + +export interface SelectedSpan { + event_id: string; + op: string; + name: string | null; + description: string; + duration: number; + is_transaction: boolean; + children: SelectedSpan[]; + level: number; +} + +/** + * Formats a span display name for the tree view. + * + * Uses span.name if available (OTEL-native), otherwise falls back to span.description. + */ +export function formatSpanDisplayName(span: SelectedSpan): string { + if (span.op === "trace") { + return "trace"; + } + return span.name?.trim() || span.description || "unnamed"; +} + +/** + * Renders a hierarchical tree structure of spans using Unicode box-drawing characters. + */ +export function renderSpanTree(spans: SelectedSpan[]): string[] { + const lines: string[] = []; + + function renderSpan(span: SelectedSpan, prefix = "", isLast = true): void { + const shortId = span.event_id.substring(0, 8); + const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ "; + const displayName = formatSpanDisplayName(span); + + if (span.op === "trace") { + lines.push(`${prefix}${connector}${displayName} [${shortId}]`); + } else { + const duration = span.duration + ? `${Math.round(span.duration)}ms` + : "unknown"; + const opDisplay = span.op === "default" ? "" : ` · ${span.op}`; + lines.push( + `${prefix}${connector}${displayName} [${shortId}${opDisplay} · ${duration}]`, + ); + } + + for (let i = 0; i < span.children.length; i++) { + const child = span.children[i]; + const isLastChild = i === span.children.length - 1; + const childPrefix = prefix + (isLast ? " " : "│ "); + renderSpan(child, childPrefix, isLastChild); + } + } + + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + const isLastRoot = i === spans.length - 1; + renderSpan(span, "", isLastRoot); + } + + return lines; +} + +/** + * Flattens a hierarchical span tree into a single array. + * Filters out non-span items (issues) from the trace data. + */ +export function getAllSpansFlattened(spans: unknown[]): any[] { + const result: any[] = []; + const actualSpans = filterActualSpans(spans); + + function collectSpans(spanList: any[]) { + for (const span of spanList) { + result.push(span); + if (span.children && span.children.length > 0) { + collectSpans(span.children); + } + } + } + + collectSpans(actualSpans); + return result; +} + +/** + * Selects a subset of "interesting" spans from a trace for display in the overview. + * + * Creates a fake root span representing the entire trace, with selected interesting + * spans as children. Selection prioritizes: + * + * 1. **Transactions** - Top-level operations + * 2. **Error spans** - Any spans that contain errors + * 3. **Long-running spans** - Operations >= 10ms duration + * 4. **Hierarchical context** - Maintains parent-child relationships + */ +export function selectInterestingSpans( + spans: any[], + traceId: string, + maxSpans = 20, +): SelectedSpan[] { + const selected: SelectedSpan[] = []; + let spanCount = 0; + const actualSpans = filterActualSpans(spans); + + function addSpan(span: any, level: number): boolean { + if (spanCount >= maxSpans || level > MAX_DEPTH) return false; + + const duration = span.duration || 0; + const isTransaction = span.is_transaction; + const hasErrors = span.errors?.length > 0; + + const shouldInclude = + isTransaction || + hasErrors || + level === 0 || + duration >= MINIMUM_DURATION_THRESHOLD_MS; + + if (!shouldInclude) return false; + + const selectedSpan: SelectedSpan = { + event_id: span.event_id, + op: span.op || "unknown", + name: span.name || null, + description: span.description || span.transaction || "unnamed", + duration, + is_transaction: isTransaction, + children: [], + level, + }; + + spanCount++; + + if (level < MAX_DEPTH && span.children?.length > 0) { + const sortedChildren = span.children + .filter((child: any) => child.duration > MIN_MEANINGFUL_CHILD_DURATION) + .sort((a: any, b: any) => (b.duration || 0) - (a.duration || 0)); + + const maxChildren = isTransaction ? 2 : 1; + let addedChildren = 0; + + for (const child of sortedChildren) { + if (addedChildren >= maxChildren || spanCount >= maxSpans) break; + + if (addSpan(child, level + 1)) { + const childSpan = selected[selected.length - 1]; + selectedSpan.children.push(childSpan); + addedChildren++; + } + } + } + + selected.push(selectedSpan); + return true; + } + + const sortedRoots = actualSpans + .sort((a: any, b: any) => (b.duration || 0) - (a.duration || 0)) + .slice(0, 5); + + for (const root of sortedRoots) { + if (spanCount >= maxSpans) break; + addSpan(root, 0); + } + + const rootSpans = selected.filter((span) => span.level === 0); + + const fakeRoot: SelectedSpan = { + event_id: traceId, + op: "trace", + name: null, + description: `Trace ${traceId.substring(0, 8)}`, + duration: 0, + is_transaction: false, + children: rootSpans, + level: -1, + }; + + return [fakeRoot]; +} + +/** + * Converts raw trace data into a full SelectedSpan tree without any filtering. + * Used by the `spans` resource type to show the complete span tree. + */ +export function buildFullSpanTree( + spans: any[], + traceId: string, +): SelectedSpan[] { + const actualSpans = filterActualSpans(spans); + + function convertSpan(span: any, level: number): SelectedSpan { + const children: SelectedSpan[] = []; + if (span.children?.length > 0) { + for (const child of span.children) { + children.push(convertSpan(child, level + 1)); + } + } + + return { + event_id: span.event_id, + op: span.op || "unknown", + name: span.name || null, + description: span.description || span.transaction || "unnamed", + duration: span.duration || 0, + is_transaction: span.is_transaction || false, + children, + level, + }; + } + + const rootSpans = actualSpans.map((span: any) => convertSpan(span, 0)); + + const fakeRoot: SelectedSpan = { + event_id: traceId, + op: "trace", + name: null, + description: `Trace ${traceId.substring(0, 8)}`, + duration: 0, + is_transaction: false, + children: rootSpans, + level: -1, + }; + + return [fakeRoot]; +} diff --git a/packages/mcp-core/src/toolDefinitions.json b/packages/mcp-core/src/toolDefinitions.json index 7c8545285..d89ffe3b7 100644 --- a/packages/mcp-core/src/toolDefinitions.json +++ b/packages/mcp-core/src/toolDefinitions.json @@ -532,12 +532,12 @@ }, "resourceType": { "type": "string", - "enum": ["issue", "event", "trace", "breadcrumbs"], - "description": "Resource type. With a URL, overrides the auto-detected type (e.g., 'breadcrumbs' on an issue URL)." + "enum": ["issue", "event", "trace", "spans", "breadcrumbs"], + "description": "Resource type. With a URL, overrides the auto-detected type (e.g., 'breadcrumbs' on an issue URL). Use 'spans' with a trace ID to get the complete span tree." }, "resourceId": { "type": "string", - "description": "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, or trace ID. Required when not using a URL." + "description": "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, or trace ID. For 'spans', pass the trace ID. Required when not using a URL." }, "organizationSlug": { "type": "string", diff --git a/packages/mcp-core/src/tools/get-profile.ts b/packages/mcp-core/src/tools/get-profile.ts index 7c8062583..870ecf386 100644 --- a/packages/mcp-core/src/tools/get-profile.ts +++ b/packages/mcp-core/src/tools/get-profile.ts @@ -12,6 +12,7 @@ import { } from "./profile/formatter"; import { hasProfileData } from "./profile/analyzer"; import { parseSentryUrl, isProfileUrl } from "../internal/url-helpers"; +import { getEventsToolName } from "../internal/tool-helpers/tool-names"; interface ResolvedProfileParams { organizationSlug: string; @@ -264,7 +265,7 @@ export default defineTool({ "- Profiling may not be enabled for this project", "", "**Suggestions:**", - "- Verify the exact transaction name using search_events", + `- Verify the exact transaction name using ${getEventsToolName()}`, "- Check if profiling is enabled for this project", ].join("\n"); } @@ -325,7 +326,7 @@ export default defineTool({ "- Transaction may not have been executed recently", "", "**Suggestions:**", - "- Verify the exact transaction name using search_events", + `- Verify the exact transaction name using ${getEventsToolName()}`, "- Try a longer time period (e.g., '30d')", "- Check if profiling is enabled for this project", ].join("\n"); diff --git a/packages/mcp-core/src/tools/get-sentry-resource.test.ts b/packages/mcp-core/src/tools/get-sentry-resource.test.ts index 304d0bb69..ac705cc10 100644 --- a/packages/mcp-core/src/tools/get-sentry-resource.test.ts +++ b/packages/mcp-core/src/tools/get-sentry-resource.test.ts @@ -18,7 +18,7 @@ const baseContext = { function callHandler(params: { url?: string; - resourceType?: "issue" | "event" | "trace" | "breadcrumbs"; + resourceType?: "issue" | "event" | "trace" | "spans" | "breadcrumbs"; resourceId?: string; organizationSlug?: string; }) { @@ -419,6 +419,38 @@ describe("get_sentry_resource", () => { expect(result).toContain(`# Trace \`${traceId}\` in **test-org**`); expect(result).toContain("**Total Spans**: 112"); expect(result).toContain("**Errors**: 0"); + // trace via get_sentry_resource should suggest spans resource type + expect(result).toContain("get_sentry_resource(resourceType='spans'"); + }); + + it("fetches spans by traceId", async () => { + const traceId = "a4d1aae7216b47ff8117cf4e09ce9d0a"; + + mswServer.use( + http.get( + `https://sentry.io/api/0/organizations/test-org/trace-meta/${traceId}/`, + () => HttpResponse.json(traceMetaFixture), + { once: true }, + ), + http.get( + `https://sentry.io/api/0/organizations/test-org/trace/${traceId}/`, + () => HttpResponse.json(traceFixture), + { once: true }, + ), + ); + + const result = await callHandler({ + resourceType: "spans", + organizationSlug: "test-org", + resourceId: traceId, + }); + expect(result).toContain( + `# Span Tree for Trace \`${traceId}\` in **test-org**`, + ); + expect(result).toContain("**Total Spans**: 112"); + expect(result).toContain("**Errors**: 0"); + // Should render actual span tree + expect(result).toContain("trace [a4d1aae7]"); }); it("fetches breadcrumbs by issueId", async () => { @@ -586,6 +618,15 @@ describe("get_sentry_resource", () => { ).rejects.toThrow("`resourceId` is required when not using a URL"); }); + it("throws when resourceId missing for spans type", async () => { + await expect( + callHandler({ + resourceType: "spans", + organizationSlug: "my-org", + }), + ).rejects.toThrow("`resourceId` is required when not using a URL"); + }); + it("throws for unsupported explicit resourceType (profile)", async () => { await expect( callHandler({ diff --git a/packages/mcp-core/src/tools/get-sentry-resource.ts b/packages/mcp-core/src/tools/get-sentry-resource.ts index e7e69ed10..53f9ba9dd 100644 --- a/packages/mcp-core/src/tools/get-sentry-resource.ts +++ b/packages/mcp-core/src/tools/get-sentry-resource.ts @@ -9,8 +9,17 @@ import { apiServiceFromContext } from "../internal/tool-helpers/api"; import { ApiNotFoundError } from "../api-client"; import { enhanceNotFoundError } from "../internal/tool-helpers/enhance-error"; import { fetchAndFormatBreadcrumbs } from "../internal/tool-helpers/breadcrumbs"; +import { + getEventsToolName, + getIssuesToolName, +} from "../internal/tool-helpers/tool-names"; +import { + type SelectedSpan, + buildFullSpanTree, + renderSpanTree, +} from "../internal/tool-helpers/trace-rendering"; import getIssueDetails from "./get-issue-details"; -import getTraceDetails from "./get-trace-details"; +import { formatTraceOutput } from "./get-trace-details"; import getProfile from "./get-profile"; /** Types with full API integration. */ @@ -18,6 +27,7 @@ export const FULLY_SUPPORTED_TYPES = [ "issue", "event", "trace", + "spans", "breadcrumbs", ] as const; export type FullySupportedType = (typeof FULLY_SUPPORTED_TYPES)[number]; @@ -120,6 +130,13 @@ export function resolveResourceParams(params: { traceId: resourceId, }; + case "spans": + return { + type: "spans", + organizationSlug, + traceId: resourceId, + }; + case "breadcrumbs": return { type: "breadcrumbs", @@ -142,7 +159,7 @@ function resolveFromParsedUrl( if (detectedType === "unknown") { if (parsed.transaction) { throw new UserInputError( - `Detected a performance summary URL for transaction "${parsed.transaction}". Use \`search_events\` to find traces and performance data for this transaction.`, + `Detected a performance summary URL for transaction "${parsed.transaction}". Use \`${getEventsToolName()}\` to find traces and performance data for this transaction.`, ); } throw new UserInputError( @@ -267,8 +284,8 @@ function generateUnsupportedResourceMessage( "Session replay support is coming soon. In the meantime:", "", `- **View in Sentry**: [Open Replay](${replayUrl})`, - "- **Find related issues**: Use `search_issues` with the replay's time range", - `- **Search events**: Use \`search_events\` with query \`replay_id:${resolved.replayId}\` to find events associated with this replay`, + `- **Find related issues**: Use \`${getIssuesToolName()}\` with the replay's time range`, + `- **Search events**: Use \`${getEventsToolName()}\` with query \`replay_id:${resolved.replayId}\` to find events associated with this replay`, ].join("\n"); } @@ -288,7 +305,7 @@ function generateUnsupportedResourceMessage( "Cron monitor support is coming soon. In the meantime:", "", `- **View in Sentry**: [Open Monitor](${monitorUrl})`, - `- **Search issues**: Use \`search_issues\` with query \`monitor.slug:${resolved.monitorSlug}\` to find issues from this monitor`, + `- **Search issues**: Use \`${getIssuesToolName()}\` with query \`monitor.slug:${resolved.monitorSlug}\` to find issues from this monitor`, ] .filter(Boolean) .join("\n"); @@ -306,7 +323,7 @@ function generateUnsupportedResourceMessage( "", `- **View in Sentry**: [Open Release](${releaseUrl})`, `- **Find releases**: Use \`find_releases(organizationSlug='${organizationSlug}')\` to list releases and their details`, - `- **Search issues**: Use \`search_issues\` with query \`release:${resolved.releaseVersion}\` to find issues in this release`, + `- **Search issues**: Use \`${getIssuesToolName()}\` with query \`release:${resolved.releaseVersion}\` to find issues in this release`, ].join("\n"); } @@ -347,10 +364,10 @@ export default defineTool({ ), resourceType: z - .enum(["issue", "event", "trace", "breadcrumbs"]) + .enum(["issue", "event", "trace", "spans", "breadcrumbs"]) .optional() .describe( - "Resource type. With a URL, overrides the auto-detected type (e.g., 'breadcrumbs' on an issue URL).", + "Resource type. With a URL, overrides the auto-detected type (e.g., 'breadcrumbs' on an issue URL). Use 'spans' with a trace ID to get the complete span tree.", ), resourceId: z @@ -358,7 +375,7 @@ export default defineTool({ .trim() .optional() .describe( - "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, or trace ID. Required when not using a URL.", + "Resource identifier: issue shortId (e.g., 'PROJECT-123'), event ID, or trace ID. For 'spans', pass the trace ID. Required when not using a URL.", ), organizationSlug: ParamOrganizationSlug.optional(), @@ -408,15 +425,99 @@ export default defineTool({ context, ); - case "trace": - return getTraceDetails.handler( - { + case "trace": { + const traceApiService = apiServiceFromContext(context); + const [traceTraceMeta, traceTraceData] = await Promise.all([ + traceApiService.getTraceMeta({ organizationSlug: resolved.organizationSlug, traceId: resolved.traceId!, - regionUrl: null, - }, - context, + statsPeriod: "14d", + }), + traceApiService.getTrace({ + organizationSlug: resolved.organizationSlug, + traceId: resolved.traceId!, + limit: 10, + statsPeriod: "14d", + }), + ]); + return formatTraceOutput({ + organizationSlug: resolved.organizationSlug, + traceId: resolved.traceId!, + traceMeta: traceTraceMeta, + trace: traceTraceData, + apiService: traceApiService, + suggestSpansResource: true, + }); + } + + case "spans": { + const spansApiService = apiServiceFromContext(context); + const traceId = resolved.traceId!; + + if (!/^[0-9a-fA-F]{32}$/.test(traceId)) { + throw new UserInputError( + "Trace ID must be a 32-character hexadecimal string", + ); + } + + const [spansMeta, spansTrace] = await Promise.all([ + spansApiService.getTraceMeta({ + organizationSlug: resolved.organizationSlug, + traceId, + statsPeriod: "14d", + }), + spansApiService.getTrace({ + organizationSlug: resolved.organizationSlug, + traceId, + limit: 1000, + statsPeriod: "14d", + }), + ]); + + const sections: string[] = []; + sections.push( + `# Span Tree for Trace \`${traceId}\` in **${resolved.organizationSlug}**`, ); + sections.push(""); + sections.push(`**Total Spans**: ${spansMeta.span_count}`); + sections.push(`**Errors**: ${spansMeta.errors}`); + sections.push(""); + + if (spansTrace.length > 0) { + const fullTree = buildFullSpanTree(spansTrace, traceId); + const treeLines = renderSpanTree(fullTree); + sections.push(...treeLines); + + // Count rendered spans (excludes the fake trace root) + function countSpans(spans: SelectedSpan[]): number { + let count = 0; + for (const span of spans) { + if (span.op !== "trace") count++; + count += countSpans(span.children); + } + return count; + } + const renderedCount = countSpans(fullTree); + + if (spansMeta.span_count > renderedCount) { + sections.push(""); + sections.push( + `*Showing ${renderedCount} of ${spansMeta.span_count} total spans. The trace has more spans than could be loaded in a single request.*`, + ); + } + } else { + sections.push("No spans found in this trace."); + } + + const traceUrl = spansApiService.getTraceUrl( + resolved.organizationSlug, + traceId, + ); + sections.push(""); + sections.push(`**View in Sentry**: ${traceUrl}`); + + return sections.join("\n"); + } case "breadcrumbs": { const apiService = apiServiceFromContext(context); diff --git a/packages/mcp-core/src/tools/get-trace-details.test.ts b/packages/mcp-core/src/tools/get-trace-details.test.ts index 5dac9fffc..3a976f905 100644 --- a/packages/mcp-core/src/tools/get-trace-details.test.ts +++ b/packages/mcp-core/src/tools/get-trace-details.test.ts @@ -59,9 +59,9 @@ describe("get_trace_details", () => { ## Find Related Events - To list all spans in this trace, use \`list_events\` with cursor pagination: + To list all spans in this trace, use \`search_events\` with cursor pagination: \`\`\` - list_events(organizationSlug='sentry-mcp-evals', dataset='spans', query='trace:a4d1aae7216b47ff8117cf4e09ce9d0a', sort='-timestamp', limit=100) + search_events(organizationSlug='sentry-mcp-evals', dataset='spans', query='trace:a4d1aae7216b47ff8117cf4e09ce9d0a', sort='-timestamp', limit=100) \`\`\` Use the returned \`cursor\` value to fetch subsequent pages until all spans are retrieved." `); @@ -360,9 +360,9 @@ describe("get_trace_details", () => { ## Find Related Events - To list all spans in this trace, use \`list_events\` with cursor pagination: + To list all spans in this trace, use \`search_events\` with cursor pagination: \`\`\` - list_events(organizationSlug='sentry-mcp-evals', dataset='spans', query='trace:b4d1aae7216b47ff8117cf4e09ce9d0b', sort='-timestamp', limit=100) + search_events(organizationSlug='sentry-mcp-evals', dataset='spans', query='trace:b4d1aae7216b47ff8117cf4e09ce9d0b', sort='-timestamp', limit=100) \`\`\` Use the returned \`cursor\` value to fetch subsequent pages until all spans are retrieved." `); diff --git a/packages/mcp-core/src/tools/get-trace-details.ts b/packages/mcp-core/src/tools/get-trace-details.ts index ff1b2820f..61b609357 100644 --- a/packages/mcp-core/src/tools/get-trace-details.ts +++ b/packages/mcp-core/src/tools/get-trace-details.ts @@ -4,12 +4,13 @@ import { apiServiceFromContext } from "../internal/tool-helpers/api"; import { UserInputError } from "../errors"; import type { ServerContext } from "../types"; import { ParamOrganizationSlug, ParamRegionUrl, ParamTraceId } from "../schema"; - -// Constants for span filtering and tree rendering -const MAX_DEPTH = 2; -const MINIMUM_DURATION_THRESHOLD_MS = 10; -const MIN_MEANINGFUL_CHILD_DURATION = 5; -const MIN_AVG_DURATION_MS = 5; +import { + MIN_AVG_DURATION_MS, + selectInterestingSpans, + getAllSpansFlattened, + renderSpanTree, +} from "../internal/tool-helpers/trace-rendering"; +import { getEventsToolName } from "../internal/tool-helpers/tool-names"; export default defineTool({ name: "get_trace_details", @@ -95,222 +96,6 @@ export default defineTool({ }, }); -interface SelectedSpan { - event_id: string; - op: string; - name: string | null; - description: string; - duration: number; - is_transaction: boolean; - children: SelectedSpan[]; - level: number; -} - -/** - * Selects a subset of "interesting" spans from a trace for display in the overview. - * - * Creates a fake root span representing the entire trace, with selected interesting - * spans as children. This provides a unified tree view of the trace. - * - * The goal is to provide a meaningful sample of the trace that highlights the most - * important operations while staying within display limits. Selection prioritizes: - * - * 1. **Transactions** - Top-level operations that represent complete user requests - * 2. **Error spans** - Any spans that contain errors (critical for debugging) - * 3. **Long-running spans** - Operations >= 10ms duration (performance bottlenecks) - * 4. **Hierarchical context** - Maintains parent-child relationships for understanding - * - * Span inclusion rules: - * - All transactions are included (they're typically root-level operations) - * - Spans with errors are always included (debugging importance) - * - Spans with duration >= 10ms are included (performance relevance) - * - Children are recursively added up to 2 levels deep: - * - Transactions can have up to 2 children each - * - Regular spans can have up to 1 child each - * - Total output is capped at maxSpans to prevent overwhelming display - * - * @param spans - Complete array of trace spans with nested children - * @param traceId - Trace ID to display in the fake root span - * @param maxSpans - Maximum number of spans to include in output (default: 20) - * @returns Single-element array containing fake root span with selected spans as children - */ -function selectInterestingSpans( - spans: any[], - traceId: string, - maxSpans = 20, -): SelectedSpan[] { - const selected: SelectedSpan[] = []; - let spanCount = 0; - - // Filter out non-span items (issues) from the trace data - // Spans must have children array, duration, and other span-specific fields - const actualSpans = spans.filter( - (item) => - item && - typeof item === "object" && - "children" in item && - Array.isArray(item.children) && - "duration" in item, - ); - - function addSpan(span: any, level: number): boolean { - if (spanCount >= maxSpans || level > MAX_DEPTH) return false; - - const duration = span.duration || 0; - const isTransaction = span.is_transaction; - const hasErrors = span.errors?.length > 0; - - // Always include transactions and spans with errors - // For regular spans, include if they have reasonable duration or are at root level - const shouldInclude = - isTransaction || - hasErrors || - level === 0 || - duration >= MINIMUM_DURATION_THRESHOLD_MS; - - if (!shouldInclude) return false; - - const selectedSpan: SelectedSpan = { - event_id: span.event_id, - op: span.op || "unknown", - name: span.name || null, - description: span.description || span.transaction || "unnamed", - duration, - is_transaction: isTransaction, - children: [], - level, - }; - - spanCount++; - - // Add up to one interesting child per span, up to MAX_DEPTH levels deep - if (level < MAX_DEPTH && span.children?.length > 0) { - // Sort children by duration (descending) and take the most interesting ones - const sortedChildren = span.children - .filter((child: any) => child.duration > MIN_MEANINGFUL_CHILD_DURATION) // Only children with meaningful duration - .sort((a: any, b: any) => (b.duration || 0) - (a.duration || 0)); - - // Add up to 2 children for transactions, 1 for regular spans - const maxChildren = isTransaction ? 2 : 1; - let addedChildren = 0; - - for (const child of sortedChildren) { - if (addedChildren >= maxChildren || spanCount >= maxSpans) break; - - if (addSpan(child, level + 1)) { - const childSpan = selected[selected.length - 1]; - selectedSpan.children.push(childSpan); - addedChildren++; - } - } - } - - selected.push(selectedSpan); - return true; - } - - // Sort root spans by duration and select the most interesting ones - const sortedRoots = actualSpans - .sort((a, b) => (b.duration || 0) - (a.duration || 0)) - .slice(0, 5); // Start with top 5 root spans - - for (const root of sortedRoots) { - if (spanCount >= maxSpans) break; - addSpan(root, 0); - } - - const rootSpans = selected.filter((span) => span.level === 0); - - // Create fake root span representing the entire trace (no duration - traces are unbounded) - const fakeRoot: SelectedSpan = { - event_id: traceId, - op: "trace", - name: null, - description: `Trace ${traceId.substring(0, 8)}`, - duration: 0, // Traces don't have duration - is_transaction: false, - children: rootSpans, - level: -1, // Mark as fake root - }; - - return [fakeRoot]; -} - -/** - * Formats a span display name for the tree view. - * - * Uses span.name if available (OTEL-native), otherwise falls back to span.description. - * - * @param span - The span to format - * @returns A formatted display name for the span - */ -function formatSpanDisplayName(span: SelectedSpan): string { - // For the fake trace root, just return "trace" - if (span.op === "trace") { - return "trace"; - } - - // Use span.name if available (OTEL-native), otherwise use description - return span.name?.trim() || span.description || "unnamed"; -} - -/** - * Renders a hierarchical tree structure of spans using Unicode box-drawing characters. - * - * Creates a visual tree representation showing parent-child relationships between spans, - * with proper indentation and connecting lines. Each span shows its operation, short ID, - * description, duration, and type (transaction vs span). - * - * Tree format: - * - Root spans have no prefix - * - Child spans use ├─ for intermediate children, └─ for last child - * - Continuation lines use │ for vertical connections - * - Proper spacing maintains visual alignment - * - * @param spans - Array of selected spans with their nested children structure - * @returns Array of formatted markdown strings representing the tree structure - */ -function renderSpanTree(spans: SelectedSpan[]): string[] { - const lines: string[] = []; - - function renderSpan(span: SelectedSpan, prefix = "", isLast = true): void { - const shortId = span.event_id.substring(0, 8); - const connector = prefix === "" ? "" : isLast ? "└─ " : "├─ "; - const displayName = formatSpanDisplayName(span); - - // Don't show duration for the fake trace root span - if (span.op === "trace") { - lines.push(`${prefix}${connector}${displayName} [${shortId}]`); - } else { - const duration = span.duration - ? `${Math.round(span.duration)}ms` - : "unknown"; - - // Don't show 'default' operations as they're not meaningful - const opDisplay = span.op === "default" ? "" : ` · ${span.op}`; - lines.push( - `${prefix}${connector}${displayName} [${shortId}${opDisplay} · ${duration}]`, - ); - } - - // Render children with proper tree indentation - for (let i = 0; i < span.children.length; i++) { - const child = span.children[i]; - const isLastChild = i === span.children.length - 1; - const childPrefix = prefix + (isLast ? " " : "│ "); - renderSpan(child, childPrefix, isLastChild); - } - } - - for (let i = 0; i < spans.length; i++) { - const span = spans[i]; - const isLastRoot = i === spans.length - 1; - renderSpan(span, "", isLastRoot); - } - - return lines; -} - function calculateOperationStats(spans: any[]): Record< string, { @@ -368,45 +153,20 @@ function calculateOperationStats(spans: any[]): Record< return stats; } -function getAllSpansFlattened(spans: any[]): any[] { - const result: any[] = []; - - // Filter out non-span items (issues) from the trace data - // Spans must have children array and duration - const actualSpans = spans.filter( - (item) => - item && - typeof item === "object" && - "children" in item && - Array.isArray(item.children) && - "duration" in item, - ); - - function collectSpans(spanList: any[]) { - for (const span of spanList) { - result.push(span); - if (span.children && span.children.length > 0) { - collectSpans(span.children); - } - } - } - - collectSpans(actualSpans); - return result; -} - -function formatTraceOutput({ +export function formatTraceOutput({ organizationSlug, traceId, traceMeta, trace, apiService, + suggestSpansResource = false, }: { organizationSlug: string; traceId: string; traceMeta: any; trace: any[]; apiService: any; + suggestSpansResource?: boolean; }): string { const sections: string[] = []; @@ -471,17 +231,30 @@ function formatTraceOutput({ sections.push(""); sections.push("## Find Related Events"); sections.push(""); - sections.push( - "To list all spans in this trace, use `list_events` with cursor pagination:", - ); - sections.push("```"); - sections.push( - `list_events(organizationSlug='${organizationSlug}', dataset='spans', query='trace:${traceId}', sort='-timestamp', limit=100)`, - ); - sections.push("```"); - sections.push( - "Use the returned `cursor` value to fetch subsequent pages until all spans are retrieved.", - ); + + if (suggestSpansResource) { + sections.push( + "To view the complete span tree for this trace, use `get_sentry_resource`:", + ); + sections.push("```"); + sections.push( + `get_sentry_resource(resourceType='spans', organizationSlug='${organizationSlug}', resourceId='${traceId}')`, + ); + sections.push("```"); + } else { + const eventsToolName = getEventsToolName(); + sections.push( + `To list all spans in this trace, use \`${eventsToolName}\` with cursor pagination:`, + ); + sections.push("```"); + sections.push( + `${eventsToolName}(organizationSlug='${organizationSlug}', dataset='spans', query='trace:${traceId}', sort='-timestamp', limit=100)`, + ); + sections.push("```"); + sections.push( + "Use the returned `cursor` value to fetch subsequent pages until all spans are retrieved.", + ); + } return sections.join("\n"); } diff --git a/packages/mcp-core/src/tools/search-issues/formatters.ts b/packages/mcp-core/src/tools/search-issues/formatters.ts index 353e52f4e..91b4942ed 100644 --- a/packages/mcp-core/src/tools/search-issues/formatters.ts +++ b/packages/mcp-core/src/tools/search-issues/formatters.ts @@ -2,6 +2,7 @@ import type { Issue } from "../../api-client"; import { logInfo } from "../../telem/logging"; import { getIssueUrl, getIssuesSearchUrl } from "../../utils/url-utils"; import { getSeerActionabilityLabel } from "../../internal/formatting"; +import { getEventsToolName } from "../../internal/tool-helpers/tool-names"; /** * Format an explanation for how a natural language query was translated @@ -134,8 +135,7 @@ export function formatIssueResults(params: FormatIssueResultsParams): string { "- Get more details about a specific issue: Use the Issue ID with get_issue_details\n"; output += "- Update issue status: Use update_issue to resolve or assign issues\n"; - output += - "- View event counts: Use search_events for aggregated statistics\n"; + output += `- View event counts: Use ${getEventsToolName()} for aggregated statistics\n`; // Add feedback-specific guidance if results contain feedback const hasFeedback = issues.some((i) => i.issueCategory === "feedback"); From 74ac3cd5ccaf3ec94ff4d7e6d25eec5da9dd64a4 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 2 Mar 2026 14:28:02 -0800 Subject: [PATCH 5/7] test(trace-rendering): Add comprehensive tests for trace rendering utilities Covers formatSpanDisplayName, renderSpanTree, getAllSpansFlattened, selectInterestingSpans, and buildFullSpanTree with 37 tests including edge cases for filtering, hierarchy, mixed span/issue arrays, and default field handling. Co-Authored-By: Claude Opus 4.6 --- .../tool-helpers/trace-rendering.test.ts | 652 ++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 packages/mcp-core/src/internal/tool-helpers/trace-rendering.test.ts diff --git a/packages/mcp-core/src/internal/tool-helpers/trace-rendering.test.ts b/packages/mcp-core/src/internal/tool-helpers/trace-rendering.test.ts new file mode 100644 index 000000000..9a03015f8 --- /dev/null +++ b/packages/mcp-core/src/internal/tool-helpers/trace-rendering.test.ts @@ -0,0 +1,652 @@ +import { describe, it, expect } from "vitest"; +import { + formatSpanDisplayName, + renderSpanTree, + getAllSpansFlattened, + selectInterestingSpans, + buildFullSpanTree, + type SelectedSpan, +} from "./trace-rendering"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeSpan(overrides: Partial = {}): SelectedSpan { + return { + event_id: "aaaaaaaabbbbccccddddeeeeffffaaaa", + op: "http.client", + name: null, + description: "GET /api/test", + duration: 100, + is_transaction: false, + children: [], + level: 0, + ...overrides, + }; +} + +/** Minimal raw span matching the shape returned by the Sentry trace API. */ +function makeRawSpan(overrides: Record = {}): any { + return { + event_id: "aaaaaaaabbbbccccddddeeeeffffaaaa", + op: "http.client", + description: "GET /api/test", + duration: 100, + is_transaction: false, + children: [], + errors: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// formatSpanDisplayName +// --------------------------------------------------------------------------- + +describe("formatSpanDisplayName", () => { + it("returns 'trace' for trace-op spans", () => { + expect(formatSpanDisplayName(makeSpan({ op: "trace" }))).toBe("trace"); + }); + + it("prefers span.name when present", () => { + expect( + formatSpanDisplayName( + makeSpan({ name: "my-span", description: "fallback" }), + ), + ).toBe("my-span"); + }); + + it("trims whitespace from span.name", () => { + expect(formatSpanDisplayName(makeSpan({ name: " padded " }))).toBe( + "padded", + ); + }); + + it("falls back to description when name is null", () => { + expect( + formatSpanDisplayName(makeSpan({ name: null, description: "desc" })), + ).toBe("desc"); + }); + + it("falls back to description when name is empty string", () => { + expect( + formatSpanDisplayName(makeSpan({ name: " ", description: "desc" })), + ).toBe("desc"); + }); + + it("returns 'unnamed' when both name and description are missing", () => { + expect( + formatSpanDisplayName(makeSpan({ name: null, description: "" })), + ).toBe("unnamed"); + }); +}); + +// --------------------------------------------------------------------------- +// renderSpanTree +// --------------------------------------------------------------------------- + +describe("renderSpanTree", () => { + it("renders a single root span", () => { + const span = makeSpan({ event_id: "aabbccdd11223344" }); + const lines = renderSpanTree([span]); + expect(lines).toEqual(["GET /api/test [aabbccdd · http.client · 100ms]"]); + }); + + it("renders a trace root without duration", () => { + const root = makeSpan({ + event_id: "aabbccdd11223344", + op: "trace", + duration: 0, + }); + const lines = renderSpanTree([root]); + expect(lines).toEqual(["trace [aabbccdd]"]); + }); + + it("omits op display for 'default' op", () => { + const span = makeSpan({ + event_id: "aabbccdd11223344", + op: "default", + duration: 50, + }); + const lines = renderSpanTree([span]); + expect(lines).toEqual(["GET /api/test [aabbccdd · 50ms]"]); + }); + + it("renders parent-child hierarchy with box-drawing characters", () => { + const child1 = makeSpan({ + event_id: "child111aaaabbbb", + description: "child-1", + duration: 30, + }); + const child2 = makeSpan({ + event_id: "child222aaaabbbb", + description: "child-2", + duration: 20, + }); + const root = makeSpan({ + event_id: "root0000aaaabbbb", + op: "trace", + children: [child1, child2], + }); + + const lines = renderSpanTree([root]); + expect(lines).toMatchInlineSnapshot(` + [ + "trace [root0000]", + " ├─ child-1 [child111 · http.client · 30ms]", + " └─ child-2 [child222 · http.client · 20ms]", + ] + `); + }); + + it("renders deeply nested spans with correct indentation", () => { + const grandchild = makeSpan({ + event_id: "grand000aaaabbbb", + description: "grandchild", + duration: 5, + }); + const child = makeSpan({ + event_id: "child000aaaabbbb", + description: "child", + duration: 50, + children: [grandchild], + }); + const root = makeSpan({ + event_id: "root0000aaaabbbb", + op: "trace", + children: [child], + }); + + const lines = renderSpanTree([root]); + expect(lines).toMatchInlineSnapshot(` + [ + "trace [root0000]", + " └─ child [child000 · http.client · 50ms]", + " └─ grandchild [grand000 · http.client · 5ms]", + ] + `); + }); + + it("renders multiple root spans", () => { + const root1 = makeSpan({ + event_id: "root1111aaaabbbb", + description: "first", + duration: 100, + }); + const root2 = makeSpan({ + event_id: "root2222aaaabbbb", + description: "second", + duration: 200, + }); + const lines = renderSpanTree([root1, root2]); + expect(lines).toMatchInlineSnapshot(` + [ + "first [root1111 · http.client · 100ms]", + "second [root2222 · http.client · 200ms]", + ] + `); + }); + + it("shows 'unknown' for zero-duration non-trace spans", () => { + const span = makeSpan({ + event_id: "aabbccdd11223344", + duration: 0, + }); + const lines = renderSpanTree([span]); + expect(lines).toEqual(["GET /api/test [aabbccdd · http.client · unknown]"]); + }); + + it("returns empty array for empty input", () => { + expect(renderSpanTree([])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// getAllSpansFlattened +// --------------------------------------------------------------------------- + +describe("getAllSpansFlattened", () => { + it("flattens a nested span tree", () => { + const grandchild = makeRawSpan({ + event_id: "grandchild", + description: "gc", + }); + const child = makeRawSpan({ + event_id: "child", + description: "c", + children: [grandchild], + }); + const root = makeRawSpan({ + event_id: "root", + description: "r", + children: [child], + }); + + const result = getAllSpansFlattened([root]); + expect(result.map((s: any) => s.event_id)).toEqual([ + "root", + "child", + "grandchild", + ]); + }); + + it("filters out non-span items (issues)", () => { + const span = makeRawSpan({ event_id: "span1" }); + const issue = { + id: 123, + issue_id: 123, + title: "Error", + type: "error", + }; + + const result = getAllSpansFlattened([span, issue as any]); + expect(result).toHaveLength(1); + expect(result[0].event_id).toBe("span1"); + }); + + it("returns empty array for empty input", () => { + expect(getAllSpansFlattened([])).toEqual([]); + }); + + it("handles items without children array", () => { + const noChildren = { event_id: "x", duration: 10 }; + expect(getAllSpansFlattened([noChildren as any])).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// selectInterestingSpans +// --------------------------------------------------------------------------- + +describe("selectInterestingSpans", () => { + const traceId = "aaaaaaaabbbbbbbbccccccccdddddddd"; + + it("wraps results in a fake trace root", () => { + const result = selectInterestingSpans([], traceId); + expect(result).toHaveLength(1); + expect(result[0].op).toBe("trace"); + expect(result[0].event_id).toBe(traceId); + expect(result[0].children).toEqual([]); + }); + + it("includes root-level spans regardless of duration", () => { + const shortSpan = makeRawSpan({ + event_id: "short000aaaabbbb", + duration: 1, // below MINIMUM_DURATION_THRESHOLD_MS + }); + + const result = selectInterestingSpans([shortSpan], traceId); + const root = result[0]; + expect(root.children).toHaveLength(1); + expect(root.children[0].event_id).toBe("short000aaaabbbb"); + }); + + it("includes transactions", () => { + const txn = makeRawSpan({ + event_id: "txn00000aaaabbbb", + is_transaction: true, + duration: 500, + children: [], + }); + + const result = selectInterestingSpans([txn], traceId); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].is_transaction).toBe(true); + }); + + it("filters out non-span items from mixed arrays", () => { + const span = makeRawSpan({ + event_id: "span0000aaaabbbb", + duration: 100, + }); + const issue = { + id: 123, + issue_id: 123, + title: "Error", + type: "error", + timestamp: 123, + }; + + const result = selectInterestingSpans([span, issue as any], traceId); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].event_id).toBe("span0000aaaabbbb"); + }); + + it("respects maxSpans limit", () => { + const spans = Array.from({ length: 30 }, (_, i) => + makeRawSpan({ + event_id: `span${String(i).padStart(4, "0")}aaaabbbb`, + duration: 100 + i, + }), + ); + + const result = selectInterestingSpans(spans, traceId, 5); + const root = result[0]; + // maxSpans=5, and we only take top 5 roots by duration + expect(root.children.length).toBeLessThanOrEqual(5); + }); + + it("sorts roots by duration descending", () => { + const slow = makeRawSpan({ + event_id: "slow0000aaaabbbb", + duration: 1000, + }); + const fast = makeRawSpan({ + event_id: "fast0000aaaabbbb", + duration: 50, + }); + + const result = selectInterestingSpans([fast, slow], traceId); + const root = result[0]; + // slow should come first + expect(root.children[0].event_id).toBe("slow0000aaaabbbb"); + }); + + it("includes children of transactions (up to 2)", () => { + const child1 = makeRawSpan({ + event_id: "ch100000aaaabbbb", + duration: 200, + children: [], + }); + const child2 = makeRawSpan({ + event_id: "ch200000aaaabbbb", + duration: 150, + children: [], + }); + const child3 = makeRawSpan({ + event_id: "ch300000aaaabbbb", + duration: 100, + children: [], + }); + const txn = makeRawSpan({ + event_id: "txn00000aaaabbbb", + is_transaction: true, + duration: 500, + children: [child1, child2, child3], + }); + + const result = selectInterestingSpans([txn], traceId); + const txnSpan = result[0].children[0]; + // Transactions get up to 2 children + expect(txnSpan.children.length).toBeLessThanOrEqual(2); + // Should pick the longest-duration children + expect(txnSpan.children[0].event_id).toBe("ch100000aaaabbbb"); + }); + + it("includes non-transaction children (up to 1)", () => { + const child1 = makeRawSpan({ + event_id: "ch100000aaaabbbb", + duration: 200, + children: [], + }); + const child2 = makeRawSpan({ + event_id: "ch200000aaaabbbb", + duration: 150, + children: [], + }); + const parent = makeRawSpan({ + event_id: "parent00aaaabbbb", + is_transaction: false, + duration: 500, + children: [child1, child2], + }); + + const result = selectInterestingSpans([parent], traceId); + const parentSpan = result[0].children[0]; + // Non-transactions get up to 1 child + expect(parentSpan.children).toHaveLength(1); + expect(parentSpan.children[0].event_id).toBe("ch100000aaaabbbb"); + }); + + it("excludes children below MIN_MEANINGFUL_CHILD_DURATION", () => { + const tinyChild = makeRawSpan({ + event_id: "tiny0000aaaabbbb", + duration: 2, // below MIN_MEANINGFUL_CHILD_DURATION (5ms) + children: [], + }); + const parent = makeRawSpan({ + event_id: "parent00aaaabbbb", + is_transaction: true, + duration: 500, + children: [tinyChild], + }); + + const result = selectInterestingSpans([parent], traceId); + const parentSpan = result[0].children[0]; + expect(parentSpan.children).toHaveLength(0); + }); + + it("defaults missing op to 'unknown'", () => { + const span = makeRawSpan({ + event_id: "noop0000aaaabbbb", + op: undefined, + duration: 100, + }); + + const result = selectInterestingSpans([span], traceId); + expect(result[0].children[0].op).toBe("unknown"); + }); + + it("uses transaction field as fallback description", () => { + const span = makeRawSpan({ + event_id: "txnf0000aaaabbbb", + description: undefined, + transaction: "POST /api/submit", + duration: 100, + }); + + const result = selectInterestingSpans([span], traceId); + expect(result[0].children[0].description).toBe("POST /api/submit"); + }); +}); + +// --------------------------------------------------------------------------- +// buildFullSpanTree +// --------------------------------------------------------------------------- + +describe("buildFullSpanTree", () => { + const traceId = "aaaaaaaabbbbbbbbccccccccdddddddd"; + + it("wraps results in a fake trace root", () => { + const result = buildFullSpanTree([], traceId); + expect(result).toHaveLength(1); + expect(result[0].op).toBe("trace"); + expect(result[0].event_id).toBe(traceId); + expect(result[0].description).toBe("Trace aaaaaaaa"); + expect(result[0].children).toEqual([]); + }); + + it("converts a flat list of root spans", () => { + const span1 = makeRawSpan({ event_id: "span1111aaaabbbb", duration: 100 }); + const span2 = makeRawSpan({ event_id: "span2222aaaabbbb", duration: 200 }); + + const result = buildFullSpanTree([span1, span2], traceId); + const root = result[0]; + expect(root.children).toHaveLength(2); + expect(root.children[0].event_id).toBe("span1111aaaabbbb"); + expect(root.children[1].event_id).toBe("span2222aaaabbbb"); + }); + + it("preserves the full hierarchy without filtering", () => { + const grandchild = makeRawSpan({ + event_id: "gc000000aaaabbbb", + duration: 1, // would be filtered by selectInterestingSpans + children: [], + }); + const child = makeRawSpan({ + event_id: "child000aaaabbbb", + duration: 3, // below MINIMUM_DURATION_THRESHOLD_MS + children: [grandchild], + }); + const root = makeRawSpan({ + event_id: "root0000aaaabbbb", + duration: 5, + children: [child], + }); + + const result = buildFullSpanTree([root], traceId); + const rootSpan = result[0].children[0]; + expect(rootSpan.children).toHaveLength(1); + expect(rootSpan.children[0].event_id).toBe("child000aaaabbbb"); + expect(rootSpan.children[0].children).toHaveLength(1); + expect(rootSpan.children[0].children[0].event_id).toBe("gc000000aaaabbbb"); + }); + + it("assigns correct levels to nested spans", () => { + const grandchild = makeRawSpan({ + event_id: "gc", + children: [], + }); + const child = makeRawSpan({ + event_id: "ch", + children: [grandchild], + }); + const root = makeRawSpan({ + event_id: "rt", + children: [child], + }); + + const result = buildFullSpanTree([root], traceId); + expect(result[0].level).toBe(-1); // fake root + expect(result[0].children[0].level).toBe(0); + expect(result[0].children[0].children[0].level).toBe(1); + expect(result[0].children[0].children[0].children[0].level).toBe(2); + }); + + it("filters out non-span items (issues)", () => { + const span = makeRawSpan({ event_id: "span0000aaaabbbb" }); + const issue = { + id: 999, + issue_id: 999, + title: "TypeError", + type: "error", + timestamp: 123, + }; + + const result = buildFullSpanTree([span, issue as any], traceId); + expect(result[0].children).toHaveLength(1); + expect(result[0].children[0].event_id).toBe("span0000aaaabbbb"); + }); + + it("defaults missing fields gracefully", () => { + const span = makeRawSpan({ + event_id: "bare0000aaaabbbb", + op: undefined, + description: undefined, + duration: undefined, + is_transaction: undefined, + name: undefined, + }); + + const result = buildFullSpanTree([span], traceId); + const converted = result[0].children[0]; + expect(converted.op).toBe("unknown"); + expect(converted.name).toBeNull(); + expect(converted.description).toBe("unnamed"); + expect(converted.duration).toBe(0); + expect(converted.is_transaction).toBe(false); + }); + + it("works with the trace-mixed fixture shape", () => { + // Simulates the mixed span + issue array from the real trace API + const mixedData = [ + makeRawSpan({ + event_id: "aa8e7f3384ef4ff5", + op: "function", + description: "tools/call search_events", + duration: 5203, + is_transaction: false, + children: [ + makeRawSpan({ + event_id: "ad0f7c48fb294de3", + op: "http.client", + description: "POST https://api.openai.com/v1/chat/completions", + duration: 1708, + }), + ], + }), + // Issue item (should be filtered out) + { + id: 6507376925, + issue_id: 6507376925, + title: "Error: Standalone issue", + type: "error", + timestamp: 123, + }, + makeRawSpan({ + event_id: "b4abfe5ed7984c2b", + op: "http.client", + description: + "GET https://us.sentry.io/api/0/organizations/example-org/events/", + duration: 1482, + children: [ + makeRawSpan({ + event_id: "99a97a1d42c3489a", + op: "http.server", + description: + "/api/0/organizations/{organization_id_or_slug}/events/", + duration: 1408, + }), + ], + }), + ]; + + const result = buildFullSpanTree( + mixedData as any[], + "b4d1aae7216b47ff8117cf4e09ce9d0b", + ); + + const root = result[0]; + expect(root.op).toBe("trace"); + // Only 2 real spans at root level (issue is filtered) + expect(root.children).toHaveLength(2); + expect(root.children[0].description).toBe("tools/call search_events"); + expect(root.children[0].children).toHaveLength(1); + expect(root.children[1].description).toContain("GET https://us.sentry.io"); + expect(root.children[1].children).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: buildFullSpanTree + renderSpanTree +// --------------------------------------------------------------------------- + +describe("buildFullSpanTree + renderSpanTree integration", () => { + it("produces a renderable tree from raw trace data", () => { + const raw = [ + makeRawSpan({ + event_id: "root0000aaaabbbb", + op: "http.server", + description: "GET /api/users", + duration: 250, + children: [ + makeRawSpan({ + event_id: "db000000aaaabbbb", + op: "db", + description: "SELECT * FROM users", + duration: 15, + }), + makeRawSpan({ + event_id: "cache000aaaabbbb", + op: "cache", + description: "redis.get user:123", + duration: 3, + }), + ], + }), + ]; + + const tree = buildFullSpanTree(raw, "aaaaaaaabbbbbbbbccccccccdddddddd"); + const lines = renderSpanTree(tree); + + expect(lines).toMatchInlineSnapshot(` + [ + "trace [aaaaaaaa]", + " └─ GET /api/users [root0000 · http.server · 250ms]", + " ├─ SELECT * FROM users [db000000 · db · 15ms]", + " └─ redis.get user:123 [cache000 · cache · 3ms]", + ] + `); + }); +}); From 06746e9aca2a21c35add74b3d397e02918a32587 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 10 Mar 2026 14:45:05 -0700 Subject: [PATCH 6/7] feat(skills): Add Sentry search translation skills Add top-level skills that translate natural-language issue and event searches into direct list_issues and list_events calls. This preserves the useful query-planning behavior from the embedded search agents while we prepare a larger overhaul that can remove the AI search tools themselves. Co-Authored-By: Codex --- skills/sentry-event-search/SKILL.md | 45 ++++++++ .../references/event-query-patterns.md | 106 ++++++++++++++++++ skills/sentry-issue-search/SKILL.md | 38 +++++++ .../references/issue-query-patterns.md | 89 +++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 skills/sentry-event-search/SKILL.md create mode 100644 skills/sentry-event-search/references/event-query-patterns.md create mode 100644 skills/sentry-issue-search/SKILL.md create mode 100644 skills/sentry-issue-search/references/issue-query-patterns.md diff --git a/skills/sentry-event-search/SKILL.md b/skills/sentry-event-search/SKILL.md new file mode 100644 index 000000000..afdf960b8 --- /dev/null +++ b/skills/sentry-event-search/SKILL.md @@ -0,0 +1,45 @@ +--- +name: sentry-event-search +description: Translate natural-language event searches into direct `list_events` calls. Use when asked to "find errors", "show logs", "count issues", "look up spans or traces", "find slow endpoints", "find LLM calls", "count tool calls", "sum tokens", or similar Sentry event searches without `search_events`. Chooses the right dataset, maps natural language to Sentry query syntax, fields, sort, and time windows, and uses `whoami` when user identity filters are needed. +--- + +Replace `search_events` by translating the request into `list_events(...)`. + +## Step 1: Confirm this is an event search + +Use this skill when the user wants: + +- raw errors, logs, or spans +- counts, sums, averages, percentiles, or grouped metrics +- traces, latency, AI/LLM calls, token usage, or MCP tool execution + +Route elsewhere when: + +- The user gives an exact trace ID or trace URL: use `get_trace_details` or `get_sentry_resource`. +- The user gives an exact issue ID or issue URL: use `get_issue_details` or `get_sentry_resource`. +- The user wants grouped issue cards: use `list_issues`. + +## Step 2: Normalize shared inputs + +- Parse `org/project` shorthand directly when present. +- If organization or project is unclear, use `find_organizations` or `find_projects`. +- If the query refers to the current user in event data, call `whoami` and prefer exact `user.id` or `user.email` filters. + +## Step 3: Build the event query + +Read [event-query-patterns.md](./references/event-query-patterns.md) before choosing `dataset`, `query`, `fields`, `sort`, and `statsPeriod`. + +## Step 4: Execute directly + +Call `list_events(...)` with: + +- `organizationSlug` +- `projectSlug` if known +- chosen `dataset` +- translated `query` +- `fields` +- `sort` +- `statsPeriod` +- `limit` + +Do not narrate the translation unless the user asks. diff --git a/skills/sentry-event-search/references/event-query-patterns.md b/skills/sentry-event-search/references/event-query-patterns.md new file mode 100644 index 000000000..26317bdd4 --- /dev/null +++ b/skills/sentry-event-search/references/event-query-patterns.md @@ -0,0 +1,106 @@ +# Event Query Patterns + +Use this reference when translating natural language into `list_events(...)`. + +## Dataset selection + +- `errors`: exceptions, crashes, stack traces, unhandled errors +- `logs`: log lines, severity filtering, debugging output +- `spans`: traces, HTTP calls, database work, slow endpoints, AI/LLM calls, token usage, MCP tools + +For ambiguous operational searches, prefer `spans`. + +## Core syntax rules + +- Use Sentry search syntax, not SQL. +- Never use `IS NULL`, `IS NOT NULL`, `yesterday()`, `today()`, or `now()`. +- Use `has:field` and `!has:field` for field presence checks. +- Put relative time windows in `statsPeriod`, not in the query string. +- Always choose an explicit `sort`. +- If `sort` uses a field, include that field in `fields`. +- For aggregate queries, `fields` should contain only group-by fields and aggregate functions. + +## Default sorts + +- `errors`: `-timestamp` +- `logs`: `-timestamp` +- `spans`: `-span.duration` + +## Aggregate patterns + +- total count: + - `fields=['count()']`, `sort='-count()'` +- grouped count: + - `fields=['field', 'count()']`, `sort='-count()'` +- distinct values: + - also use `['field', 'count()']`, sorted by `-count()` +- averages and sums: + - use `avg(...)`, `sum(...)`, `p75(...)`, `p95(...)` as needed + +## "Me" references + +If the query refers to the current user in event data: + +1. Call `whoami`. +2. Prefer exact `user.id` or `user.email` filters. + +## Performance and span heuristics + +For performance investigations: + +- use `dataset='spans'` +- prefer aggregates over raw samples +- group by `transaction` +- include `count()` +- prefer `p75(span.duration)` or `p95(span.duration)` + +Use duck typing for span classes: + +- web vitals: `has:measurements.lcp`, `has:measurements.cls`, `has:measurements.inp` +- database: `has:db.statement` or `has:db.system` +- HTTP: `has:http.method` or `has:http.url` +- AI/LLM: `has:gen_ai.system` or `has:gen_ai.request.model` +- MCP tools: `has:mcp.tool.name` + +Use `is_transaction:true` only when the user explicitly wants transaction boundaries. + +## LLM and self-debugging patterns + +Prefer `spans` for: + +- which models were called +- how many tool calls happened +- which prompts are slow +- how many input or output tokens were used +- which MCP tools fail or take longest + +Useful fields: + +- identity: `gen_ai.request.model`, `gen_ai.system`, `mcp.tool.name` +- token usage: `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` +- timing: `span.duration`, `transaction`, `span.op` +- correlation: `trace` + +Reusable translations: + +- distinct models: + - `dataset='spans'` + - `fields=['gen_ai.request.model', 'count()']` + - `sort='-count()'` +- tool-call counts: + - `dataset='spans'` + - `fields=['mcp.tool.name', 'count()']` + - `sort='-count()'` +- token-heavy models: + - `dataset='spans'` + - `fields=['gen_ai.request.model', 'sum(gen_ai.usage.input_tokens)', 'sum(gen_ai.usage.output_tokens)']` +- slow endpoints: + - `dataset='spans'` + - `fields=['transaction', 'p75(span.duration)', 'count()']` + - `sort='-p75(span.duration)'` + +## When to route away + +- Exact trace ID or trace URL -> `get_trace_details` or `get_sentry_resource` +- Exact issue ID or issue URL -> `get_issue_details` or `get_sentry_resource` +- Grouped issue lists -> `list_issues` diff --git a/skills/sentry-issue-search/SKILL.md b/skills/sentry-issue-search/SKILL.md new file mode 100644 index 000000000..8c1d00db8 --- /dev/null +++ b/skills/sentry-issue-search/SKILL.md @@ -0,0 +1,38 @@ +--- +name: sentry-issue-search +description: Translate natural-language issue searches into direct `list_issues` calls. Use when asked to "show issues", "find bugs", "what problems do we have", "show user feedback", "issues assigned to me", "critical issues", or similar grouped issue searches in Sentry without `search_issues`. Resolves "me" with `whoami`, maps natural language to Sentry issue query syntax, and picks the correct sort. +--- + +Replace `search_issues` by translating the request into `list_issues(...)`. + +## Step 1: Confirm this is an issue-list request + +Use this skill when the user wants grouped issue cards, not counts or raw events. + +Route elsewhere when: + +- The user gives an exact issue ID or issue URL: use `get_issue_details` or `get_sentry_resource`. +- The user wants counts, totals, averages, or grouped metrics: use `list_events`. +- The user wants raw error, log, or span rows: use `list_events`. + +## Step 2: Normalize shared inputs + +- Parse `org/project` shorthand directly when present. +- If organization or project is unclear, use `find_organizations` or `find_projects`. +- If the request refers to "me", "my", or "myself" for assignment, call `whoami` and use the returned email in `assignedOrSuggested:EMAIL`. + +## Step 3: Build the issue query + +Read [issue-query-patterns.md](./references/issue-query-patterns.md) before constructing the `query` and `sort`. + +## Step 4: Execute directly + +Call `list_issues(...)` with: + +- `organizationSlug` +- `projectSlugOrId` if known +- translated `query` +- chosen `sort` +- `limit` + +Do not narrate the translation unless the user asks. diff --git a/skills/sentry-issue-search/references/issue-query-patterns.md b/skills/sentry-issue-search/references/issue-query-patterns.md new file mode 100644 index 000000000..3e4349851 --- /dev/null +++ b/skills/sentry-issue-search/references/issue-query-patterns.md @@ -0,0 +1,89 @@ +# Issue Query Patterns + +Use this reference when translating natural language into `list_issues(...)`. + +## When to use `list_issues` + +Use `list_issues` for grouped issue and feedback lists: + +- unresolved issues +- bugs affecting many users +- noisiest problems +- issues assigned to a person +- user feedback +- quick wins / actionable issues + +Do not use `list_issues` for counts or aggregations. + +## Core syntax rules + +- Use Sentry issue search syntax, not SQL. +- Relative time goes in the query, for example `lastSeen:-24h` or `firstSeen:-7d`. +- `lastSeen` means recent activity. +- `firstSeen` means newly created issues. +- Do not default to `level:error` just because the user says "critical", "important", or "severe". Those usually mean impact, not explicit severity level filtering. + +## High-value filters + +- unresolved: `is:unresolved` +- unassigned: `is:unassigned` +- feedback: `issueCategory:feedback` +- production only: `environment:production` +- release-specific: `release:VALUE` +- user impact threshold: `userCount:>N` + +## Sort choices + +- `date`: recent activity / default +- `freq`: highest event volume +- `new`: newest issues by first seen time +- `user`: most affected users + +Choose sort based on the user's actual intent: + +- recent or active -> `date` +- noisiest -> `freq` +- newest -> `new` +- most users affected -> `user` + +## "Me" references + +For assignment filters: + +1. Call `whoami`. +2. Use the returned email in `assignedOrSuggested:EMAIL`. + +Examples: + +- "issues assigned to me" -> `assignedOrSuggested:user@example.com` +- "my feedback issues" -> `issueCategory:feedback assignedOrSuggested:user@example.com` + +## Seer actionability + +Use `issue.seer_actionability` when the user asks for easy fixes, quick wins, low-hanging fruit, or actionable issues. + +- quick wins / easy to fix -> `issue.seer_actionability:[high,super_high]` +- actionable issues -> `issue.seer_actionability:[medium,high,super_high]` +- trivial fixes -> `issue.seer_actionability:super_high` + +Usually combine this with `is:unresolved`. + +## Reusable translations + +- unresolved issues: + - `query='is:unresolved'`, `sort='date'` +- worst issues affecting the most users: + - `query='is:unresolved'`, `sort='user'` +- noisiest issues: + - `query='is:unresolved'`, `sort='freq'` +- new issues from last week: + - `query='is:unresolved firstSeen:-7d'`, `sort='new'` +- active issues from last week: + - `query='is:unresolved lastSeen:-7d'`, `sort='date'` +- user feedback in production: + - `query='issueCategory:feedback environment:production'`, `sort='date'` + +## When to route away + +- Exact issue ID or issue URL -> `get_issue_details` or `get_sentry_resource` +- Counts, totals, trends, grouped metrics -> `list_events` From 705797e4e4e332c9240f38f89185f74e1e6901ca Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 10 Mar 2026 16:23:21 -0700 Subject: [PATCH 7/7] fix dotagents --- .agents/.gitignore | 9 -------- .gitignore | 3 +++ agents.lock | 52 ---------------------------------------------- agents.toml | 16 +++++++------- 4 files changed, 12 insertions(+), 68 deletions(-) delete mode 100644 .agents/.gitignore delete mode 100644 agents.lock diff --git a/.agents/.gitignore b/.agents/.gitignore deleted file mode 100644 index 3c2717313..000000000 --- a/.agents/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# Auto-generated by dotagents. Do not edit. -# Managed skills (installed by dotagents) -/skills/agents-md/ -/skills/brand-guidelines/ -/skills/claude-settings-audit/ -/skills/code-simplifier/ -/skills/commit/ -/skills/create-pr/ -/skills/skill-creator/ diff --git a/.gitignore b/.gitignore index 279443290..7b8f4e89d 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ coverage packages/mcp-server/src/toolDefinitions.json packages/mcp-server/src/skillDefinitions.json packages/mcp-server-evals/eval-results.json +# Auto-generated by dotagents — do not commit these files. +agents.lock +.agents/.gitignore diff --git a/agents.lock b/agents.lock deleted file mode 100644 index b8a35762a..000000000 --- a/agents.lock +++ /dev/null @@ -1,52 +0,0 @@ -# Auto-generated by dotagents. Do not edit. -version = 1 - -[skills.agents-md] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/agents-md" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-YP+BMAkRO9YiP7Kq5DEBFkwBnJDUaqjMKW3MwdU1KXY=" - -[skills.brand-guidelines] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/brand-guidelines" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-OI4bxji4am79cV/3TNDVUfsnt6L2HBysG7epE4HFkVI=" - -[skills.claude-settings-audit] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/claude-settings-audit" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-KoWbFvgMmS7MP0v0fmzC3wBLawfY+9U+mmNEbjVVVPk=" - -[skills.code-simplifier] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/code-simplifier" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-PplG7pAg8buUE1Zj1xCFf4UwpUialPxWUD7qPtiZdKo=" - -[skills.commit] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/commit" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-EFbC0wiqzaiVLzfKi6aNSJukKYl36mb4qHW0wYooWxU=" - -[skills.create-pr] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/create-pr" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-7VZW3WkW6pcj8SMm4OrGo3MlSYtJsHeLzyr+iZPPaYs=" - -[skills.skill-creator] -source = "getsentry/skills" -resolved_url = "https://github.com/getsentry/skills.git" -resolved_path = ".agents/skills/skill-creator" -commit = "300f87e68926c4a89d664d730e87c2375ab6d215" -integrity = "sha256-cwime8dg09zRSkfbnBU4ObFPofjGigXCmDNvpNL7NNY=" - diff --git a/agents.toml b/agents.toml index 918ad2546..8cc067839 100644 --- a/agents.toml +++ b/agents.toml @@ -1,6 +1,4 @@ version = 1 -# Managed skills are gitignored; collaborators must run 'dotagents install'. -gitignore = true agents = ["claude", "cursor", "codex"] [trust] @@ -10,10 +8,6 @@ allow_all = true name = "agents-md" source = "getsentry/skills" -[[skills]] -name = "create-pr" -source = "getsentry/skills" - [[skills]] name = "commit" source = "getsentry/skills" @@ -31,5 +25,13 @@ name = "claude-settings-audit" source = "getsentry/skills" [[skills]] -name = "skill-creator" +name = "skill-writer" +source = "getsentry/skills" + +[[skills]] +name = "pr-writer" +source = "getsentry/skills" + +[[skills]] +name = "iterate-pr" source = "getsentry/skills"