Coverage for src / sentry_tool / commands / traces.py: 69.01%

171 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-28 19:20 -0500

1"""Trace and transaction commands.""" 

2 

3import re 

4from dataclasses import dataclass, field 

5from typing import Annotated, Any 

6 

7import structlog 

8import typer 

9from rich.console import Console 

10from rich.table import Table 

11from rich.tree import Tree 

12 

13from sentry_tool.output import Column, OutputFormat, render 

14from sentry_tool.utils import api, get_config 

15 

16TRACE_ID_PATTERN = re.compile(r"^[0-9a-fA-F]{32}$") 

17MAX_DESCRIPTION_LENGTH = 50 

18 

19 

20@dataclass 

21class SpanNode: 

22 span_id: str 

23 parent_span_id: str | None 

24 op: str 

25 description: str 

26 duration: float 

27 children: list["SpanNode"] = field(default_factory=list) 

28 

29 

30def list_transactions( 

31 max_rows: Annotated[int, typer.Option("--max", "-n", help="Maximum transactions to show")] = 10, 

32 format: Annotated[ 

33 OutputFormat, 

34 typer.Option("--format", "-f", help="Output format"), 

35 ] = OutputFormat.table, 

36) -> None: 

37 """List recent transactions for the active project. 

38 

39 Examples: 

40 sentry-tool transactions 

41 sentry-tool transactions -n 5 

42 sentry-tool transactions --format json 

43 """ 

44 log = structlog.get_logger() 

45 config = get_config() 

46 

47 response = api( 

48 f"/organizations/{config['org']}/events/" 

49 f"?query=event.type:transaction project:{config['project']}" 

50 "&field=title&field=id&field=trace&field=transaction.duration" 

51 "&field=transaction.status&field=project&field=timestamp" 

52 "&sort=-timestamp", 

53 token=config["auth_token"], 

54 base_url=config["url"], 

55 ) 

56 

57 events = response.get("data", []) 

58 

59 if not events: 

60 log.info("No transactions found", project=config["project"]) 

61 return 

62 

63 events = events[:max_rows] 

64 

65 if format == OutputFormat.json: 

66 render(events, format) 

67 return 

68 

69 rows = [ 

70 { 

71 "title": evt.get("title", ""), 

72 "id": evt.get("id", ""), 

73 "trace": evt.get("trace", ""), 

74 "duration": str(evt.get("transaction.duration", "")), 

75 "status": evt.get("transaction.status", ""), 

76 "timestamp": evt.get("timestamp", "")[:19], 

77 } 

78 for evt in events 

79 ] 

80 

81 columns = [ 

82 Column("Transaction", "title", max_width=30), 

83 Column("Event ID", "id", style="dim", max_width=36), 

84 Column("Trace ID", "trace", style="dim", max_width=36), 

85 Column("Duration (ms)", "duration", justify="right"), 

86 Column("Status", "status"), 

87 Column("Timestamp", "timestamp"), 

88 ] 

89 

90 render(rows, format, columns=columns, footer=f"Showing {len(events)} transactions") 

91 

92 

93def lookup_trace( 

94 trace_id: Annotated[str, typer.Argument(help="Trace ID (32-character hex string)")], 

95 max_rows: Annotated[int, typer.Option("--max", "-n", help="Maximum events to show")] = 25, 

96 format: Annotated[ 

97 OutputFormat, 

98 typer.Option("--format", "-f", help="Output format"), 

99 ] = OutputFormat.table, 

100) -> None: 

101 """Look up all events belonging to a trace by its 32-character hex ID. 

102 

103 Examples: 

104 sentry-tool trace abc123def456789012345678901234ab 

105 sentry-tool trace abc123def456789012345678901234ab -n 10 

106 sentry-tool trace abc123def456789012345678901234ab --format json 

107 """ 

108 log = structlog.get_logger() 

109 if not TRACE_ID_PATTERN.match(trace_id): 

110 log.error("Invalid trace ID format", trace_id=trace_id, expected="32-character hex string") 

111 raise typer.Exit(2) 

112 

113 config = get_config() 

114 

115 response = api( 

116 f"/organizations/{config['org']}/events/?query=trace:{trace_id}" 

117 "&field=title&field=id&field=span_id&field=transaction.duration" 

118 "&field=transaction.status&field=project&field=timestamp", 

119 token=config["auth_token"], 

120 base_url=config["url"], 

121 ) 

122 

123 events = response.get("data", []) 

124 

125 if not events: 

126 log.info("No events found for trace", trace_id=trace_id) 

127 return 

128 

129 events.sort(key=lambda e: e.get("timestamp", "")) 

130 events = events[:max_rows] 

131 

132 if format == OutputFormat.json: 

133 render(events, format) 

134 return 

135 

136 rows = [ 

137 { 

138 "title": evt.get("title", ""), 

139 "span_id": evt.get("span_id", ""), 

140 "duration": str(evt.get("transaction.duration", "")), 

141 "status": evt.get("transaction.status", ""), 

142 "project": evt.get("project", ""), 

143 "timestamp": evt.get("timestamp", "")[:19], 

144 } 

145 for evt in events 

146 ] 

