-
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathview.ts
More file actions
465 lines (429 loc) · 14.9 KB
/
Copy pathview.ts
File metadata and controls
465 lines (429 loc) · 14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
/**
* sentry span view
*
* View detailed information about one or more spans within a trace.
*/
import type { SentryContext } from "../../context.js";
import {
attributesToDict,
fetchMultiSpanDetails,
getDetailedTrace,
type TraceItemDetail,
} from "../../lib/api-client.js";
import { spansFlag } from "../../lib/arg-parsing.js";
import { buildCommand } from "../../lib/command.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import {
type FoundSpan,
findSpanById,
formatSimpleSpanTree,
formatSpanDetails,
} from "../../lib/formatters/index.js";
import { filterFields } from "../../lib/formatters/json.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { computeSpanDurationMs } from "../../lib/formatters/time-utils.js";
import {
HEX_ID_RE,
normalizeHexId,
SPAN_ID_RE,
validateSpanId,
} from "../../lib/hex-id.js";
import {
handleRecoveryResult,
recoverHexId,
} from "../../lib/hex-id-recovery.js";
import {
applyFreshFlag,
FRESH_ALIASES,
FRESH_FLAG,
} from "../../lib/list-command.js";
import { logger } from "../../lib/logger.js";
import {
type parseSlashSeparatedTraceTarget,
parseTraceTargetWithRecovery,
resolveTraceOrgProject,
warnIfNormalized,
} from "../../lib/trace-target.js";
const log = logger.withTag("span.view");
type ViewFlags = {
readonly json: boolean;
readonly spans: number;
readonly fresh: boolean;
readonly fields?: string[];
};
/** Usage hint for ContextError messages */
const USAGE_HINT =
"sentry span view [<org>/<project>/]<trace-id> <span-id> [<span-id>...]";
/** Result of the initial positional-arg parse for `span view`. */
type SpanViewArgs =
| {
/** Args are already resolved (auto-split `<trace-id>/<span-id>`). */
kind: "resolved";
traceTarget: ReturnType<typeof parseSlashSeparatedTraceTarget>;
rawSpanIds: string[];
}
| {
/**
* First arg is the raw trace target (needs async recovery); the rest
* are raw span IDs. Command layer calls `parseTraceTargetWithRecovery`.
*/
kind: "deferred";
rawTraceArg: string;
rawSpanIds: string[];
};
/**
* Parse positional arguments for span view.
*
* Handles a few sync edge cases (single-arg `<trace>/<span>` slash split,
* bare span ID without a trace) and returns either a fully-resolved
* target or a deferred form. In the deferred case the command layer calls
* {@link parseTraceTargetWithRecovery} on `rawTraceArg` so a malformed
* trace ID gets the full recovery treatment (matching `trace view`
* behavior).
*
* @throws {ContextError} If insufficient arguments or a single bare span ID
*/
export function parsePositionalArgs(args: string[]): SpanViewArgs {
if (args.length === 0) {
throw new ContextError("Trace ID and span ID", USAGE_HINT, []);
}
const first = args[0];
if (first === undefined) {
throw new ContextError("Trace ID and span ID", USAGE_HINT, []);
}
// Auto-detect `[<org>/<project>/]<trace-id>/<span-id>` single-arg form.
// The user clearly intended to pass both trace target and span ID together.
// We split on the last slash so that full `org/project/trace-id/span-id`
// inputs are handled correctly, not just the simple `trace-id/span-id` form.
// Without this check, parseSlashSeparatedTraceTarget treats the span
// ID as a trace ID and fails validation (CLI-G6).
if (args.length === 1) {
const lastSlashIdx = first.lastIndexOf("/");
if (lastSlashIdx !== -1) {
const right = first
.slice(lastSlashIdx + 1)
.trim()
.toLowerCase()
.replace(/-/g, "");
if (SPAN_ID_RE.test(right)) {
const rawLeft = first.slice(0, lastSlashIdx);
const firstSlashIdx = first.indexOf("/");
const hasMultipleSlashes = firstSlashIdx !== lastSlashIdx;
if (hasMultipleSlashes) {
// Multi-segment form: org/project/trace-id/span-id — defer so that
// parseTraceTargetWithRecovery resolves the full trace target.
log.warn(
`Interpreting '${first}' as <trace-target>/<span-id>. ` +
`Use separate arguments: sentry span view ${rawLeft} ${right}`
);
return {
kind: "deferred",
rawTraceArg: rawLeft,
rawSpanIds: [right],
};
} else {
// Simple trace-id/span-id form: left must be a valid 32-char hex
// trace ID. If not, fall through to the missing-span-IDs error.
const left = normalizeHexId(rawLeft);
if (HEX_ID_RE.test(left)) {
log.warn(
`Interpreting '${first}' as <trace-id>/<span-id>. ` +
`Use separate arguments: sentry span view ${left} ${right}`
);
return {
kind: "resolved",
traceTarget: { type: "auto-detect" as const, traceId: left },
rawSpanIds: [right],
};
}
}
}
}
}
// Single bare arg that looks like a span ID (16-char hex, no slashes):
// the user forgot the trace ID. Give a targeted ContextError instead
// of the confusing "Invalid trace ID" from validateTraceId() (CLI-SC).
if (args.length === 1 && !first.includes("/")) {
const normalized = first.trim().toLowerCase().replace(/-/g, "");
if (SPAN_ID_RE.test(normalized)) {
throw new ContextError("Trace ID and span ID", USAGE_HINT, [
`'${first}' looks like a span ID (16 characters), not a trace ID`,
`Provide the trace ID first: sentry span view <trace-id> ${normalized}`,
`Use 'sentry trace list' to find trace IDs`,
]);
}
}
const rawSpanIds = args.slice(1);
if (rawSpanIds.length === 0) {
throw new ContextError("Span ID", USAGE_HINT, [
`Use 'sentry span list ${first}' to find span IDs within this trace`,
]);
}
return { kind: "deferred", rawTraceArg: first, rawSpanIds };
}
/**
* Validate a raw span ID, attempting {@link recoverHexId} on
* {@link ValidationError}. Requires trace-scoped context because span IDs
* are only unique within a trace.
*/
async function validateAndRecoverSpanId(
rawSpanId: string,
ctx: { org: string; project: string; traceId: string }
): Promise<string> {
try {
return validateSpanId(rawSpanId);
} catch (err) {
if (!(err instanceof ValidationError)) {
throw err;
}
const result = await recoverHexId(rawSpanId, "span", ctx);
return handleRecoveryResult(result, err, {
entityType: "span",
canonicalCommand: `sentry span view ${ctx.org}/${ctx.project}/${ctx.traceId} <id>`,
logTag: "span.view",
});
}
}
/**
* Format a list of span IDs as a markdown bullet list.
*/
function formatIdList(ids: string[]): string {
return ids.map((id) => ` - \`${id}\``).join("\n");
}
/**
* Warn about span IDs that weren't found in the trace.
*/
function warnMissingIds(spanIds: string[], foundIds: Set<string>): void {
const missing = spanIds.filter((id) => !foundIds.has(id));
if (missing.length > 0) {
log.warn(
`${missing.length} of ${spanIds.length} span(s) not found in trace:\n${formatIdList(missing)}`
);
}
}
// ---------------------------------------------------------------------------
// Output config types and formatters
// ---------------------------------------------------------------------------
/** Resolved span result from tree search. */
type SpanResult = FoundSpan & { spanId: string };
/** Structured data returned by the command for both JSON and human output */
type SpanViewData = {
/** Found span results with ancestors and depth */
results: SpanResult[];
/** The trace ID for context */
traceId: string;
/** Maximum child tree depth to display (from --spans flag) */
spansDepth: number;
/** Full attribute details per span ID (from trace-items endpoint) */
details?: Map<string, TraceItemDetail>;
};
/**
* Serialize span results for JSON output.
*
* When `details` is provided (from the trace-items endpoint), each span
* gains a `data` dict containing all custom attributes. Internal/storage
* attributes are filtered out for readability.
*/
function buildJsonResults(
results: SpanResult[],
traceId: string,
details?: Map<string, TraceItemDetail>
): unknown[] {
return results.map((r) => {
const base: Record<string, unknown> = {
span_id: r.span.span_id,
parent_span_id: r.span.parent_span_id,
trace_id: traceId,
op: r.span.op || r.span["transaction.op"],
description: r.span.description || r.span.transaction,
start_timestamp: r.span.start_timestamp,
end_timestamp: r.span.end_timestamp || r.span.timestamp,
duration: computeSpanDurationMs(r.span),
project_slug: r.span.project_slug,
transaction: r.span.transaction,
depth: r.depth,
ancestors: r.ancestors.map((a) => ({
span_id: a.span_id,
op: a.op || a["transaction.op"],
description: a.description || a.transaction,
})),
children: (r.span.children ?? []).map((c) => ({
span_id: c.span_id,
op: c.op || c["transaction.op"],
description: c.description || c.transaction,
})),
};
// Merge full attribute data from the trace-items endpoint
const detail = details?.get(r.spanId);
if (detail) {
const data = attributesToDict(detail.attributes);
if (Object.keys(data).length > 0) {
base.data = data;
}
}
return base;
});
}
/**
* Format span view data for human-readable terminal output.
*
* Renders each span's details (KV table + ancestor chain) and optionally
* shows the child span tree. When attribute details are available, notable
* custom attributes are appended after the standard fields.
* Multiple spans are separated by `---`.
*/
function formatSpanViewHuman(data: SpanViewData): string {
const parts: string[] = [];
for (let i = 0; i < data.results.length; i++) {
if (i > 0) {
parts.push("\n---\n");
}
const result = data.results[i];
if (!result) {
continue;
}
// Standard span details (KV table + ancestor chain)
const detail = data.details?.get(result.spanId);
parts.push(
formatSpanDetails(result.span, result.ancestors, data.traceId, detail)
);
// Show child tree if --spans > 0 and the span has children
const children = result.span.children ?? [];
if (data.spansDepth > 0 && children.length > 0) {
const treeLines = formatSimpleSpanTree(
data.traceId,
[result.span],
data.spansDepth
);
if (treeLines.length > 0) {
parts.push(`${treeLines.join("\n")}\n`);
}
}
}
return parts.join("");
}
/**
* Transform span view data for JSON output.
* Applies `--fields` filtering per element.
*/
function jsonTransformSpanView(data: SpanViewData, fields?: string[]): unknown {
const mapped = buildJsonResults(data.results, data.traceId, data.details);
if (fields && fields.length > 0) {
return mapped.map((item) => filterFields(item, fields));
}
return mapped;
}
export const viewCommand = buildCommand({
docs: {
brief: "View details of specific spans",
fullDescription:
"View detailed information about one or more spans within a trace.\n\n" +
"Target specification:\n" +
" sentry span view <trace-id> <span-id> # auto-detect\n" +
" sentry span view <org>/<project>/<trace-id> <span-id> # explicit\n\n" +
"The first argument is the trace ID (optionally prefixed with org/project),\n" +
"followed by one or more span IDs.\n\n" +
"Examples:\n" +
" sentry span view <trace-id> a1b2c3d4e5f67890\n" +
" sentry span view <trace-id> a1b2c3d4e5f67890 b2c3d4e5f6789012\n" +
" sentry span view sentry/my-project/<trace-id> a1b2c3d4e5f67890",
},
output: {
human: formatSpanViewHuman,
jsonTransform: jsonTransformSpanView,
},
parameters: {
positional: {
kind: "array",
parameter: {
placeholder: "trace-id/span-id",
brief:
"[<org>/<project>/]<trace-id> <span-id> [<span-id>...] - Trace ID and one or more span IDs",
parse: String,
},
},
flags: {
...spansFlag,
fresh: FRESH_FLAG,
},
aliases: { ...FRESH_ALIASES },
},
async *func(this: SentryContext, flags: ViewFlags, ...args: string[]) {
applyFreshFlag(flags);
const { cwd } = this;
// Parse positional args. Either we get a resolved trace target (single-arg
// <trace>/<span> slash form) or a deferred trace arg that routes through
// `parseTraceTargetWithRecovery` for async trace-ID recovery — same
// contract as `trace view`.
const parsed = parsePositionalArgs(args);
const rawSpanIds = parsed.rawSpanIds;
const traceTarget =
parsed.kind === "resolved"
? parsed.traceTarget
: await parseTraceTargetWithRecovery([parsed.rawTraceArg], USAGE_HINT);
warnIfNormalized(traceTarget, "span.view");
// Resolve org/project
const { traceId, org, project } = await resolveTraceOrgProject(
traceTarget,
cwd,
USAGE_HINT
);
// Validate + recover span IDs now that trace context is available.
// Span IDs are unique only within a trace, so recovery scopes its
// fuzzy lookup via `ctx.traceId`.
const spanIds = await Promise.all(
rawSpanIds.map((raw) =>
validateAndRecoverSpanId(raw, { org, project, traceId })
)
);
// Fetch trace data (single fetch for all span lookups)
const timestamp = Math.floor(Date.now() / 1000);
const spans = await getDetailedTrace(org, traceId, timestamp);
if (spans.length === 0) {
throw new ValidationError(
`No trace found with ID "${traceId}".\n\n` +
"The ID format is valid but no matching trace exists in this project. " +
"Check that you are querying the right org/project, or the trace may be past your plan's retention window."
);
}
// Find each requested span
const results: SpanResult[] = [];
const foundIds = new Set<string>();
for (const spanId of spanIds) {
const found = findSpanById(spans, spanId);
if (found) {
results.push({
spanId,
span: found.span,
ancestors: found.ancestors,
depth: found.depth,
});
foundIds.add(spanId);
}
}
if (results.length === 0) {
const idList = formatIdList(spanIds);
throw new ValidationError(
spanIds.length === 1
? `No span found with ID "${spanIds[0]}" in trace ${traceId}.`
: `No spans found with any of the following IDs in trace ${traceId}:\n${idList}`
);
}
warnMissingIds(spanIds, foundIds);
// Fetch full attribute details for each found span in parallel.
// Uses the trace-items detail endpoint which returns ALL attributes.
const details = await fetchMultiSpanDetails(
results.map((r) => ({
span_id: r.spanId,
project_slug: r.span.project_slug,
})),
{ org, fallbackProject: project, traceId }
);
yield new CommandOutput({
results,
traceId,
spansDepth: flags.spans,
details,
});
},
});