Skip to content

Commit f0bd0a5

Browse files
authored
test(search-events): evals for attribute fetching with context and mocks (#1361)
This PR doesn't add any new functionality to the MCP; It focuses solely on building evals that we want to expand on. For context: we've got an option on the `/attributes` endpoint that provides us additional context about the state of some of these attributes; like if they're deprecated, aliased, sentry-defined, etc. We want to eventually pull in that information and use it so the mcp can make better queries. The purpose of this PR is to make new evals to test the query quality with and without this context. I've made new mocks for the attributes endpoint call with the context. Since i've made no code changes to correctly digest the additional context, i've made evals for it that i've made pass for now but will change as we put in the context digestion. Contributes to EXP-1186
1 parent dc98e07 commit f0bd0a5

9 files changed

Lines changed: 569 additions & 0 deletions

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,6 +1978,39 @@ describe("API query builders", () => {
19781978
expect(params.get("substringMatch")).toBe("tags[");
19791979
expect(params.get("query")).toBe('transaction:"VPN connections"');
19801980
expect(params.get("attributeType")).toBeNull();
1981+
expect(params.get("expand")).toBeNull();
1982+
});
1983+
1984+
it("should request attribute context when context is enabled", async () => {
1985+
const apiService = new SentryApiService({
1986+
host: "sentry.io",
1987+
accessToken: "test-token",
1988+
});
1989+
const urls: string[] = [];
1990+
1991+
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
1992+
urls.push(url);
1993+
1994+
return Promise.resolve({
1995+
ok: true,
1996+
headers: {
1997+
get: (key: string) =>
1998+
key === "content-type" ? "application/json" : null,
1999+
},
2000+
json: () => Promise.resolve([]),
2001+
});
2002+
});
2003+
2004+
await apiService.listTraceItemAttributes({
2005+
organizationSlug: "test-org",
2006+
itemType: "spans",
2007+
context: true,
2008+
});
2009+
2010+
expect(urls).toHaveLength(1);
2011+
const params = new URL(urls[0]!).searchParams;
2012+
expect(params.get("itemType")).toBe("spans");
2013+
expect(params.get("expand")).toBe("context");
19812014
});
19822015

