Skip to content

Commit 1957447

Browse files
dcramercodex
andauthored
feat(resource): Add AI conversation retrieval (#993)
Expose AI conversations through get_sentry_resource so agents can fetch a readable conversation transcript from a conversation ID or Explore URL. The response is organized by turns, with compact metadata, grouped tool calls, parsed tool inputs, and a concise structured artifact for downstream use. **Conversation Resource Support** get_sentry_resource now accepts resourceType='ai_conversation' and resolves /explore/conversations/{conversationId}/ URLs without adding another public MCP tool. **Turn-Based Transcript Rendering** AI client spans are rendered as grouped turns with user, assistant, and tool sections. Tool calls are associated with the AI turn that triggered them, including repeated messages and trailing tools after the final AI client span. **API Shape Validation** The Sentry API client now calls /api/0/organizations/{org}/ai-conversations/{conversation_id}/, handles cursor pagination, and validates the flat span row shape returned by the endpoint. Fixes GH-990 --------- Co-authored-by: Codex <codex@openai.com>
1 parent 0abbe03 commit 1957447

12 files changed

Lines changed: 1269 additions & 11 deletions

‎packages/mcp-core/src/api-client/client.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod";
22
import {
33
getContinuousProfileUrl as getContinuousProfileUrlUtil,
4+
getAIConversationUrl as getAIConversationUrlUtil,
45
getIssueUrl as getIssueUrlUtil,
56
getMonitorUrl as getMonitorUrlUtil,
67
getProfileUrl as getProfileUrlUtil,
@@ -53,6 +54,7 @@ import {
5354
ReplayListResponseSchema,
5455
ReplayIdsByResourceSchema,
5556
ReplayRecordingSegmentsSchema,
57+
AIConversationSpanListSchema,
5658
} from "./schema";
5759
import { ConfigurationError } from "../errors";
5860
import { createApiError, ApiNotFoundError, ApiValidationError } from "./errors";
@@ -86,6 +88,7 @@ import type {
8688
ReplayDetails,
8789
ReplayList,
8890
ReplayRecordingSegments,
91+
AIConversationSpanList,
8992
} from "./types";
9093
// TODO: this is shared - so ideally, for safety, it uses @sentry/core, but currently
9194
// logger isnt exposed (or rather, it is, but its not the right logger)
@@ -103,6 +106,25 @@ const NETWORK_ERROR_MESSAGES: Record<string, string> = {
103106
ECONNRESET: "Connection reset. Try again in a moment.",
104107
};
105108

109+
function getNextCursor(linkHeader: string | null): string | null {
110+
if (!linkHeader) {
111+
return null;
112+
}
113+
114+
for (const link of linkHeader.split(",")) {
115+
if (!link.includes('rel="next"') || !link.includes('results="true"')) {
116+
continue;
117+
}
118+
119+
const cursorMatch = link.match(/cursor="([^"]+)"/);
120+
if (cursorMatch?.[1]) {
121+
return cursorMatch[1];
122+
}
123+
}
124+
125+
return null;
126+
}
127+
106128
/**
107129
* Custom error class for Sentry API responses.
108130
*
@@ -645,6 +667,18 @@ export class SentryApiService {
645667
);
646668
}
647669

670+
getAIConversationUrl(
671+
organizationSlug: string,
672+
conversationId: string,
673+
): string {
674+
return getAIConversationUrlUtil(
675+
this.host,
676+
organizationSlug,
677+
conversationId,
678+
this.protocol,
679+
);
680+
}
681+
648682
getProfileUrl(
649683
organizationSlug: string,
650684
projectSlug: string,
@@ -2871,6 +2905,56 @@ export class SentryApiService {
28712905
return TraceSchema.parse(body);
28722906
}
28732907

2908+
async getAIConversation(
2909+
{
2910+
organizationSlug,
2911+
conversationId,
2912+
project = "-1",
2913+
statsPeriod = "30d",
2914+
perPage = 1000,
2915+
maxPages = 10,
2916+
}: {
2917+
organizationSlug: string;
2918+
conversationId: string;
2919+
project?: string | string[];
2920+
statsPeriod?: string;
2921+
perPage?: number;
2922+
maxPages?: number;
2923+
},
2924+
opts?: RequestOptions,
2925+
): Promise<AIConversationSpanList> {
2926+
const spans: AIConversationSpanList = [];
2927+
let cursor: string | null = null;
2928+
2929+
for (let page = 0; page < maxPages; page++) {
2930+
const queryParams = new URLSearchParams();
2931+
queryParams.set("per_page", String(perPage));
2932+
queryParams.set("statsPeriod", statsPeriod);
2933+
const projects = Array.isArray(project) ? project : [project];
2934+
for (const projectId of projects) {
2935+
queryParams.append("project", projectId);
2936+
}
2937+
if (cursor) {
2938+
queryParams.set("cursor", cursor);
2939+
}
2940+
2941+
const response = await this.request(
2942+
`/organizations/${organizationSlug}/ai-conversations/${encodeURIComponent(conversationId)}/?${queryParams.toString()}`,
2943+
undefined,
2944+
opts,
2945+
);
2946+
const body = await this.parseJsonResponse(response);
2947+
spans.push(...AIConversationSpanListSchema.parse(body));
2948+
2949+
cursor = getNextCursor(response.headers.get("link"));
2950+
if (!cursor) {
2951+
break;
2952+
}
2953+
}
2954+
2955+
return spans;
2956+
}
2957+
28742958
/**
28752959
* Retrieves flamegraph data for a transaction.
28762960
*

‎packages/mcp-core/src/api-client/schema.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,58 @@ export const TraceSchema = z.array(
10491049
z.union([TraceSpanSchema, TraceIssueSchema]),
10501050
);
10511051

1052+
const NullableStringOrNumberSchema = z
1053+
.union([z.string(), z.number()])
1054+
.nullable()
1055+
.optional();
1056+
const NullableOptionalStringSchema = z
1057+
.string()
1058+
.nullable()
1059+
.optional()
1060+
.transform((value) => value ?? undefined);
1061+
1062+
export const AIConversationSpanSchema = z
1063+
.object({
1064+
"gen_ai.conversation.id": z.string(),
1065+
span_id: z.string(),
1066+
trace: z.string(),
1067+
parent_span: z.string().nullable().optional(),
1068+
"precise.start_ts": z.number(),
1069+
"precise.finish_ts": z.number(),
1070+
project: z.string(),
1071+
"project.id": z.union([z.string(), z.number()]),
1072+
"span.name": NullableOptionalStringSchema,
1073+
"span.status": NullableOptionalStringSchema,
1074+
"span.op": NullableOptionalStringSchema,
1075+
"span.description": NullableOptionalStringSchema,
1076+
"span.duration": z.number().optional(),
1077+
transaction: NullableOptionalStringSchema,
1078+
is_transaction: z.boolean().optional(),
1079+
"gen_ai.cost.total_tokens": NullableStringOrNumberSchema,
1080+
"gen_ai.operation.type": NullableOptionalStringSchema,
1081+
"gen_ai.input.messages": NullableOptionalStringSchema,
1082+
"gen_ai.output.messages": NullableOptionalStringSchema,
1083+
"gen_ai.system_instructions": NullableOptionalStringSchema,
1084+
"gen_ai.tool.definitions": NullableOptionalStringSchema,
1085+
"gen_ai.request.messages": NullableOptionalStringSchema,
1086+
"gen_ai.response.object": NullableOptionalStringSchema,
1087+
"gen_ai.response.text": NullableOptionalStringSchema,
1088+
"gen_ai.tool.name": NullableOptionalStringSchema,
1089+
"gen_ai.tool.call.arguments": NullableOptionalStringSchema,
1090+
"gen_ai.tool.input": NullableOptionalStringSchema,
1091+
"gen_ai.usage.total_tokens": NullableStringOrNumberSchema,
1092+
"gen_ai.request.model": NullableOptionalStringSchema,
1093+
"gen_ai.response.model": NullableOptionalStringSchema,
1094+
"gen_ai.agent.name": NullableOptionalStringSchema,
1095+
"user.id": NullableOptionalStringSchema,
1096+
"user.email": NullableOptionalStringSchema,
1097+
"user.username": NullableOptionalStringSchema,
1098+
"user.ip": NullableOptionalStringSchema,
1099+
})
1100+
.passthrough();
1101+
1102+
export const AIConversationSpanListSchema = z.array(AIConversationSpanSchema);
1103+
10521104
/**
10531105
* Schema for individual frames in a flamegraph.
10541106
*

‎packages/mcp-core/src/api-client/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
import type { z } from "zod";
4242
import type {
4343
AssignedToSchema,
44+
AIConversationSpanListSchema,
45+
AIConversationSpanSchema,
4446
AutofixRunSchema,
4547
AutofixRunStateSchema,
4648
ClientKeyListSchema,
@@ -138,6 +140,10 @@ export type TraceMeta = z.infer<typeof TraceMetaSchema>;
138140
export type TraceSpan = z.infer<typeof TraceSpanSchema>;
139141
export type TraceIssue = z.infer<typeof TraceIssueSchema>;
140142
export type Trace = z.infer<typeof TraceSchema>;
143+
export type AIConversationSpan = z.infer<typeof AIConversationSpanSchema>;
144+
export type AIConversationSpanList = z.infer<
145+
typeof AIConversationSpanListSchema
146+
>;
141147

142148
// Profile types
143149
export type Flamegraph = z.infer<typeof FlamegraphSchema>;

‎packages/mcp-core/src/internal/url-helpers.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,43 @@ describe("parseSentryUrl", () => {
232232
});
233233
});
234234

235+
describe("AI conversation URLs", () => {
236+
it("parses AI conversation URL with subdomain", () => {
237+
expect(
238+
parseSentryUrl(
239+
"https://my-org.sentry.io/explore/conversations/conv-123/",
240+
),
241+
).toMatchInlineSnapshot(`
242+
{
243+
"conversationId": "conv-123",
244+
"organizationSlug": "my-org",
245+
"type": "ai_conversation",
246+
}
247+
`);
248+
});
249+
250+
it("parses AI conversation URL with organizations path", () => {
251+
expect(
252+
parseSentryUrl(
253+
"https://sentry.io/organizations/my-org/explore/conversations/conv-123/",
254+
),
255+
).toMatchInlineSnapshot(`
256+
{
257+
"conversationId": "conv-123",
258+
"organizationSlug": "my-org",
259+
"type": "ai_conversation",
260+
}
261+
`);
262+
});
263+
264+
it("does not parse unrelated conversations path segments", () => {
265+
const result = parseSentryUrl(
266+
"https://sentry.io/organizations/my-org/settings/projects/conversations/keys/",
267+
);
268+
expect(result.type).toBe("unknown");
269+
});
270+
});
271+
235272
describe("replay URLs", () => {
236273
it("parses canonical replay URL with subdomain", () => {
237274
expect(

‎packages/mcp-core/src/internal/url-helpers.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type SentryResourceType =
1515
| "issue"
1616
| "trace"
1717
| "profile"
18+
| "ai_conversation"
1819
| "event"
1920
| "replay"
2021
| "monitor"
@@ -35,6 +36,8 @@ export interface ParsedSentryUrl {
3536
issueId?: string;
3637
/** Trace ID (for trace URLs) */
3738
traceId?: string;
39+
/** AI conversation ID (for Explore conversation URLs) */
40+
conversationId?: string;
3841
/** Span ID (for trace URLs with span focus, from query param) */
3942
spanId?: string;
4043
/** Event ID (for event URLs) */
@@ -72,6 +75,7 @@ export interface ParsedSentryUrl {
7275
* - Trace: `/explore/traces/trace/{traceId}` or `/performance/trace/{traceId}`
7376
* - Profile: `/explore/profiling/profile/{project}/{profileId}/flamegraph/`
7477
* - Continuous profile: `/explore/profiling/profile/{project}/flamegraph/` with query params
78+
* - AI conversation: `/explore/conversations/{conversationId}/`
7579
* - Replay: `/explore/replays/{replayId}/` or `/replays/{replayId}/`
7680
* - Monitor: `/crons/{monitorSlug}/` or `/monitors/{monitorSlug}/`
7781
* - Release: `/releases/{version}/`
@@ -189,6 +193,28 @@ function extractOrganizationSlug(parsedUrl: URL, pathParts: string[]): string {
189193
);
190194
}
191195

196+
function escapeRegex(value: string): string {
197+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
198+
}
199+
200+
function extractPathGroup(
201+
pathParts: string[],
202+
patterns: RegExp[],
203+
groupName: string,
204+
): string | undefined {
205+
const path = pathParts.join("/");
206+
207+
for (const pattern of patterns) {
208+
const match = pattern.exec(path);
209+
const value = match?.groups?.[groupName];
210+
if (value) {
211+
return value;
212+
}
213+
}
214+
215+
return undefined;
216+
}
217+
192218
/**
193219
* Identifies the resource type and extracts relevant identifiers.
194220
*/
@@ -222,6 +248,26 @@ function identifyResource(
222248
};
223249
}
224250

251+
// AI conversation URL: /explore/conversations/{conversationId}/
252+
const conversationId = extractPathGroup(
253+
pathParts,
254+
[
255+
/^explore\/conversations\/(?<conversationId>[^/]+)$/,
256+
/^organizations\/[^/]+\/explore\/conversations\/(?<conversationId>[^/]+)$/,
257+
new RegExp(
258+
`^${escapeRegex(organizationSlug)}\\/explore\\/conversations\\/(?<conversationId>[^/]+)$`,
259+
),
260+
],
261+
"conversationId",
262+
);
263+
if (conversationId) {
264+
return {
265+
type: "ai_conversation",
266+
organizationSlug,
267+
conversationId,
268+
};
269+
}
270+
225271
// Replay URL: /explore/replays/{replayId}/ or /replays/{replayId}/
226272
const replaysIndex = pathParts.indexOf("replays");
227273
if (replaysIndex !== -1) {

0 commit comments

Comments
 (0)