sentry-fetch

  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()