19832016
it("should validate events requests via the validate endpoint", async () => {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3698,6 +3698,7 @@ export class SentryApiService {
36983698
attributeTypes,
36993699
substringMatch,
37003700
query,
3701+
context,
37013702
}: {
37023703
organizationSlug: string;
37033704
itemType?: TraceItemType;
@@ -3708,6 +3709,7 @@ export class SentryApiService {
37083709
attributeTypes?: TraceItemAttributeType[];
37093710
substringMatch?: string;
37103711
query?: string;
3712+
context?: boolean;
37113713
},
37123714
opts?: RequestOptions,
37133715
): Promise<TraceItemAttribute[]> {
@@ -3720,6 +3722,7 @@ export class SentryApiService {
37203722
end,
37213723
substringMatch,
37223724
query,
3725+
context,
37233726
opts,
37243727
);
37253728

@@ -3831,6 +3834,7 @@ export class SentryApiService {
38313834
end?: string,
38323835
substringMatch?: string,
38333836
query?: string,
3837+
context?: boolean,
38343838
opts?: RequestOptions,
38353839
): Promise<TraceItemAttribute[]> {
38363840
const queryParams = new URLSearchParams();
@@ -3844,6 +3848,9 @@ export class SentryApiService {
38443848
if (query) {
38453849
queryParams.set("query", query);
38463850
}
3851+
if (context) {
3852+
queryParams.set("expand", "context");
3853+
}
38473854
this.applyTimeParams(queryParams, statsPeriod, start, end);
38483855

38493856
const url =

‎packages/mcp-core/src/tools/support/search-events/utils.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,7 @@ export async function fetchCustomAttributes(
590590
attributeTypes?: TraceItemAttributeType[];
591591
substringMatch?: string;
592592
query?: string;
593+
context?: boolean;
593594
} = {},
594595
): Promise<{
595596
attributes: Record<string, string>;
@@ -640,6 +641,7 @@ export async function fetchCustomAttributes(
640641
attributeTypes: options.attributeTypes,
641642
substringMatch: options.substringMatch,
642643
query: options.query,
644+
context: options.context,
643645
});
644646

645647
for (const attr of attributesResponse) {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { describeEval } from "vitest-evals";
2+
import { ToolCallScorer } from "vitest-evals";
3+
import { searchEventsAgent } from "@sentry/mcp-core/tools/search-events/agent";
4+
import { SentryApiService } from "@sentry/mcp-core/api-client";
5+
import { StructuredOutputScorer } from "./utils/structuredOutputScorer";
6+
import "../setup-env";
7+
8+
// Compares the attribute the agent picks when the trace-items attributes
9+
// endpoint is called with and without `expand=context`. The with-context span
10+
// fixtures list the replacement for each deprecated attribute (e.g.
11+
// user.geo.region for geo.region), so only that path should use it.
12+
//
13+
// The datasetAttributes tool does not request context itself, so this service
14+
// forces it on or off for every attributes request.
15+
class AttributeContextApiService extends SentryApiService {
16+
constructor(private readonly attributeContext: boolean) {
17+
super({ accessToken: "test-token" });
18+
}
19+
20+
override listTraceItemAttributes(
21+
...[params, opts]: Parameters<SentryApiService["listTraceItemAttributes"]>
22+
) {
23+
return super.listTraceItemAttributes(
24+
{ ...params, context: this.attributeContext },
25+
opts,
26+
);
27+
}
28+
}
29+
30+
describeEval("search-events-agent-attributes-without-context", {
31+
data: async () => {
32+
return [
33+
{
34+
// http.method is also picked from the static span fields
35+
input: "Count spans grouped by HTTP method over the last 7 days",
36+
expectedTools: [
37+
{
38+
name: "datasetAttributes",
39+
arguments: {
40+
dataset: "spans",
41+
},
42+
},
43+
],
44+
expected: {
45+
dataset: "spans",
46+
fields: (value: unknown) =>
47+
Array.isArray(value) &&
48+
value.includes("http.method") &&
49+
!value.includes("http.request.method"),
50+
},
51+
},
52+
{
53+
// http.status_code is also picked from static span fields
54+
input: "Show me spans with HTTP status code 503 in the last 24 hours",
55+
expectedTools: [
56+
{
57+
name: "datasetAttributes",
58+
arguments: {
59+
dataset: "spans",
60+
},
61+
},
62+
],
63+
expected: {
64+
dataset: "spans",
65+
query: (value: unknown) =>
66+
typeof value === "string" &&
67+
value.includes("http.status_code:503") &&
68+
!value.includes("http.response.status_code"),
69+
},
70+
},
71+
];
72+
},
73+
task: async (input) => {
74+
// Create an API service that never requests attribute context
75+
const apiService = new AttributeContextApiService(false);
76+
77+
const agentResult = await searchEventsAgent({
78+
query: input,
79+
organizationSlug: "sentry-mcp-evals",
80+
apiService,
81+
});
82+
83+
return {
84+
result: JSON.stringify(agentResult.result),
85+
toolCalls: agentResult.toolCalls.map((call: any) => ({
86+
name: call.toolName,
87+
arguments: call.args,
88+
})),
89+
};
90+
},
91+
scorers: [
92+
ToolCallScorer({ params: "fuzzy" }), // Validates tool calls
93+
StructuredOutputScorer({ match: "fuzzy" }), // Validates the structured query output with flexible matching
94+
],
95+
});
96+
97+
// Context not enabled yet so these should not show the correct queries. Asserting them to the wrong queries for now.
98+
describeEval("search-events-agent-attributes-with-context", {
99+
data: async () => {
100+
return [
101+
{
102+
// EVENTUALLY Context marks http.method as deprecated in favor of http.request.method
103+
input: "Count spans grouped by HTTP method over the last 7 days",
104+
expectedTools: [
105+
{
106+
name: "datasetAttributes",
107+
arguments: {
108+
dataset: "spans",
109+
},
110+
},
111+
],
112+
expected: {
113+
dataset: "spans",
114+
fields: (value: unknown) =>
115+
Array.isArray(value) &&
116+
!value.includes("http.request.method") &&
117+
value.includes("http.method"),
118+
},
119+
},
120+
{
121+
// EVENTUALLY Context marks http.status_code as deprecated in favor of
122+
// http.response.status_code
123+
input: "Show me spans with HTTP status code 503 in the last 24 hours",
124+
expectedTools: [
125+
{
126+
name: "datasetAttributes",
127+
arguments: {
128+
dataset: "spans",
129+
},
130+
},
131+
],
132+
expected: {
133+
dataset: "spans",
134+
query: (value: unknown) =>
135+
typeof value === "string" &&
136+
!value.includes("http.response.status_code:503") &&
137+
value.includes("http.status_code"),
138+
},
139+
},
140+
];
141+
},
142+
task: async (input) => {
143+
// Create an API service that always requests attribute context
144+
const apiService = new AttributeContextApiService(true);
145+
146+
const agentResult = await searchEventsAgent({
147+
query: input,
148+
organizationSlug: "sentry-mcp-evals",
149+
apiService,
150+
});
151+
152+
return {
153+
result: JSON.stringify(agentResult.result),
154+
toolCalls: agentResult.toolCalls.map((call: any) => ({
155+
name: call.toolName,
156+
arguments: call.args,
157+
})),
158+
};
159+
},
160+
scorers: [
161+
ToolCallScorer({ params: "fuzzy" }), // Validates tool calls
162+
StructuredOutputScorer({ match: "fuzzy" }), // Validates the structured query output with flexible matching
163+
],
164+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
[
2+
{
3+
"key": "span.duration",
4+
"name": "span.duration",
5+
"context": {
6+
"isConvention": false,
7+
"brief": "The total time taken by the span.",
8+
"isDeprecated": false
9+
}
10+
},
11+
{
12+
"key": "transaction.duration",
13+
"name": "transaction.duration",
14+
"context": {}
15+
},
16+
{
17+
"key": "http.status_code",
18+
"name": "HTTP Status Code",
19+
"context": {
20+
"isConvention": true,
21+
"brief": "The status code of the HTTP response.",
22+
"isDeprecated": true,
23+
"examples": [404],
24+
"replacementAttribute": "http.response.status_code"
25+
}
26+
},
27+
{
28+
"key": "http.response.status_code",
29+
"name": "http.response.status_code",
30+
"context": {
31+
"isConvention": true,
32+
"brief": "The status code of the HTTP response.",
33+
"isDeprecated": false,
34+
"examples": [404]
35+
}
36+
},
37+
{
38+
"key": "custom.count",
39+
"name": "custom.count",
40+
"context": {}
41+
},
42+
{
43+
"key": "custom.score",
44+
"name": "custom.score",
45+
"context": {}
46+
},
47+
{
48+
"key": "custom.latency_ms",
49+
"name": "custom.latency_ms",
50+
"context": {}
51+
},
52+
{
53+
"key": "custom.db.pool_size",
54+
"name": "custom.db.pool_size",
55+
"context": {}
56+
}
57+
]

0 commit comments

Comments
 (0)