Skip to content

Commit 1111a40

Browse files
betegonclaude
andcommitted
feat: switch span list to EAP spans endpoint and show span IDs in trace view
Use the server-side spans search endpoint (dataset=spans) for `span list` instead of fetching the full trace tree and filtering client-side. Add `translateSpanQuery` to rewrite CLI shorthand keys (op→span.op, duration→span.duration) for the API. Also fix trace view showing `undefined` for span IDs — the trace detail API returns `event_id` instead of `span_id`, so normalize in `getDetailedTrace`. Append span IDs (dimmed) to each tree line. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b1cfd6 commit 1111a40

File tree

7 files changed

+239
-246
lines changed

7 files changed

+239
-246
lines changed

src/commands/span/list.ts

Lines changed: 21 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type { SentryContext } from "../../context.js";
8-
import { getDetailedTrace } from "../../lib/api-client.js";
8+
import { listSpans } from "../../lib/api-client.js";
99
import {
1010
parseOrgProjectArg,
1111
parseSlashSeparatedArg,
@@ -14,9 +14,8 @@ import {
1414
import { buildCommand } from "../../lib/command.js";
1515
import { ContextError, ValidationError } from "../../lib/errors.js";
1616
import {
17-
applySpanFilter,
18-
flattenSpanTree,
19-
parseSpanQuery,
17+
spanListItemToFlatSpan,
18+
translateSpanQuery,
2019
writeFooter,
2120
writeJsonList,
2221
writeSpanTable,
@@ -230,42 +229,31 @@ export const listCommand = buildCommand({
230229

231230
setContext([target.org], [target.project]);
232231

233-
// Fetch trace data
234-
const timestamp = Math.floor(Date.now() / 1000);
235-
const spans = await getDetailedTrace(target.org, traceId, timestamp);
236-
237-
if (spans.length === 0) {
238-
throw new ValidationError(
239-
`No trace found with ID "${traceId}".\n\n` +
240-
"Make sure the trace ID is correct and the trace was sent recently."
241-
);
242-
}
243-
244-
// Flatten and filter
245-
let flatSpans = flattenSpanTree(spans);
246-
const totalSpans = flatSpans.length;
247-
232+
// Build server-side query
233+
const queryParts = [`trace:${traceId}`];
248234
if (flags.query) {
249-
const filter = parseSpanQuery(flags.query);
250-
flatSpans = applySpanFilter(flatSpans, filter);
235+
queryParts.push(translateSpanQuery(flags.query));
251236
}
252-
const matchedSpans = flatSpans.length;
253-
254-
// Sort
255-
if (flags.sort === "duration") {
256-
flatSpans.sort((a, b) => (b.duration_ms ?? -1) - (a.duration_ms ?? -1));
257-
}
258-
// "time" is already in depth-first (start_timestamp) order from flattenSpanTree
237+
const apiQuery = queryParts.join(" ");
238+
239+
// Fetch spans from EAP endpoint
240+
const { data: spanItems, nextCursor } = await listSpans(
241+
target.org,
242+
target.project,
243+
{
244+
query: apiQuery,
245+
sort: flags.sort,
246+
limit: flags.limit,
247+
}
248+
);
259249

260-
// Apply limit
261-
const hasMore = flatSpans.length > flags.limit;
262-
flatSpans = flatSpans.slice(0, flags.limit);
250+
const flatSpans = spanItems.map(spanListItemToFlatSpan);
251+
const hasMore = nextCursor !== undefined;
263252

264253
if (flags.json) {
265254
writeJsonList(stdout, flatSpans, {
266255
hasMore,
267256
fields: flags.fields,
268-
extra: { totalSpans, matchedSpans },
269257
});
270258
return;
271259
}
@@ -279,11 +267,7 @@ export const listCommand = buildCommand({
279267
writeSpanTable(stdout, flatSpans);
280268

281269
// Footer
282-
const filterNote =
283-
matchedSpans < totalSpans
284-
? ` (${matchedSpans} matched, ${totalSpans} total)`
285-
: ` (${totalSpans} total)`;
286-
const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}${filterNote}.`;
270+
const countText = `Showing ${flatSpans.length} span${flatSpans.length === 1 ? "" : "s"}.`;
287271

288272
if (hasMore) {
289273
writeFooter(stdout, `${countText} Use --limit to see more.`);

src/lib/api-client.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ import {
5050
type SentryTeam,
5151
type SentryUser,
5252
SentryUserSchema,
53+
type SpanListItem,
54+
type SpansResponse,
55+
SpansResponseSchema,
5356
type TraceLog,
5457
TraceLogsResponseSchema,
5558
type TraceSpan,
@@ -1503,7 +1506,24 @@ export async function getDetailedTrace(
15031506
},
15041507
}
15051508
);
1506-
return data;
1509+
return data.map(normalizeTraceSpan);
1510+
}
1511+
1512+
/**
1513+
* The trace detail API (`/trace/{id}/`) returns each span's unique identifier
1514+
* as `event_id` rather than `span_id`. The value is the same 16-hex-char span
1515+
* ID that `parent_span_id` references on child spans. We copy it to `span_id`
1516+
* so the rest of the codebase can use a single, predictable field name.
1517+
*/
1518+
function normalizeTraceSpan(span: TraceSpan): TraceSpan {
1519+
const normalized = { ...span };
1520+
if (!normalized.span_id && normalized.event_id) {
1521+
normalized.span_id = normalized.event_id;
1522+
}
1523+
if (normalized.children) {
1524+
normalized.children = normalized.children.map(normalizeTraceSpan);
1525+
}
1526+
return normalized;
15071527
}
15081528

15091529
/** Fields to request from the transactions API */
@@ -1583,6 +1603,74 @@ export async function listTransactions(
15831603
return { data: response.data, nextCursor };
15841604
}
15851605

1606+
/** Fields to request from the spans API */
1607+
const SPAN_FIELDS = [
1608+
"id",
1609+
"parent_span",
1610+
"span.op",
1611+
"description",
1612+
"span.duration",
1613+
"timestamp",
1614+
"project",
1615+
"transaction",
1616+
"trace",
1617+
];
1618+
1619+
type ListSpansOptions = {
1620+
/** Search query using Sentry query syntax */
1621+
query?: string;
1622+
/** Maximum number of spans to return */
1623+
limit?: number;
1624+
/** Sort order: "time" (newest first) or "duration" (slowest first) */
1625+
sort?: "time" | "duration";
1626+
/** Time period for spans (e.g., "7d", "24h") */
1627+
statsPeriod?: string;
1628+
/** Pagination cursor to resume from a previous page */
1629+
cursor?: string;
1630+
};
1631+
1632+
/**
1633+
* List spans using the EAP spans search endpoint.
1634+
* Uses the Explore/Events API with dataset=spans.
1635+
*
1636+
* @param orgSlug - Organization slug
1637+
* @param projectSlug - Project slug or numeric ID
1638+
* @param options - Query options (query, limit, sort, statsPeriod, cursor)
1639+
* @returns Paginated response with span items and optional next cursor
1640+
*/
1641+
export async function listSpans(
1642+
orgSlug: string,
1643+
projectSlug: string,
1644+
options: ListSpansOptions = {}
1645+
): Promise<PaginatedResponse<SpanListItem[]>> {
1646+
const isNumericProject = isAllDigits(projectSlug);
1647+
const projectFilter = isNumericProject ? "" : `project:${projectSlug}`;
1648+
const fullQuery = [projectFilter, options.query].filter(Boolean).join(" ");
1649+
1650+
const regionUrl = await resolveOrgRegion(orgSlug);
1651+
1652+
const { data: response, headers } = await apiRequestToRegion<SpansResponse>(
1653+
regionUrl,
1654+
`/organizations/${orgSlug}/events/`,
1655+
{
1656+
params: {
1657+
dataset: "spans",
1658+
field: SPAN_FIELDS,
1659+
project: isNumericProject ? projectSlug : undefined,
1660+
query: fullQuery || undefined,
1661+
per_page: options.limit || 10,
1662+
statsPeriod: options.statsPeriod ?? "7d",
1663+
sort: options.sort === "duration" ? "-span.duration" : "-timestamp",
1664+
cursor: options.cursor,
1665+
},
1666+
schema: SpansResponseSchema,
1667+
}
1668+
);
1669+
1670+
const { nextCursor } = parseLinkHeader(headers.get("link") ?? null);
1671+
return { data: response.data, nextCursor };
1672+
}
1673+
15861674
// Issue update functions
15871675

15881676
/**

src/lib/formatters/human.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,8 @@ function formatSpanSimple(span: TraceSpan, opts: FormatSpanOptions): void {
10671067
line += ` ${muted(`(${prettyMs(durationMs)})`)}`;
10681068
}
10691069

1070+
line += ` ${muted(span.span_id)}`;
1071+
10701072
lines.push(line);
10711073

10721074
if (currentDepth < maxDepth) {

0 commit comments

Comments
 (0)