1#!/usr/bin/env python3
2"""Fetch a crash report from Sentry and output formatted markdown.
3
4Usage:
5 script/sentry-fetch <issue-short-id-or-numeric-id>
6 script/sentry-fetch ZED-4VS
7 script/sentry-fetch 7243282041
8
9Authentication (checked in order):
10 1. SENTRY_AUTH_TOKEN environment variable
11 2. Token from ~/.sentryclirc (written by `sentry-cli login`)
12
13If neither is found, the script will print setup instructions and exit.
14"""
15
16import argparse
17import configparser
18import json
19import os
20import sys
21import urllib.error
22import urllib.request
23
24SENTRY_BASE_URL = "https://sentry.io/api/0"
25DEFAULT_SENTRY_ORG = "zed-dev"
26
27
28def main():
29 parser = argparse.ArgumentParser(
30 description="Fetch a crash report from Sentry and output formatted markdown."
31 )
32 parser.add_argument(
33 "issue",
34 help="Sentry issue short ID (e.g. ZED-4VS) or numeric issue ID",
35 )
36 args = parser.parse_args()
37
38 token = find_auth_token()
39 if not token:
40 print(
41 "Error: No Sentry auth token found.",
42 file=sys.stderr,
43 )
44 print(
45 "\nSet up authentication using one of these methods:\n"
46 " 1. Run `sentry-cli login` (stores token in ~/.sentryclirc)\n"
47 " 2. Set the SENTRY_AUTH_TOKEN environment variable\n"
48 "\nGet a token at https://sentry.io/settings/auth-tokens/",
49 file=sys.stderr,
50 )
51 sys.exit(1)
52
53 try:
54 issue_id, short_id, issue = resolve_issue(args.issue, token)
55 event = fetch_latest_event(issue_id, token)
56 except FetchError as err:
57 print(f"Error: {err}", file=sys.stderr)
58 sys.exit(1)
59
60 markdown = format_crash_report(issue, event, short_id)
61 print(markdown)
62
63
64class FetchError(Exception):
65 pass
66
67
68def find_auth_token():
69 """Find a Sentry auth token from environment or ~/.sentryclirc.
70
71 Checks in order:
72 1. SENTRY_AUTH_TOKEN environment variable
73 2. auth.token in ~/.sentryclirc (INI format, written by `sentry-cli login`)
74 """
75 token = os.environ.get("SENTRY_AUTH_TOKEN")
76 if token:
77 return token
78
79 sentryclirc_path = os.path.expanduser("~/.sentryclirc")
80 if os.path.isfile(sentryclirc_path):
81 config = configparser.ConfigParser()
82 try:
83 config.read(sentryclirc_path)
84 token = config.get("auth", "token", fallback=None)
85 if token:
86 return token
87 except configparser.Error:
88 pass
89
90 return None
91
92
93def api_get(path, token):
94 """Make an authenticated GET request to the Sentry API."""
95 url = f"{SENTRY_BASE_URL}{path}"
96 req = urllib.request.Request(url)
97 req.add_header("Authorization", f"Bearer {token}")
98 req.add_header("Accept", "application/json")
99 try:
100 with urllib.request.urlopen(req) as response:
101 return json.loads(response.read().decode("utf-8"))
102 except urllib.error.HTTPError as err:
103 body = err.read().decode("utf-8", errors="replace")
104 try:
105 detail = json.loads(body).get("detail", body)
106 except (json.JSONDecodeError, AttributeError):
107 detail = body
108 raise FetchError(f"Sentry API returned HTTP {err.code} for {path}: {detail}")
109 except urllib.error.URLError as err:
110 raise FetchError(f"Failed to connect to Sentry API: {err.reason}")
111
112
113def resolve_issue(identifier, token):
114 """Resolve a Sentry issue by short ID or numeric ID.
115
116 Returns (issue_id, short_id, issue_data).
117 """
118 if identifier.isdigit():
119 issue = api_get(f"/issues/{identifier}/", token)
120 return identifier, issue.get("shortId", identifier), issue
121
122 result = api_get(f"/organizations/{DEFAULT_SENTRY_ORG}/shortids/{identifier}/", token)
123 group_id = str(result["groupId"])
124 issue = api_get(f"/issues/{group_id}/", token)
125 return group_id, identifier, issue
126
127
128def fetch_latest_event(issue_id, token):
129 """Fetch the latest event for an issue."""
130 return api_get(f"/issues/{issue_id}/events/latest/", token)
131
132
133def format_crash_report(issue, event, short_id):
134 """Format a Sentry issue and event as a markdown crash report."""
135 lines = []
136
137 title = issue.get("title", "Unknown Crash")
138 lines.append(f"# {title}")
139 lines.append("")
140
141 issue_id = issue.get("id", "unknown")
142 project = issue.get("project", {})
143 project_slug = (
144 project.get("slug", "unknown") if isinstance(project, dict) else str(project)
145 )
146 first_seen = issue.get("firstSeen", "unknown")
147 last_seen = issue.get("lastSeen", "unknown")
148 count = issue.get("count", "unknown")
149 sentry_url = f"https://sentry.io/organizations/{DEFAULT_SENTRY_ORG}/issues/{issue_id}/"
150
151 lines.append(f"**Short ID:** {short_id}")
152 lines.append(f"**Issue ID:** {issue_id}")
153 lines.append(f"**Project:** {project_slug}")
154 lines.append(f"**Sentry URL:** {sentry_url}")
155 lines.append(f"**First Seen:** {first_seen}")
156 lines.append(f"**Last Seen:** {last_seen}")
157 lines.append(f"**Event Count:** {count}")
158 lines.append("")
159
160 format_tags(lines, event)
161 format_entries(lines, event)
162
163 return "\n".join(lines)
164
165
166def format_tags(lines, event):
167 """Extract and format tags from the event."""
168 tags = event.get("tags", [])
169 if not tags:
170 return
171
172 lines.append("## Tags")
173 lines.append("")
174 for tag in tags:
175 key = tag.get("key", "") if isinstance(tag, dict) else ""
176 value = tag.get("value", "") if isinstance(tag, dict) else ""
177 if key:
178 lines.append(f"- **{key}:** {value}")
179 lines.append("")
180
181
182def format_entries(lines, event):
183 """Format exception and thread entries from the event."""
184 entries = event.get("entries", [])
185
186 for entry in entries:
187 entry_type = entry.get("type", "")
188
189 if entry_type == "exception":
190 format_exceptions(lines, entry)
191 elif entry_type == "threads":
192 format_threads(lines, entry)
193
194
195def format_exceptions(lines, entry):
196 """Format exception entries."""
197 exceptions = entry.get("data", {}).get("values", [])
198 if not exceptions:
199 return
200
201 lines.append("## Exceptions")
202 lines.append("")
203
204 for i, exc in enumerate(exceptions):
205 exc_type = exc.get("type", "Unknown")
206 exc_value = exc.get("value", "")
207 mechanism = exc.get("mechanism", {})
208
209 lines.append(f"### Exception {i + 1}")
210 lines.append(f"**Type:** {exc_type}")
211 if exc_value:
212 lines.append(f"**Value:** {exc_value}")
213 if mechanism:
214 mech_type = mechanism.get("type", "unknown")
215 handled = mechanism.get("handled")
216 if handled is not None:
217 lines.append(f"**Mechanism:** {mech_type} (handled: {handled})")
218 else:
219 lines.append(f"**Mechanism:** {mech_type}")
220 lines.append("")
221
222 stacktrace = exc.get("stacktrace")
223 if stacktrace:
224 frames = stacktrace.get("frames", [])
225 lines.append("#### Stacktrace")
226 lines.append("")
227 lines.append("```")
228 lines.append(format_frames(frames))
229 lines.append("```")
230 lines.append("")
231
232
233def format_threads(lines, entry):
234 """Format thread entries, focusing on crashed and current threads."""
235 threads = entry.get("data", {}).get("values", [])
236 if not threads:
237 return
238
239 crashed_threads = [t for t in threads if t.get("crashed", False)]
240 current_threads = [
241 t for t in threads if t.get("current", False) and not t.get("crashed", False)
242 ]
243 other_threads = [
244 t
245 for t in threads
246 if not t.get("crashed", False) and not t.get("current", False)
247 ]
248
249 lines.append("## Threads")
250 lines.append("")
251
252 for thread in crashed_threads + current_threads:
253 format_single_thread(lines, thread, show_frames=True)
254
255 if other_threads:
256 lines.append(f"*({len(other_threads)} other threads omitted)*")
257 lines.append("")
258
259
260def format_single_thread(lines, thread, show_frames=False):
261 """Format a single thread entry."""
262 thread_id = thread.get("id", "?")
263 thread_name = thread.get("name", "unnamed")
264 crashed = thread.get("crashed", False)
265 current = thread.get("current", False)
266
267 markers = []
268 if crashed:
269 markers.append("CRASHED")
270 if current:
271 markers.append("current")
272 marker_str = f" ({', '.join(markers)})" if markers else ""
273
274 lines.append(f"### Thread {thread_id}: {thread_name}{marker_str}")
275 lines.append("")
276
277 if not show_frames:
278 return
279
280 stacktrace = thread.get("stacktrace")
281 if not stacktrace:
282 return
283
284 frames = stacktrace.get("frames", [])
285 if frames:
286 lines.append("```")
287 lines.append(format_frames(frames))
288 lines.append("```")
289 lines.append("")
290
291
292def format_frames(frames):
293 """Format stack trace frames for display.
294
295 Sentry provides frames from outermost caller to innermost callee,
296 so we reverse them to show the most recent (crashing) call first,
297 matching the convention used in most crash report displays.
298 """
299 output_lines = []
300
301 for frame in reversed(frames):
302 func = frame.get("function") or frame.get("symbol") or "unknown"
303 filename = (
304 frame.get("filename")
305 or frame.get("absPath")
306 or frame.get("abs_path")
307 or "unknown file"
308 )
309 line_no = frame.get("lineNo") or frame.get("lineno")
310 in_app = frame.get("inApp", frame.get("in_app", False))
311
312 app_marker = "(In app)" if in_app else "(Not in app)"
313 line_info = f"Line {line_no}" if line_no else "Line null"
314
315 output_lines.append(f" {func} in {filename} [{line_info}] {app_marker}")
316
317 context_lines = build_context_lines(frame, line_no)
318 output_lines.extend(context_lines)
319
320 return "\n".join(output_lines)
321
322
323def build_context_lines(frame, suspect_line_no):
324 """Build context code lines for a single frame.
325
326 Handles both Sentry response formats:
327 - preContext/contextLine/postContext (separate fields)
328 - context as an array of [line_no, code] tuples
329 """
330 output = []
331
332 pre_context = frame.get("preContext") or frame.get("pre_context") or []
333 context_line = frame.get("contextLine") or frame.get("context_line")
334 post_context = frame.get("postContext") or frame.get("post_context") or []
335
336 if context_line is not None or pre_context or post_context:
337 for code_line in pre_context:
338 output.append(f" {code_line}")
339 if context_line is not None:
340 output.append(f" {context_line} <-- SUSPECT LINE")
341 for code_line in post_context:
342 output.append(f" {code_line}")
343 return output
344
345 context = frame.get("context") or []
346 for ctx_entry in context:
347 if isinstance(ctx_entry, list) and len(ctx_entry) >= 2:
348 ctx_line_no = ctx_entry[0]
349 ctx_code = ctx_entry[1]
350 suspect = " <-- SUSPECT LINE" if ctx_line_no == suspect_line_no else ""
351 output.append(f" {ctx_code}{suspect}")
352
353 return output
354
355
356if __name__ == "__main__":
357 main()