147 

148 columns = [ 

149 Column("Transaction", "title", max_width=40), 

150 Column("Span ID", "span_id", style="dim", max_width=16), 

151 Column("Duration", "duration", justify="right"), 

152 Column("Status", "status"), 

153 Column("Project", "project"), 

154 Column("Timestamp", "timestamp"), 

155 ] 

156 

157 render(rows, format, columns=columns, footer=f"Showing {len(events)} events") 

158 

159 

160def _extract_spans(event: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None, float]: 

161 trace_ctx = event.get("contexts", {}).get("trace", {}) 

162 root_span_id = trace_ctx.get("span_id") 

163 raw_duration = trace_ctx.get("duration") 

164 txn_duration = raw_duration / 1000.0 if raw_duration is not None else 0.0 

165 for entry in event.get("entries", []): 

166 if entry.get("type") == "spans": 

167 return entry.get("data", []), root_span_id, txn_duration 

168 return [], root_span_id, txn_duration 

169 

170 

171def _build_span_tree(spans: list[dict[str, Any]], root_span_id: str | None) -> SpanNode: 

172 log = structlog.get_logger() 

173 root = SpanNode( 

174 span_id=root_span_id or "root", 

175 parent_span_id=None, 

176 op="transaction", 

177 description="", 

178 duration=0.0, 

179 ) 

180 

181 node_map: dict[str, SpanNode] = {} 

182 for span in spans: 

183 span_id = span.get("span_id", "") 

184 if not span_id: 

185 continue 

186 if span_id in node_map: 

187 log.warning("Duplicate span_id skipped", span_id=span_id) 

188 continue 

189 duration = span.get("timestamp", 0) - span.get("start_timestamp", 0) 

190 node_map[span_id] = SpanNode( 

191 span_id=span_id, 

192 parent_span_id=span.get("parent_span_id"), 

193 op=span.get("op", "(unknown)"), 

194 description=span.get("description", "(no description)"), 

195 duration=duration, 

196 ) 

197 

198 node_map[root.span_id] = root 

199 

200 for node in node_map.values(): 

201 if node is root: 

202 continue 

203 parent_id = node.parent_span_id 

204 if parent_id and parent_id in node_map: 

205 node_map[parent_id].children.append(node) 

206 else: 

207 root.children.append(node) 

208 

209 return root 

210 

211 

212def _render_span_tree( 

213 root: SpanNode, 

214 total_spans: int, 

215 txn_duration: float, 

216 console: Console | None = None, 

217) -> None: 

218 if console is None: 

219 console = Console() 

220 

221 tree = Tree(f"[bold]{root.op}[/bold] {root.description}") 

222 

223 def _add_children(parent_tree: Tree, node: SpanNode) -> None: 

224 for child in node.children: 

225 desc = child.description 

226 if len(desc) > MAX_DESCRIPTION_LENGTH: 

227 desc = desc[: MAX_DESCRIPTION_LENGTH - 3] + "..." 

228 label = f"[cyan]{child.op}[/cyan] {desc} [dim]{child.duration:.3f}s[/dim]" 

229 branch = parent_tree.add(label) 

230 _add_children(branch, child) 

231 

232 _add_children(tree, root) 

233 console.print(tree) 

234 console.print(f"\n{total_spans} spans | {txn_duration:.3f}s total") 

235 

236 

237def _render_timeline(spans: list[dict[str, Any]], console: Console) -> None: 

238 min_start = min(s.get("start_timestamp", 0) for s in spans) 

239 max_end = max(s.get("timestamp", 0) for s in spans) 

240 total_duration = max_end - min_start 

241 

242 if total_duration <= 0: 

243 console.print("\n[dim]Cannot render timeline (zero duration)[/dim]") 

244 return 

245 

246 bar_width = 40 

247 

248 table = Table(title="Span Timeline") 

249 table.add_column("Op", style="cyan", max_width=20) 

250 table.add_column("Description", max_width=35) 

251 table.add_column("Start", justify="right") 

252 table.add_column("Dur (s)", justify="right") 

253 table.add_column("Timeline", no_wrap=True) 

254 

255 for span in spans: 

256 start = span.get("start_timestamp", 0) 

257 end = span.get("timestamp", 0) 

258 offset = start - min_start 

259 duration = end - start 

260 

261 pos = int(offset / total_duration * bar_width) 

262 length = max(1, int(duration / total_duration * bar_width)) 

263 bar = " " * pos + "█" * length 

264 

265 table.add_row( 

266 span.get("op", ""), 

267 span.get("description", "")[:35], 

268 f"{offset:.3f}s", 

269 f"{duration:.3f}s", 

270 bar, 

271 ) 

272 

273 console.print() 

274 console.print(table) 

275 console.print(f"\n Total duration: {total_duration:.3f}s") 

276 

277 

