diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 3f5ec4ec9..8f909edf7 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -36,6 +36,7 @@ import { getContinuousProfileUrl as getContinuousProfileUrlUtil, getDashboardUrl as getDashboardUrlUtil, getIssueUrl as getIssueUrlUtil, + getLogUrl as getLogUrlUtil, getMonitorUrl as getMonitorUrlUtil, getPreprodSnapshotUrl as getPreprodSnapshotUrlUtil, getProfileUrl as getProfileUrlUtil, @@ -1204,6 +1205,10 @@ export class SentryApiService { return getTraceUrlUtil(this.host, organizationSlug, traceId, this.protocol); } + getLogUrl(organizationSlug: string, logId: string): string { + return getLogUrlUtil(this.host, organizationSlug, logId, this.protocol); + } + getReplayUrl(organizationSlug: string, replayId: string): string { return getReplayUrlUtil( this.host, diff --git a/packages/mcp-core/src/tools/catalog/search-events.test.ts b/packages/mcp-core/src/tools/catalog/search-events.test.ts index 8e18abadc..cb864235c 100644 --- a/packages/mcp-core/src/tools/catalog/search-events.test.ts +++ b/packages/mcp-core/src/tools/catalog/search-events.test.ts @@ -2657,6 +2657,65 @@ describe("search_events", () => { expect(mockGenerateText).toHaveBeenCalled(); expect(result).toContain("Connection failed to database"); expect(result).toContain("🔴 [ERROR]"); + expect(result).toContain( + "- **Log URL**: https://test-org.sentry.io/explore/logs/?project=-1&logsPinned=log1", + ); + }); + + it("does not request log IDs for aggregate logs queries", async () => { + let eventsRequestUrl: URL | undefined; + + mockGenerateText.mockResolvedValueOnce( + mockAIResponse( + "logs", + "severity:error", + ["severity", "count()"], + undefined, + "-count()", + ), + ); + + mswServer.use( + http.get( + "https://sentry.io/api/0/organizations/test-org/events/", + ({ request }) => { + eventsRequestUrl = new URL(request.url); + return HttpResponse.json({ + data: [{ severity: "error", "count()": 3 }], + }); + }, + ), + ); + + const result = await searchEvents.handler( + { + organizationSlug: "test-org", + regionUrl: null, + projectSlug: null, + query: "count error logs by severity", + dataset: "errors", + fields: null, + sort: "-count()", + period: "14d", + limit: 10, + includeExplanation: false, + }, + { + constraints: { + organizationSlug: null, + regionUrl: null, + projectSlug: null, + }, + accessToken: "test-token", + userId: "1", + }, + ); + + expect(eventsRequestUrl!.searchParams.getAll("field")).toEqual([ + "severity", + "count()", + ]); + expect(result).not.toContain("Log URL"); }); it("should repair direct search params with the agent when available", async () => { @@ -2722,6 +2781,7 @@ describe("search_events", () => { "timestamp", "message", "severity", + "id", ]); expect(result).toContain("Connection failed to database"); }); diff --git a/packages/mcp-core/src/tools/support/search-events/formatters.ts b/packages/mcp-core/src/tools/support/search-events/formatters.ts index 32943e225..1ffd2a53c 100644 --- a/packages/mcp-core/src/tools/support/search-events/formatters.ts +++ b/packages/mcp-core/src/tools/support/search-events/formatters.ts @@ -386,6 +386,10 @@ export function formatLogResults(params: FormatEventResultsParams): string { output += `### Log ${i + 1}\n`; + if (typeof event.id === "string" && event.id) { + output += `- **Log URL**: ${apiService.getLogUrl(organizationSlug, event.id)}\n`; + } + // Display priority fields first for (const field of priorityFields) { if ( diff --git a/packages/mcp-core/src/tools/support/search-events/search.ts b/packages/mcp-core/src/tools/support/search-events/search.ts index 029a02406..879397a05 100644 --- a/packages/mcp-core/src/tools/support/search-events/search.ts +++ b/packages/mcp-core/src/tools/support/search-events/search.ts @@ -128,9 +128,13 @@ function buildRequestFields( dataset: PublicEventsDataset | "replays", fields: string[], ): string[] { - return dataset !== "replays" && - isMetricsDataset(dataset) && - !isAggregateQuery(fields) + if (dataset === "replays" || isAggregateQuery(fields)) { + return fields; + } + if (dataset === "logs") { + return Array.from(new Set([...fields, "id"])); + } + return isMetricsDataset(dataset) ? Array.from(new Set([...fields, ...TRACE_METRICS_SAMPLE_IDENTITY_FIELDS])) : fields; } diff --git a/packages/mcp-core/src/utils/url-utils.test.ts b/packages/mcp-core/src/utils/url-utils.test.ts index dfbef62eb..bfd2a0742 100644 --- a/packages/mcp-core/src/utils/url-utils.test.ts +++ b/packages/mcp-core/src/utils/url-utils.test.ts @@ -5,6 +5,7 @@ import { getEventsExplorerUrl, getIssuesSearchUrl, getIssueUrl, + getLogUrl, getMonitorUrl, getPreprodSnapshotUrl, getReleaseUrl, @@ -309,6 +310,22 @@ describe("url-utils", () => { }); }); + describe("getLogUrl", () => { + it("pins the log in the logs explorer for SaaS", () => { + const result = getLogUrl("us.sentry.io", "myorg", "abc123"); + expect(result).toBe( + "https://myorg.sentry.io/explore/logs/?project=-1&logsPinned=abc123", + ); + }); + + it("pins the log in the logs explorer for self-hosted", () => { + const result = getLogUrl("sentry.example.com", "myorg", "abc123"); + expect(result).toBe( + "https://sentry.example.com/organizations/myorg/explore/logs/?project=-1&logsPinned=abc123", + ); + }); + }); + describe("getReplaysSearchUrl", () => { it("should handle regional URLs correctly for SaaS", () => { const result = getReplaysSearchUrl("us.sentry.io", "myorg", { diff --git a/packages/mcp-core/src/utils/url-utils.ts b/packages/mcp-core/src/utils/url-utils.ts index 41bb1f64e..c08a33d63 100644 --- a/packages/mcp-core/src/utils/url-utils.ts +++ b/packages/mcp-core/src/utils/url-utils.ts @@ -591,6 +591,21 @@ export function getTraceUrl( ); } +export function getLogUrl( + host: string, + organizationSlug: string, + logId: string, + protocol: SentryProtocol = "https", +): string { + const urlParams = new URLSearchParams({ project: "-1", logsPinned: logId }); + return getSentryWebBaseUrl( + host, + organizationSlug, + `/explore/logs/?${urlParams.toString()}`, + protocol, + ); +} + /** * Generates a Sentry replay URL. * @param host The Sentry host (may include regional subdomain for API access)