diff --git a/packages/mcp-core/src/api-client/client.test.ts b/packages/mcp-core/src/api-client/client.test.ts index c2ad4c6a..81c5ffc1 100644 --- a/packages/mcp-core/src/api-client/client.test.ts +++ b/packages/mcp-core/src/api-client/client.test.ts @@ -1409,7 +1409,7 @@ describe("API query builders", () => { ).toEqual(["string", "boolean"]); }); - it("should validate exact trace item attributes", async () => { + it("should validate events requests via the validate endpoint", async () => { const apiService = new SentryApiService({ host: "sentry.io", accessToken: "test-token", @@ -1423,46 +1423,105 @@ describe("API query builders", () => { requestUrl = url; requestOptions = options; return Promise.resolve({ - ok: true, + ok: false, + status: 400, headers: { get: (key: string) => key === "content-type" ? "application/json" : null, }, json: () => Promise.resolve({ - attributes: { - "tags[type]": { valid: true, type: "string" }, - "tags[missing]": { + valid: false, + projects: [], + dataset: [], + environment: [], + field: [ + { + name: "tags[type]", + valid: true, + attrType: "string", + error: null, + }, + { + name: "tags[missing]", valid: false, - error: "Unknown attribute: tags[missing]", + attrType: null, + error: "Unknown attribute", }, + ], + query: { + valid: false, + error: "Invalid syntax", + fields: [ + { + name: "transaction", + valid: true, + attrType: "string", + error: null, + }, + ], }, + orderby: [ + { + name: "-span.duration", + valid: false, + attrType: null, + error: "Orderby must also be a selected field", + }, + ], }), }); }); - const result = await apiService.validateTraceItemAttributes({ + const result = await apiService.validateEvents({ organizationSlug: "test-org", - itemType: "spans", - attributes: ["tags[type]", "tags[missing]"], + dataset: "spans", + fields: ["tags[type]", "tags[missing]"], + query: 'transaction:"VPN connections"', + orderby: ["-span.duration"], + environment: ["production", "staging"], project: "123", statsPeriod: "7d", }); expect(result).toEqual({ - "tags[type]": { valid: true, type: "string" }, - "tags[missing]": { + valid: false, + projects: [], + dataset: [], + environment: [], + field: [ + { name: "tags[type]", valid: true, type: "string" }, + { + name: "tags[missing]", + valid: false, + error: "Unknown attribute", + }, + ], + query: { valid: false, - error: "Unknown attribute: tags[missing]", + error: "Invalid syntax", + fields: [{ name: "transaction", valid: true, type: "string" }], }, + orderby: [ + { + name: "-span.duration", + valid: false, + error: "Orderby must also be a selected field", + }, + ], }); expect(requestUrl).toContain( - "/api/0/organizations/test-org/trace-items/attributes/validate/?itemType=spans&project=123&statsPeriod=7d", + "/api/0/organizations/test-org/events/validate/?", ); - expect(requestOptions?.method).toBe("POST"); - expect(JSON.parse(String(requestOptions?.body))).toEqual({ - attributes: ["tags[type]", "tags[missing]"], - }); + const params = new URL(String(requestUrl)).searchParams; + expect(params.get("dataset")).toBe("spans"); + expect(params.get("project")).toBe("123"); + expect(params.get("statsPeriod")).toBe("7d"); + expect(params.get("query")).toBe('transaction:"VPN connections"'); + expect(params.getAll("field")).toEqual(["tags[type]", "tags[missing]"]); + expect(params.getAll("orderby")).toEqual(["-span.duration"]); + expect(params.getAll("environment")).toEqual(["production", "staging"]); + expect(requestOptions?.method).toBeUndefined(); }); }); diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index ab31bc09..d1db57a0 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -261,6 +261,165 @@ export type TraceItemAttributeValidationResult = { error?: string; }; +export type EventsValidationIssue = { + valid: boolean; + error?: string; +}; + +export type EventsNamedValidationIssue = { + name: string; + valid: boolean; + error?: string; +}; + +export type EventsAttributeValidationResult = { + name: string; + valid: boolean; + type?: TraceItemAttributeType; + error?: string; +}; + +export type EventsQueryValidation = { + valid: boolean; + error?: string; + fields: EventsAttributeValidationResult[]; +}; + +export type EventsValidationResult = { + valid: boolean; + projects: EventsValidationIssue[]; + dataset: EventsNamedValidationIssue[]; + environment: EventsValidationIssue[]; + field: EventsAttributeValidationResult[]; + query: EventsQueryValidation; + orderby: EventsAttributeValidationResult[]; +}; + +function parseEventsValidationIssue( + value: unknown, +): EventsValidationIssue | null { + if (!isRecord(value) || typeof value.valid !== "boolean") { + return null; + } + const issue: EventsValidationIssue = { valid: value.valid }; + if (typeof value.error === "string" && value.error.length > 0) { + issue.error = value.error; + } + return issue; +} + +function parseEventsAttributeValidation( + value: unknown, +): EventsAttributeValidationResult | null { + if ( + !isRecord(value) || + typeof value.name !== "string" || + typeof value.valid !== "boolean" + ) { + return null; + } + const result: EventsAttributeValidationResult = { + name: value.name, + valid: value.valid, + }; + if (isTraceItemAttributeType(value.attrType)) { + result.type = value.attrType; + } + if (typeof value.error === "string" && value.error.length > 0) { + result.error = value.error; + } + return result; +} + +function parseEventsValidationIssues(value: unknown): EventsValidationIssue[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map(parseEventsValidationIssue) + .filter((issue): issue is EventsValidationIssue => issue !== null); +} + +function parseEventsAttributeValidations( + value: unknown, +): EventsAttributeValidationResult[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map(parseEventsAttributeValidation) + .filter((item): item is EventsAttributeValidationResult => item !== null); +} + +function parseEventsNamedValidationIssue( + value: unknown, +): EventsNamedValidationIssue | null { + if ( + !isRecord(value) || + typeof value.name !== "string" || + typeof value.valid !== "boolean" + ) { + return null; + } + const issue: EventsNamedValidationIssue = { + name: value.name, + valid: value.valid, + }; + if (typeof value.error === "string" && value.error.length > 0) { + issue.error = value.error; + } + return issue; +} + +function parseEventsNamedValidationIssues( + value: unknown, +): EventsNamedValidationIssue[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map(parseEventsNamedValidationIssue) + .filter((issue): issue is EventsNamedValidationIssue => issue !== null); +} + +function parseEventsQueryValidation(value: unknown): EventsQueryValidation { + if (!isRecord(value) || typeof value.valid !== "boolean") { + return { valid: false, fields: [] }; + } + const query: EventsQueryValidation = { + valid: value.valid, + fields: parseEventsAttributeValidations(value.fields), + }; + if (typeof value.error === "string" && value.error.length > 0) { + query.error = value.error; + } + return query; +} + +function parseEventsValidationResponse(body: unknown): EventsValidationResult { + if (!isRecord(body)) { + return { + valid: false, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: false, fields: [] }, + orderby: [], + }; + } + + return { + valid: typeof body.valid === "boolean" ? body.valid : false, + projects: parseEventsValidationIssues(body.projects), + dataset: parseEventsNamedValidationIssues(body.dataset), + environment: parseEventsValidationIssues(body.environment), + field: parseEventsAttributeValidations(body.field), + query: parseEventsQueryValidation(body.query), + orderby: parseEventsAttributeValidations(body.orderby), + }; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -566,7 +725,7 @@ export class SentryApiService { private async request( path: string, options: RequestInit = {}, - { host }: { host?: string } = {}, + { host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {}, ): Promise { const url = host ? `${this.protocol}://${host}/api/0${path}` @@ -642,6 +801,9 @@ export class SentryApiService { // Handle error responses generically if (!response.ok) { + if (allowStatuses?.includes(response.status)) { + return response; + } const errorText = await response.text(); let parsed: unknown | undefined; try { @@ -2692,63 +2854,63 @@ export class SentryApiService { return attributeResponses.flat(); } - async validateTraceItemAttributes( + async validateEvents( { organizationSlug, - itemType = "spans", - attributes, + dataset = "spans", + fields, + query, + orderby, project, + environment, statsPeriod, start, end, }: { organizationSlug: string; - itemType?: TraceItemType; - attributes: string[]; + dataset?: EventsDataset; + fields?: string[]; + query?: string; + orderby?: string[]; project?: string; + environment?: string | string[]; statsPeriod?: string; start?: string; end?: string; }, opts?: RequestOptions, - ): Promise> { + ): Promise { const queryParams = new URLSearchParams(); - queryParams.set("itemType", itemType); + queryParams.set("dataset", normalizeEventsDataset(dataset)); if (project) { queryParams.set("project", project); } - this.applyTimeParams(queryParams, statsPeriod, start, end); - - const body = await this.requestJSON( - `/organizations/${organizationSlug}/trace-items/attributes/validate/?${queryParams.toString()}`, - { - method: "POST", - body: JSON.stringify({ attributes }), - }, - opts, - ); - - if (!isRecord(body) || !isRecord(body.attributes)) { - return {}; + if (query) { + queryParams.set("query", query); } - - const results: Record = {}; - for (const [attribute, value] of Object.entries(body.attributes)) { - if (!isRecord(value) || typeof value.valid !== "boolean") { - continue; - } - const validationResult: TraceItemAttributeValidationResult = { - valid: value.valid, - }; - if (isTraceItemAttributeType(value.type)) { - validationResult.type = value.type; - } - if (typeof value.error === "string") { - validationResult.error = value.error; + if (environment) { + const environments = Array.isArray(environment) + ? environment + : [environment]; + for (const value of environments) { + queryParams.append("environment", value); } - results[attribute] = validationResult; } - return results; + this.applyTimeParams(queryParams, statsPeriod, start, end); + for (const field of fields ?? []) { + queryParams.append("field", field); + } + for (const value of orderby ?? []) { + queryParams.append("orderby", value); + } + + const response = await this.request( + `/organizations/${organizationSlug}/events/validate/?${queryParams.toString()}`, + undefined, + { ...opts, allowStatuses: [400] }, + ); + const body = await this.parseJsonResponse(response); + return parseEventsValidationResponse(body); } private async fetchTraceItemAttributesByType( diff --git a/packages/mcp-core/src/tools/support/search-events/utils.test.ts b/packages/mcp-core/src/tools/support/search-events/utils.test.ts index 271b2b49..ecf750a2 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.test.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.test.ts @@ -4,6 +4,7 @@ import { mswServer } from "@sentry/mcp-server-mocks"; import { fetchCustomAttributes, formatEventValue, + formatEventsValidationResults, formatKnownUserValue, looksLikeSentrySearchSyntax, } from "./utils"; @@ -576,3 +577,136 @@ describe("fetchCustomAttributes", () => { }); }); }); + +describe("formatEventsValidationResults", () => { + it("returns an empty string when there are no validation sections", () => { + expect( + formatEventsValidationResults({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { valid: true, fields: [] }, + orderby: [], + }), + ).toBe(""); + }); + + it("formats all validation sections for a mixed result", () => { + expect( + formatEventsValidationResults({ + valid: false, + projects: [{ valid: true }], + dataset: [ + { + name: "spans", + valid: false, + error: "dataset must be one of: spans, errors", + }, + ], + environment: [{ valid: true }], + field: [ + { name: "span.duration", valid: true, type: "number" }, + { + name: "tags[missing]", + valid: false, + error: "Unknown attribute", + }, + ], + query: { + valid: false, + error: "Invalid syntax", + fields: [{ name: "transaction", valid: true, type: "string" }], + }, + orderby: [ + { + name: "-spon.duration", + valid: false, + error: "Orderby must also be a selected field", + }, + ], + }), + ).toBe(`Validation Result: invalid +Validated Dataset: +- INVALID spans — dataset must be one of: spans, errors + +Validated Fields: +- INVALID tags[missing] — Unknown attribute + +Validated Query: +- INVALID query — Invalid syntax + +Validated Order By: +- INVALID -spon.duration — Orderby must also be a selected field +`); + }); + + it("formats invalid query with invalid query fields", () => { + expect( + formatEventsValidationResults({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [], + query: { + valid: false, + error: 'quotes are not closed at "VPN connections', + fields: [ + { name: "hello", valid: false, error: "Unknown attribute" }, + { name: "tags[fake]", valid: false, error: "Unknown attribute" }, + ], + }, + orderby: [], + }), + ).toBe(`Validation Result: invalid +Validated Query: +- INVALID query — quotes are not closed at "VPN connections + - INVALID hello — Unknown attribute + - INVALID tags[fake] — Unknown attribute +`); + }); + + it("formats valid query field details when validation passes", () => { + expect( + formatEventsValidationResults({ + valid: true, + projects: [], + dataset: [], + environment: [], + field: [], + query: { + valid: true, + fields: [{ name: "transaction", valid: true, type: "string" }], + }, + orderby: [], + }), + ).toBe(`Validation Result: valid +Validated Query: +- OK query + - OK transaction — type: string +`); + }); + + it("formats query-only validation failure", () => { + expect( + formatEventsValidationResults({ + valid: false, + projects: [], + dataset: [], + environment: [], + field: [], + query: { + valid: false, + error: "Invalid syntax", + fields: [], + }, + orderby: [], + }), + ).toBe(`Validation Result: invalid +Validated Query: +- INVALID query — Invalid syntax +`); + }); +}); diff --git a/packages/mcp-core/src/tools/support/search-events/utils.ts b/packages/mcp-core/src/tools/support/search-events/utils.ts index a334bc79..9c566e40 100644 --- a/packages/mcp-core/src/tools/support/search-events/utils.ts +++ b/packages/mcp-core/src/tools/support/search-events/utils.ts @@ -2,7 +2,8 @@ import { z } from "zod"; import type { SentryApiService, TraceItemAttributeType, - TraceItemAttributeValidationResult, + EventsQueryValidation, + EventsValidationResult, TraceItemType, } from "../../../api-client"; import { @@ -423,24 +424,143 @@ function getTraceItemType(dataset: EventsDataset): TraceItemType | null { return null; } -function formatValidationResults( - validationResults: Record, +function formatValidationStatusLine({ + valid, + name, + type, + error, + indent = 0, +}: { + valid: boolean; + name?: string; + type?: string; + error?: string; + indent?: number; +}): string { + const status = valid ? "OK" : "INVALID"; + const label = name ? ` ${name}` : ""; + const prefix = `${" ".repeat(indent)}- ${status}${label}`; + + if (valid && type) { + return `${prefix} — type: ${type}`; + } + if (error) { + return `${prefix} — ${error}`; + } + return prefix; +} + +function formatQueryValidation( + query: EventsQueryValidation, + failuresOnly: boolean, +): string[] { + const invalidFields = query.fields.filter((field) => !field.valid); + const visibleFields = failuresOnly ? invalidFields : query.fields; + const showQueryLine = failuresOnly + ? !query.valid || invalidFields.length > 0 + : !query.valid || query.fields.length > 0; + + if (!showQueryLine) { + return []; + } + + const lines: string[] = []; + if (!query.valid) { + lines.push( + formatValidationStatusLine({ + valid: false, + name: "query", + error: query.error, + }), + ); + } else { + lines.push(formatValidationStatusLine({ valid: true, name: "query" })); + } + + lines.push( + ...visibleFields.map((field) => + formatValidationStatusLine({ ...field, indent: 2 }), + ), + ); + return lines; +} + +function pushValidationSection( + sections: string[], + title: string, + items: ReadonlyArray<{ + valid: boolean; + name?: string; + type?: string; + error?: string; + }>, + failuresOnly: boolean, +): void { + const visibleItems = failuresOnly + ? items.filter((item) => !item.valid) + : items; + if (visibleItems.length === 0) { + return; + } + + sections.push( + `Validated ${title}:\n${visibleItems.map((item) => formatValidationStatusLine(item)).join("\n")}`, + ); +} + +export function formatEventsValidationResults( + validationResults: EventsValidationResult, ): string { - const entries = Object.entries(validationResults); - if (entries.length === 0) { + const failuresOnly = !validationResults.valid; + const sections: string[] = []; + + pushValidationSection( + sections, + "Projects", + validationResults.projects, + failuresOnly, + ); + pushValidationSection( + sections, + "Dataset", + validationResults.dataset, + failuresOnly, + ); + pushValidationSection( + sections, + "Environment", + validationResults.environment, + failuresOnly, + ); + pushValidationSection( + sections, + "Fields", + validationResults.field, + failuresOnly, + ); + + const queryLines = formatQueryValidation( + validationResults.query, + failuresOnly, + ); + if (queryLines.length > 0) { + sections.push(`Validated Query:\n${queryLines.join("\n")}`); + } + + pushValidationSection( + sections, + "Order By", + validationResults.orderby, + failuresOnly, + ); + + if (sections.length === 0) { return ""; } - return `Validated Attributes: -${entries - .map(([attribute, result]) => { - if (result.valid) { - return `- ${attribute}: valid${result.type ? ` (${result.type})` : ""}`; - } - return `- ${attribute}: invalid${result.error ? ` (${result.error})` : ""}`; - }) - .join("\n")} -`; + const overall = validationResults.valid ? "valid" : "invalid"; + const details = sections.join("\n\n"); + return `Validation Result: ${overall}\n${details}\n`; } /** @@ -488,7 +608,23 @@ export function createDatasetAttributesTool(options: { .min(1) .optional() .describe( - "Optional exact attribute keys to validate, such as ['tags[type]', 'span.duration']", + "Optional exact field names to validate, such as ['tags[type]', 'span.duration']", + ), + orderby: z + .array(z.string().trim().min(1)) + .min(1) + .optional() + .describe( + "Optional sort fields to validate against the selected fields, such as ['-span.duration']", + ), + environment: z + .union([ + z.string().trim().min(1), + z.array(z.string().trim().min(1)).min(1), + ]) + .optional() + .describe( + "Optional environment filter to validate, as a string or array of environment names", ), }), execute: async ({ @@ -497,6 +633,8 @@ export function createDatasetAttributesTool(options: { query, attributeTypes, attributes, + orderby, + environment, }) => { const { BASE_COMMON_FIELDS, @@ -511,18 +649,17 @@ export function createDatasetAttributesTool(options: { // UserInputError will be converted to error string for the AI agent // Other errors will bubble up to be captured by Sentry const normalizedDataset = normalizeEventsDataset(dataset); - const traceItemType = getTraceItemType(normalizedDataset); const attributeTimeParams = { statsPeriod: "14d" }; - const validationResults = - traceItemType && attributes?.length - ? await apiService.validateTraceItemAttributes({ - organizationSlug, - itemType: traceItemType, - attributes, - project: projectId, - ...attributeTimeParams, - }) - : {}; + const validationResults = await apiService.validateEvents({ + organizationSlug, + dataset, + fields: attributes, + query, + orderby, + environment, + project: projectId, + ...attributeTimeParams, + }); const { attributes: customAttributes, fieldTypes } = await fetchCustomAttributes( apiService, @@ -560,7 +697,7 @@ export function createDatasetAttributesTool(options: { recordAgentToolResultCount(fieldCount); return `Dataset: ${dataset} -${formatValidationResults(validationResults)} +${validationResults ? formatEventsValidationResults(validationResults) : ""} Available Fields (${fieldCount} total): ${Object.entries(allFields) diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 78884daa..2fb14565 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -995,35 +995,67 @@ export const restHandlers = buildHandlers([ fetch: () => HttpResponse.json(tagsFixture), }, { - method: "post", - path: "/api/0/organizations/sentry-mcp-evals/trace-items/attributes/validate/", - fetch: async ({ request }) => { - const body = (await request.json().catch(() => null)) as unknown; - const attributesValue = - typeof body === "object" && body !== null && "attributes" in body - ? body.attributes - : undefined; - const attributes = Array.isArray(attributesValue) - ? attributesValue.filter( - (attribute): attribute is string => typeof attribute === "string", - ) - : []; - - return HttpResponse.json({ - attributes: Object.fromEntries( - attributes.map((attribute) => [ - attribute, + method: "get", + path: "/api/0/organizations/sentry-mcp-evals/events/validate/", + fetch: ({ request }) => { + const url = new URL(request.url); + const fields = url.searchParams.getAll("field"); + const query = url.searchParams.get("query"); + const orderby = url.searchParams.getAll("orderby"); + const environments = url.searchParams.getAll("environment"); + + const fieldResults = fields.map((field) => ({ + name: field, + valid: true, + attrType: + field.includes("sequence") || + field.includes("count") || + field.includes("duration") + ? "number" + : "string", + error: null, + })); + + const queryFieldResults = query + ? [ { + name: "transaction", valid: true, - type: - attribute.includes("sequence") || - attribute.includes("count") || - attribute.includes("duration") - ? "number" - : "string", + attrType: "string", + error: null, }, - ]), - ), + ] + : []; + + const orderbyResults = orderby.map((value) => ({ + name: value, + valid: fields.length > 0, + attrType: null, + error: + fields.length > 0 ? null : "Orderby must also be a selected field", + })); + + const environmentResults = environments.map((environment) => ({ + valid: true, + error: null, + })); + + return HttpResponse.json({ + valid: + fieldResults.every((field) => field.valid) && + queryFieldResults.every((item) => item.valid) && + orderbyResults.every((item) => item.valid) && + environmentResults.every((item) => item.valid), + projects: [], + dataset: [], + environment: environmentResults, + field: fieldResults, + query: { + valid: queryFieldResults.every((item) => item.valid), + error: null, + fields: queryFieldResults, + }, + orderby: orderbyResults, }); }, },