Skip to content

Commit 450cd4b

Browse files
wmakComposercursoragent
authored
ref(api): Fetch trace item attributes in one request (#1115)
Omit attributeType so Sentry returns all attribute types in a single attributes call instead of fanning out per type. Filter attributeTypes client-side when callers request a subset. Co-authored-by: Composer <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8d42528 commit 450cd4b

6 files changed

Lines changed: 185 additions & 137 deletions

File tree

docs/contributing/search-events-api-patterns.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ https://us.sentry.io/api/0/organizations/sentry/tags/?dataset=events&project=450
373373

374374
**Parameters**:
375375
- `itemType`: Either `spans` or `logs` (plural!)
376-
- `attributeType`: Either `string` or `number`
376+
- `attributeType`: Optional filter for `string`, `number`, or `boolean`. Omit to return all types.
377377
- `project`: Numeric project ID (optional)
378378
- `statsPeriod`: Time range
379379

@@ -412,10 +412,10 @@ https://us.sentry.io/api/0/organizations/sentry/trace-items/attributes/?attribut
412412

413413
### Implementation Strategy
414414

415-
The tool makes parallel requests to fetch attributes efficiently:
415+
The tool fetches attributes with a single request per dataset:
416416

417417
1. **For errors**: Single request to tags endpoint with optimized parameters
418-
2. **For spans/logs**: Single request that internally fetches both string + number attributes
418+
2. **For spans/logs/metrics**: Single request to trace-items attributes without `attributeType` (Sentry returns all types)
419419

420420
```typescript
421421
// For errors dataset
@@ -435,7 +435,7 @@ const attributesResponse = await apiService.listTraceItemAttributes({
435435
});
436436
```
437437

438-
Note: The `listTraceItemAttributes` method internally makes parallel requests for string and number attributes.
438+
When callers pass `attributeTypes`, `listTraceItemAttributes` filters the combined response client-side.
439439

440440
### Custom Attributes Integration
441441

packages/mcp-cloudflare/src/test-utils/fetch-mock-setup.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,9 +349,53 @@ export function registerFetchMockInterceptors(fetchMock: FetchMockLike) {
349349
const attributeType = url.searchParams.get("attributeType");
350350

351351
if (!itemType || !attributeType) {
352+
if (!itemType) {
353+
return {
354+
statusCode: 400,
355+
data: { detail: "Missing parameters" },
356+
responseOptions: { headers: JSON_HEADERS },
357+
};
358+
}
359+
360+
const normalizedItemType = itemType === "spans" ? "span" : itemType;
361+
362+
if (normalizedItemType === "span") {
363+
return {
364+
statusCode: 200,
365+
data: [
366+
...traceItemsAttributesSpansStringFixture.map((attribute) => ({
367+
...attribute,
368+
attributeType: "string",
369+
})),
370+
...traceItemsAttributesSpansNumberFixture.map((attribute) => ({
371+
...attribute,
372+
attributeType: "number",
373+
})),
374+
],
375+
responseOptions: { headers: JSON_HEADERS },
376+
};
377+
}
378+
379+
if (normalizedItemType === "logs") {
380+
return {
381+
statusCode: 200,
382+
data: [
383+
...traceItemsAttributesLogsStringFixture.map((attribute) => ({
384+
...attribute,
385+
attributeType: "string",
386+
})),
387+
...traceItemsAttributesLogsNumberFixture.map((attribute) => ({
388+
...attribute,
389+
attributeType: "number",
390+
})),
391+
],
392+
responseOptions: { headers: JSON_HEADERS },
393+
};
394+
}
395+
352396
return {
353397
statusCode: 400,
354-
data: { detail: "Missing parameters" },
398+
data: { detail: "Invalid itemType" },
355399
responseOptions: { headers: JSON_HEADERS },
356400
};
357401
}

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

Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,37 +1334,36 @@ describe("API query builders", () => {
13341334

13351335
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
13361336
urls.push(url);
1337-
const requestUrl = new URL(url);
1338-
const attributeType = requestUrl.searchParams.get("attributeType");
1339-
const body =
1340-
attributeType === "boolean"
1341-
? [
1342-
{
1343-
key: "tags[enabled,boolean]",
1344-
name: "enabled",
1345-
attributeType: "boolean",
1346-
attributeSource: { source_type: "user" },
1347-
},
1348-
]
1349-
: [
1350-
{
1351-
key: "tags[type]",
1352-
name: "type",
1353-
attributeType: "string",
1354-
attributeSource: {
1355-
source_type: "sentry",
1356-
is_transformed_alias: true,
1357-
},
1358-
},
1359-
];
13601337

13611338
return Promise.resolve({
13621339
ok: true,
13631340
headers: {
13641341
get: (key: string) =>
13651342
key === "content-type" ? "application/json" : null,
13661343
},
1367-
json: () => Promise.resolve(body),
1344+
json: () =>
1345+
Promise.resolve([
1346+
{
1347+
key: "tags[type]",
1348+
name: "type",
1349+
attributeType: "string",
1350+
attributeSource: {
1351+
source_type: "sentry",
1352+
is_transformed_alias: true,
1353+
},
1354+
},
1355+
{
1356+
key: "tags[enabled,boolean]",
1357+
name: "enabled",
1358+
attributeType: "boolean",
1359+
attributeSource: { source_type: "user" },
1360+
},
1361+
{
1362+
key: "tags[count,number]",
1363+
name: "count",
1364+
attributeType: "number",
1365+
},
1366+
]),
13681367
});
13691368
});
13701369

@@ -1395,18 +1394,14 @@ describe("API query builders", () => {
13951394
attributeSource: { source_type: "user" },
13961395
},
13971396
]);
1398-
expect(urls).toHaveLength(2);
1399-
for (const url of urls) {
1400-
const params = new URL(url).searchParams;
1401-
expect(params.get("itemType")).toBe("spans");
1402-
expect(params.get("project")).toBe("123");
1403-
expect(params.get("statsPeriod")).toBe("7d");
1404-
expect(params.get("substringMatch")).toBe("tags[");
1405-
expect(params.get("query")).toBe('transaction:"VPN connections"');
1406-
}
1407-
expect(
1408-
urls.map((url) => new URL(url).searchParams.get("attributeType")),
1409-
).toEqual(["string", "boolean"]);
1397+
expect(urls).toHaveLength(1);
1398+
const params = new URL(urls[0]!).searchParams;
1399+
expect(params.get("itemType")).toBe("spans");
1400+
expect(params.get("project")).toBe("123");
1401+
expect(params.get("statsPeriod")).toBe("7d");
1402+
expect(params.get("substringMatch")).toBe("tags[");
1403+
expect(params.get("query")).toBe('transaction:"VPN connections"');
1404+
expect(params.get("attributeType")).toBeNull();
14101405
});
14111406

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

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

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2806,6 +2806,7 @@ export class SentryApiService {
28062806
* @param params.itemType Item type to query attributes for ("spans", "logs", or "tracemetrics")
28072807
* @param params.project Numeric project ID to filter attributes
28082808
* @param params.statsPeriod Time range for attribute statistics (e.g., "24h", "7d")
2809+
* @param params.attributeTypes Optional attribute types to keep in the response
28092810
* @param opts Request options
28102811
* @returns Array of available attributes with metadata including type
28112812
*/
@@ -2817,7 +2818,7 @@ export class SentryApiService {
28172818
statsPeriod,
28182819
start,
28192820
end,
2820-
attributeTypes = ["string", "number"],
2821+
attributeTypes,
28212822
substringMatch,
28222823
query,
28232824
}: {
@@ -2833,25 +2834,24 @@ export class SentryApiService {
28332834
},
28342835
opts?: RequestOptions,
28352836
): Promise<TraceItemAttribute[]> {
2836-
const uniqueAttributeTypes = Array.from(new Set(attributeTypes));
2837-
const attributeResponses = await Promise.all(
2838-
uniqueAttributeTypes.map((attributeType) =>
2839-
this.fetchTraceItemAttributesByType(
2840-
organizationSlug,
2841-
itemType,
2842-
attributeType,
2843-
project,
2844-
statsPeriod,
2845-
start,
2846-
end,
2847-
substringMatch,
2848-
query,
2849-
opts,
2850-
),
2851-
),
2837+
const attributes = await this.fetchTraceItemAttributes(
2838+
organizationSlug,
2839+
itemType,
2840+
project,
2841+
statsPeriod,
2842+
start,
2843+
end,
2844+
substringMatch,
2845+
query,
2846+
opts,
28522847
);
28532848

2854-
return attributeResponses.flat();
2849+
if (!attributeTypes || attributeTypes.length === 0) {
2850+
return attributes;
2851+
}
2852+
2853+
const allowedTypes = new Set(attributeTypes);
2854+
return attributes.filter((attribute) => allowedTypes.has(attribute.type));
28552855
}
28562856

28572857
async validateEvents(
@@ -2913,10 +2913,9 @@ export class SentryApiService {
29132913
return parseEventsValidationResponse(body);
29142914
}
29152915

2916-
private async fetchTraceItemAttributesByType(
2916+
private async fetchTraceItemAttributes(
29172917
organizationSlug: string,
29182918
itemType: TraceItemType,
2919-
attributeType: TraceItemAttributeType,
29202919
project?: string,
29212920
statsPeriod?: string,
29222921
start?: string,
@@ -2927,7 +2926,6 @@ export class SentryApiService {
29272926
): Promise<TraceItemAttribute[]> {
29282927
const queryParams = new URLSearchParams();
29292928
queryParams.set("itemType", itemType);
2930-
queryParams.set("attributeType", attributeType);
29312929
if (project) {
29322930
queryParams.set("project", project);
29332931
}
@@ -2942,7 +2940,7 @@ export class SentryApiService {
29422940
const url = `/organizations/${organizationSlug}/trace-items/attributes/?${queryParams.toString()}`;
29432941

29442942
const body = await this.requestJSON(url, undefined, opts);
2945-
return parseTraceItemAttributes(body, attributeType);
2943+
return parseTraceItemAttributes(body, "string");
29462944
}
29472945

29482946
/**

0 commit comments

Comments
 (0)