#!/usr/bin/env python3 """Fetch a crash report from Sentry and output formatted markdown. Usage: script/sentry-fetch script/sentry-fetch ZED-4VS script/sentry-fetch 7243282041 Authentication (checked in order): 1. SENTRY_AUTH_TOKEN environment variable 2. Token from ~/.sentryclirc (written by `sentry-cli login`) If neither is found, the script will print setup instructions and exit. """ import argparse import configparser import json import os import sys import urllib.error import urllib.request SENTRY_BASE_URL = "https://sentry.io/api/0" DEFAULT_SENTRY_ORG = "zed-dev" def main(): parser = argparse.ArgumentParser( description="Fetch a crash report from Sentry and output formatted markdown." ) parser.add_argument( "issue", help="Sentry issue short ID (e.g. ZED-4VS) or numeric issue ID", ) args = parser.parse_args() token = find_auth_token() if not token: print( "Error: No Sentry auth token found.", file=sys.stderr, ) print( "\nSet up authentication using one of these methods:\n" " 1. Run `sentry-cli login` (stores token in ~/.sentryclirc)\n" " 2. Set the SENTRY_AUTH_TOKEN environment variable\n" "\nGet a token at https://sentry.io/settings/auth-tokens/", file=sys.stderr, ) sys.exit(1) try: issue_id, short_id, issue = resolve_issue(args.issue, token) event = fetch_latest_event(issue_id, token) except FetchError as err: print(f"Error: {err}", file=sys.stderr) sys.exit(1) markdown = format_crash_report(issue, event, short_id) print(markdown) class FetchError(Exception): pass def find_auth_token(): """Find a Sentry auth token from environment or ~/.sentryclirc. Checks in order: 1. SENTRY_AUTH_TOKEN environment variable 2. auth.token in ~/.sentryclirc (INI format, written by `sentry-cli login`) """ token = os.environ.get("SENTRY_AUTH_TOKEN") if token: return token sentryclirc_path = os.path.expanduser("~/.sentryclirc") if os.path.isfile(sentryclirc_path): config = configparser.ConfigParser() try: config.read(sentryclirc_path) token = config.get("auth", "token", fallback=None) if token: return token except configparser.Error: pass return None def api_get(path, token): """Make an authenticated GET request to the Sentry API.""" url = f"{SENTRY_BASE_URL}{path}" req = urllib.request.Request(url) req.add_header("Authorization", f"Bearer {token}") req.add_header("Accept", "application/json") try: with urllib.request.urlopen(req) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as err: body = err.read().decode("utf-8", errors="replace") try: detail = json.loads(body).get("detail", body) except (json.JSONDecodeError, AttributeError): detail = body raise FetchError(f"Sentry API returned HTTP {err.code} for {path}: {detail}") except urllib.error.URLError as err: raise FetchError(f"Failed to connect to Sentry API: {err.reason}") def resolve_issue(identifier, token): """Resolve a Sentry issue by short ID or numeric ID. Returns (issue_id, short_id, issue_data). """ if identifier.isdigit(): issue = api_get(f"/issues/{identifier}/", token) return identifier, issue.get("shortId", identifier), issue result = api_get(f"/organizations/{DEFAULT_SENTRY_ORG}/shortids/{identifier}/", token) group_id = str(result["groupId"]) issue = api_get(f"/issues/{group_id}/", token) return group_id, identifier, issue def fetch_latest_event(issue_id, token): """Fetch the latest event for an issue.""" return api_get(f"/issues/{issue_id}/events/latest/", token) def format_crash_report(issue, event, short_id): """Format a Sentry issue and event as a markdown crash report.""" lines = [] title = issue.get("title", "Unknown Crash") lines.append(f"# {title}") lines.append("") issue_id = issue.get("id", "unknown") project = issue.get("project", {}) project_slug = ( project.get("slug", "unknown") if isinstance(project, dict) else str(project) ) first_seen = issue.get("firstSeen", "unknown") last_seen = issue.get("lastSeen", "unknown") count = issue.get("count", "unknown") sentry_url = f"https://sentry.io/organizations/{DEFAULT_SENTRY_ORG}/issues/{issue_id}/" lines.append(f"**Short ID:** {short_id}") lines.append(f"**Issue ID:** {issue_id}") lines.append(f"**Project:** {project_slug}") lines.append(f"**Sentry URL:** {sentry_url}") lines.append(f"**First Seen:** {first_seen}") lines.append(f"**Last Seen:** {last_seen}") lines.append(f"**Event Count:** {count}") lines.append("") format_tags(lines, event) format_entries(lines, event) return "\n".join(lines) def format_tags(lines, event): """Extract and format tags from the event.""" tags = event.get("tags", []) if not tags: return lines.append("## Tags") lines.append("") for tag in tags: key = tag.get("key", "") if isinstance(tag, dict) else "" value = tag.get("value", "") if isinstance(tag, dict) else "" if key: lines.append(f"- **{key}:** {value}") lines.append("") def format_entries(lines, event): """Format exception and thread entries from the event.""" entries = event.get("entries", []) for entry in entries: entry_type = entry.get("type", "") if entry_type == "exception": format_exceptions(lines, entry) elif entry_type == "threads": format_threads(lines, entry) def format_exceptions(lines, entry): """Format exception entries.""" exceptions = entry.get("data", {}).get("values", []) if not exceptions: return lines.append("## Exceptions") lines.append("") for i, exc in enumerate(exceptions): exc_type = exc.get("type", "Unknown") exc_value = exc.get("value", "") mechanism = exc.get("mechanism", {}) lines.append(f"### Exception {i + 1}") lines.append(f"**Type:** {exc_type}") if exc_value: lines.append(f"**Value:** {exc_value}") if mechanism: mech_type = mechanism.get("type", "unknown") handled = mechanism.get("handled") if handled is not None: lines.append(f"**Mechanism:** {mech_type} (handled: {handled})") else: lines.append(f"**Mechanism:** {mech_type}") lines.append("") stacktrace = exc.get("stacktrace") if stacktrace: frames = stacktrace.get("frames", []) lines.append("#### Stacktrace") lines.append("") lines.append("```") lines.append(format_frames(frames)) lines.append("```") lines.append("") def format_threads(lines, entry): """Format thread entries, focusing on crashed and current threads.""" threads = entry.get("data", {}).get("values", []) if not threads: return crashed_threads = [t for t in threads if t.get("crashed", False)] current_threads = [ t for t in threads if t.get("current", False) and not t.get("crashed", False) ] other_threads = [ t for t in threads if not t.get("crashed", False) and not t.get("current", False) ] lines.append("## Threads") lines.append("") for thread in crashed_threads + current_threads: format_single_thread(lines, thread, show_frames=True) if other_threads: lines.append(f"*({len(other_threads)} other threads omitted)*") lines.append("") def format_single_thread(lines, thread, show_frames=False): """Format a single thread entry.""" thread_id = thread.get("id", "?") thread_name = thread.get("name", "unnamed") crashed = thread.get("crashed", False) current = thread.get("current", False) markers = [] if crashed: markers.append("CRASHED") if current: markers.append("current") marker_str = f" ({', '.join(markers)})" if markers else "" lines.append(f"### Thread {thread_id}: {thread_name}{marker_str}") lines.append("") if not show_frames: return stacktrace = thread.get("stacktrace") if not stacktrace: return frames = stacktrace.get("frames", []) if frames: lines.append("```") lines.append(format_frames(frames)) lines.append("```") lines.append("") def format_frames(frames): """Format stack trace frames for display. Sentry provides frames from outermost caller to innermost callee, so we reverse them to show the most recent (crashing) call first, matching the convention used in most crash report displays. """ output_lines = [] for frame in reversed(frames): func = frame.get("function") or frame.get("symbol") or "unknown" filename = ( frame.get("filename") or frame.get("absPath") or frame.get("abs_path") or "unknown file" ) line_no = frame.get("lineNo") or frame.get("lineno") in_app = frame.get("inApp", frame.get("in_app", False)) app_marker = "(In app)" if in_app else "(Not in app)" line_info = f"Line {line_no}" if line_no else "Line null" output_lines.append(f" {func} in {filename} [{line_info}] {app_marker}") context_lines = build_context_lines(frame, line_no) output_lines.extend(context_lines) return "\n".join(output_lines) def build_context_lines(frame, suspect_line_no): """Build context code lines for a single frame. Handles both Sentry response formats: - preContext/contextLine/postContext (separate fields) - context as an array of [line_no, code] tuples """ output = [] pre_context = frame.get("preContext") or frame.get("pre_context") or [] context_line = frame.get("contextLine") or frame.get("context_line") post_context = frame.get("postContext") or frame.get("post_context") or [] if context_line is not None or pre_context or post_context: for code_line in pre_context: output.append(f" {code_line}") if context_line is not None: output.append(f" {context_line} <-- SUSPECT LINE") for code_line in post_context: output.append(f" {code_line}") return output context = frame.get("context") or [] for ctx_entry in context: if isinstance(ctx_entry, list) and len(ctx_entry) >= 2: ctx_line_no = ctx_entry[0] ctx_code = ctx_entry[1] suspect = " <-- SUSPECT LINE" if ctx_line_no == suspect_line_no else "" output.append(f" {ctx_code}{suspect}") return output if __name__ == "__main__": main()