278def show_transaction( 

279 event_id: Annotated[str, typer.Argument(help="Event ID")], 

280 format: Annotated[ 

281 OutputFormat, 

282 typer.Option("--format", "-f", help="Output format"), 

283 ] = OutputFormat.table, 

284 timeline: Annotated[ 

285 bool, 

286 typer.Option("--timeline", "-t", help="Show Gantt-chart timeline of spans"), 

287 ] = False, 

288) -> None: 

289 """Fetch and display detailed transaction information including spans. 

290 

291 Examples: 

292 sentry-tool transaction d3f1d81247ad4516b61da92f1db050dd 

293 sentry-tool transaction d3f1d81247ad4516b61da92f1db050dd --format json 

294 """ 

295 config = get_config() 

296 

297 event = api( 

298 f"/organizations/{config['org']}/events/{config['project']}:{event_id}/", 

299 token=config["auth_token"], 

300 base_url=config["url"], 

301 ) 

302 

303 if format == OutputFormat.json: 

304 render([event], format) 

305 return 

306 

307 console = Console() 

308 console.print(f"\n[bold cyan]=== Transaction {event_id[:8]}... ===[/bold cyan]") 

309 

310 rows = [ 

311 {"field": "Transaction", "value": event.get("title", "N/A")}, 

312 {"field": "Event ID", "value": event.get("eventID", "N/A")}, 

313 { 

314 "field": "Trace ID", 

315 "value": event.get("contexts", {}).get("trace", {}).get("trace_id", "N/A"), 

316 }, 

317 { 

318 "field": "Span ID", 

319 "value": event.get("contexts", {}).get("trace", {}).get("span_id", "N/A"), 

320 }, 

321 { 

322 "field": "Parent Span", 

323 "value": event.get("contexts", {}).get("trace", {}).get("parent_span_id", "N/A"), 

324 }, 

325 { 

326 "field": "Duration", 

327 "value": f"{event.get('contexts', {}).get('trace', {}).get('duration', 'N/A')} ms", 

328 }, 

329 { 

330 "field": "Status", 

331 "value": event.get("contexts", {}).get("trace", {}).get("status", "N/A"), 

332 }, 

333 {"field": "Timestamp", "value": event.get("dateCreated", "N/A")}, 

334 ] 

335 

336 detail_columns = [ 

337 Column("Field", "field", style="bold"), 

338 Column("Value", "value"), 

339 ] 

340 render(rows, OutputFormat.table, columns=detail_columns) 

341 

342 entries = event.get("entries", []) 

343 spans = [] 

344 for entry in entries: 

345 if entry.get("type") == "spans": 

346 spans = entry.get("data", []) 

347 break 

348 

349 if spans: 

350 if timeline: 

351 _render_timeline(spans, console) 

352 else: 

353 span_rows = [ 

354 { 

355 "operation": span.get("op", ""), 

356 "description": span.get("description", "")[:50], 

357 "duration": f"{span.get('timestamp', 0) - span.get('start_timestamp', 0):.3f}", 

358 } 

359 for span in spans 

360 ] 

361 

362 span_columns = [ 

363 Column("Operation", "operation", max_width=20), 

364 Column("Description", "description", max_width=50), 

365 Column("Duration (s)", "duration", justify="right"), 

366 ] 

367 console.print() 

368 render( 

369 span_rows, OutputFormat.table, columns=span_columns, footer=f"{len(spans)} spans" 

370 ) 

371 else: 

372 console.print("\n[dim]No span data found[/dim]") 

373 

374 console.print() 

375 

376 

377def show_spans( 

378 event_id: Annotated[str, typer.Argument(help="Event ID")], 

379 format: Annotated[ 

380 OutputFormat, 

381 typer.Option("--format", "-f", help="Output format"), 

382 ] = OutputFormat.table, 

383 op: Annotated[ 

384 str | None, 

385 typer.Option("--op", help="Filter spans by operation type (comma-separated)"), 

386 ] = None, 

387) -> None: 

388 """Display transaction spans as a tree with optional operation filtering. 

389 

390 Examples: 

391 sentry-tool spans d3f1d81247ad4516b61da92f1db050dd 

392 sentry-tool spans d3f1d81247ad4516b61da92f1db050dd --format json 

393 sentry-tool spans d3f1d81247ad4516b61da92f1db050dd --op db.query 

394 """ 

395 log = structlog.get_logger() 

396 config = get_config() 

397 

398 event = api( 

399 f"/organizations/{config['org']}/events/{config['project']}:{event_id}/", 

400 token=config["auth_token"], 

401 base_url=config["url"], 

402 ) 

403 

404 spans, root_span_id, txn_duration = _extract_spans(event) 

405 

406 if not spans: 

407 log.info("No span data found", event_id=event_id) 

408 return 

409 

410 if op: 

411 op_filters = {o.strip() for o in op.split(",")} 

412 spans = [s for s in spans if s.get("op") in op_filters] 

413 if not spans: 

414 log.info("No spans matching op filter", op=op) 

415 return 

416 

417 if format == OutputFormat.json: 

418 render(spans, format) 

419 return 

420 

421 root = _build_span_tree(spans, root_span_id) 

422 _render_span_tree(root, len(spans), txn_duration)