Files
zed/script/sentry-fetch
Mohamad Khani b72a46db68
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 02:22:17 +03:30

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