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
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-28 19:20 -0500
1"""Trace and transaction commands."""
3import re
4from dataclasses import dataclass, field
5from typing import Annotated, Any
7import structlog
8import typer
9from rich.console import Console
10from rich.table import Table
11from rich.tree import Tree
13from sentry_tool.output import Column, OutputFormat, render
14from sentry_tool.utils import api, get_config
16TRACE_ID_PATTERN = re.compile(r"^[0-9a-fA-F]{32}$")
17MAX_DESCRIPTION_LENGTH = 50
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)
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.
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()
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 )
57 events = response.get("data", [])
59 if not events:
60 log.info("No transactions found", project=config["project"])
61 return
63 events = events[:max_rows]
65 if format == OutputFormat.json:
66 render(events, format)
67 return
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 ]
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 ]
90 render(rows, format, columns=columns, footer=f"Showing {len(events)} transactions")
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.
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)
113 config = get_config()
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 )
123 events = response.get("data", [])
125 if not events:
126 log.info("No events found for trace", trace_id=trace_id)
127 return
129 events.sort(key=lambda e: e.get("timestamp", ""))
130 events = events[:max_rows]
132 if format == OutputFormat.json:
133 render(events, format)
134 return
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 ]
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 ]
157 render(rows, format, columns=columns, footer=f"Showing {len(events)} events")
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
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 )
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 )
198 node_map[root.span_id] = root
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)
209 return root
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()
221 tree = Tree(f"[bold]{root.op}[/bold] {root.description}")
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)
232 _add_children(tree, root)
233 console.print(tree)
234 console.print(f"\n{total_spans} spans | {txn_duration:.3f}s total")
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
242 if total_duration <= 0:
243 console.print("\n[dim]Cannot render timeline (zero duration)[/dim]")
244 return
246 bar_width = 40
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)
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
261 pos = int(offset / total_duration * bar_width)
262 length = max(1, int(duration / total_duration * bar_width))
263 bar = " " * pos + "█" * length
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 )
273 console.print()
274 console.print(table)
275 console.print(f"\n Total duration: {total_duration:.3f}s")
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.
291 Examples:
292 sentry-tool transaction d3f1d81247ad4516b61da92f1db050dd
293 sentry-tool transaction d3f1d81247ad4516b61da92f1db050dd --format json
294 """
295 config = get_config()
297 event = api(
298 f"/organizations/{config['org']}/events/{config['project']}:{event_id}/",
299 token=config["auth_token"],
300 base_url=config["url"],
301 )
303 if format == OutputFormat.json:
304 render([event], format)
305 return
307 console = Console()
308 console.print(f"\n[bold cyan]=== Transaction {event_id[:8]}... ===[/bold cyan]")
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 ]
336 detail_columns = [
337 Column("Field", "field", style="bold"),
338 Column("Value", "value"),
339 ]
340 render(rows, OutputFormat.table, columns=detail_columns)
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
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 ]
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]")
374 console.print()
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.
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()
398 event = api(
399 f"/organizations/{config['org']}/events/{config['project']}:{event_id}/",
400 token=config["auth_token"],
401 base_url=config["url"],
402 )
404 spans, root_span_id, txn_duration = _extract_spans(event)
406 if not spans:
407 log.info("No span data found", event_id=event_id)
408 return
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
417 if format == OutputFormat.json:
418 render(spans, format)
419 return
421 root = _build_span_tree(spans, root_span_id)
422 _render_span_tree(root, len(spans), txn_duration)