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

94 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-17 21:46 -0500

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

2 

3import re 

4from typing import Annotated, Any 

5 

6import structlog 

7import typer 

8from rich.console import Console 

9from rich.table import Table 

10 

11from sentry_tool.output import Column, OutputFormat, render 

12from sentry_tool.utils import api, get_config 

13 

14log = structlog.get_logger() 

15 

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

17 

18 

19def list_transactions( 

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

21 format: Annotated[ 

22 OutputFormat, 

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

24 ] = OutputFormat.table, 

25) -> None: 

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

27 

28 Examples: 

29 sentry-tool transactions 

30 sentry-tool transactions -n 5 

31 sentry-tool transactions --format json 

32 """ 

33 config = get_config() 

34 

35 response = api( 

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

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

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

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

40 "&sort=-timestamp", 

41 token=config["auth_token"], 

42 base_url=config["url"], 

43 ) 

44 

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

46 

47 if not events: 

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

49 return 

50 

51 events = events[:max_rows] 

52 

53 if format == OutputFormat.json: 

54 render(events, format) 

55 return 

56 

57 rows = [ 

58 { 

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

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

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

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

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

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

65 } 

66 for evt in events 

67 ] 

68 

69 columns = [ 

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

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

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

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

74 Column("Status", "status"), 

75 Column("Timestamp", "timestamp"), 

76 ] 

77 

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

79 

80 

81def lookup_trace( 

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

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

84 format: Annotated[ 

85 OutputFormat, 

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

87 ] = OutputFormat.table, 

88) -> None: 

89 """Examples: 

90 sentry-tool trace abc123def456789012345678901234ab 

91 sentry-tool trace abc123def456789012345678901234ab -n 10 

92 sentry-tool trace abc123def456789012345678901234ab --format json 

93 """ 

94 if not TRACE_ID_PATTERN.match(trace_id): 

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

96 raise typer.Exit(1) 

97 

98 config = get_config() 

99 

100 response = api( 

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

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

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

104 token=config["auth_token"], 

105 base_url=config["url"], 

106 ) 

107 

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

109 

110 if not events: 

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

112 return 

113 

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

115 events = events[:max_rows] 

116 

117 if format == OutputFormat.json: 

118 render(events, format) 

119 return 

120 

121 rows = [ 

122 { 

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

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

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

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

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

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

129 } 

130 for evt in events 

131 ] 

132 

133 columns = [ 

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

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

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

137 Column("Status", "status"), 

138 Column("Project", "project"), 

139 Column("Timestamp", "timestamp"), 

140 ] 

141 

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

143 

144 

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

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

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

148 total_duration = max_end - min_start 

149 

150 if total_duration <= 0: 

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

152 return 

153 

154 bar_width = 40 

155 

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

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

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

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

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

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

162 

163 for span in spans: 

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

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

166 offset = start - min_start 

167 duration = end - start 

168 

169 pos = int(offset / total_duration * bar_width) 

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

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

172 

173 table.add_row( 

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

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

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

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

178 bar, 

179 ) 

180 

181 console.print() 

182 console.print(table) 

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

184 

185 

186def show_transaction( 

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

188 format: Annotated[ 

189 OutputFormat, 

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

191 ] = OutputFormat.table, 

192 timeline: Annotated[ 

193 bool, 

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

195 ] = False, 

196) -> None: 

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

198 

199 Examples: 

200 sentry-tool transaction d3f1d81247ad4516b61da92f1db050dd 

201 sentry-tool transaction d3f1d81247ad4516b61da92f1db050dd --format json 

202 """ 

203 config = get_config() 

204 

205 event = api( 

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

207 token=config["auth_token"], 

208 base_url=config["url"], 

209 ) 

210 

211 if format == OutputFormat.json: 

212 render([event], format) 

213 return 

214 

215 console = Console() 

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

217 

218 rows = [ 

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

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

221 { 

222 "field": "Trace ID", 

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

224 }, 

225 { 

226 "field": "Span ID", 

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

228 }, 

229 { 

230 "field": "Parent Span", 

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

232 }, 

233 { 

234 "field": "Duration", 

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

236 }, 

237 { 

238 "field": "Status", 

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

240 }, 

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

242 ] 

243 

244 detail_columns = [ 

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

246 Column("Value", "value"), 

247 ] 

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

249 

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

251 spans = [] 

252 for entry in entries: 

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

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

255 break 

256 

257 if spans: 

258 if timeline: 

259 _render_timeline(spans, console) 

260 else: 

261 span_rows = [ 

262 { 

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

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

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

266 } 

267 for span in spans 

268 ] 

269 

270 span_columns = [ 

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

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

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

274 ] 

275 console.print() 

276 render( 

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

278 ) 

279 else: 

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

281 

282 console.print()