mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-23 21:05:08 +00:00
These prompts can be used to automatically diagnose and fix crashes report in Sentry. Usage: 1. Find a crash in Sentry. It will have an ID like ZED-123 2. In an agent, do a prompt like `Follow the instructions in @investigate.md to investigate ZED-123` 3. Once the agent finds a repro, fix it in a new thread by saying `Follow the instructions in @fix.md` Release Notes: - N/A --------- Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
357 lines
11 KiB
Python
Executable file
357 lines
11 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Fetch a crash report from Sentry and output formatted markdown.
|
|
|
|
Usage:
|
|
script/sentry-fetch <issue-short-id-or-numeric-id>
|
|
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()
|