Skip to content

Commit c0e2d1f

Browse files
committed
Add script for uploading debug symbols to sentry
1 parent 854c356 commit c0e2d1f

1 file changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
#!/usr/bin/env python3
2+
3+
import argparse
4+
import json
5+
import os
6+
import re
7+
import subprocess
8+
import sys
9+
import urllib.error
10+
import urllib.parse
11+
import urllib.request
12+
13+
NAR_DIR = "/tmp/nars"
14+
DEBUG_INFO_DIR = "/tmp/debug-info"
15+
16+
17+
def get_dynamic_libraries(executable: str) -> list[str]:
18+
result = subprocess.run(["ldd", executable], capture_output=True, text=True, check=True)
19+
libs = []
20+
for line in result.stdout.splitlines():
21+
# ldd output lines look like:
22+
# libfoo.so.1 => /nix/store/.../libfoo.so.1 (0x...)
23+
# /lib64/ld-linux-x86-64.so.2 (0x...)
24+
m = re.search(r"=> (/\S+)", line)
25+
if m:
26+
libs.append(m.group(1))
27+
elif line.strip().startswith("/"):
28+
path = line.strip().split()[0]
29+
libs.append(path)
30+
return libs
31+
32+
33+
def get_build_id(path: str) -> str | None:
34+
result = subprocess.run(["readelf", "-n", path], capture_output=True, text=True)
35+
m = re.search(r"Build ID:\s+([0-9a-f]+)", result.stdout)
36+
return m.group(1) if m else None
37+
38+
39+
def download_nar(build_id: str, archive: str) -> str:
40+
"""Download a NAR to /tmp/nars and return the local path. Skips if already present."""
41+
base_url = f"https://cache.nixos.org/debuginfo/{build_id}"
42+
nar_url = urllib.parse.urljoin(base_url, archive)
43+
filename = nar_url.split("/")[-1]
44+
local_path = os.path.join(NAR_DIR, filename)
45+
if not os.path.exists(local_path):
46+
os.makedirs(NAR_DIR, exist_ok=True)
47+
print(f" downloading {nar_url} ...", file=sys.stderr)
48+
urllib.request.urlretrieve(nar_url, local_path)
49+
else:
50+
print(f" already have {filename}", file=sys.stderr)
51+
return local_path
52+
53+
54+
def extract_debug_symbols(nar_path: str, member: str, build_id: str) -> str:
55+
"""Extract a member from a .nar.xz into /tmp/debug-info/<build_id>.debug. Returns the output path."""
56+
out_path = os.path.join(DEBUG_INFO_DIR, f"{build_id}.debug")
57+
if os.path.exists(out_path):
58+
print(f" already extracted {out_path}", file=sys.stderr)
59+
return out_path
60+
os.makedirs(DEBUG_INFO_DIR, exist_ok=True)
61+
print(f" extracting {member} -> {out_path} ...", file=sys.stderr)
62+
xz = subprocess.Popen(["xz", "-d"], stdin=open(nar_path, "rb"), stdout=subprocess.PIPE)
63+
nar_cat = subprocess.run(
64+
["nix", "nar", "cat", "/dev/stdin", member],
65+
stdin=xz.stdout,
66+
capture_output=True,
67+
check=True,
68+
)
69+
xz.wait()
70+
with open(out_path, "wb") as f:
71+
f.write(nar_cat.stdout)
72+
return out_path
73+
74+
75+
def find_debug_file_in_dirs(build_id: str, debug_dirs: list[str]) -> str | None:
76+
"""Look for a .debug file by build ID under <dir>/lib/debug/.build-id/NN/NNN.debug."""
77+
subpath = os.path.join("lib", "debug", ".build-id", build_id[:2], build_id[2:] + ".debug")
78+
for d in debug_dirs:
79+
candidate = os.path.join(d, subpath)
80+
if os.path.exists(candidate):
81+
return candidate
82+
return None
83+
84+
85+
def fetch_debuginfo(build_id: str) -> dict | None:
86+
url = f"https://cache.nixos.org/debuginfo/{build_id}"
87+
try:
88+
with urllib.request.urlopen(url) as resp:
89+
return json.loads(resp.read())
90+
except urllib.error.HTTPError as e:
91+
if e.code == 404:
92+
return None
93+
raise
94+
95+
96+
def main():
97+
parser = argparse.ArgumentParser(
98+
description="Upload ELF debug symbols to Sentry."
99+
)
100+
parser.add_argument("executable", help="Path to the ELF executable (e.g. ./result/bin/nix)")
101+
parser.add_argument("--project", required=True, help="Sentry project ID")
102+
parser.add_argument("--debug-dir", action="append", default=[], metavar="DIR",
103+
help="Directory to search for debug files (may be repeated)")
104+
args = parser.parse_args()
105+
106+
debug_files = []
107+
108+
libs = [args.executable] + get_dynamic_libraries(args.executable)
109+
print("ELF files to process:", file=sys.stderr)
110+
for lib in libs:
111+
build_id = get_build_id(lib)
112+
if build_id is None:
113+
print(f" {lib} (no build ID)", file=sys.stderr)
114+
continue
115+
116+
local = find_debug_file_in_dirs(build_id, args.debug_dir)
117+
if local:
118+
print(f" {lib} ({build_id}): found locally at {local}", file=sys.stderr)
119+
debug_files.append(local)
120+
continue
121+
122+
debuginfo = fetch_debuginfo(build_id)
123+
if debuginfo is None:
124+
print(f" {lib} ({build_id}, no debug info in cache)", file=sys.stderr)
125+
continue
126+
print(f" {lib} ({build_id}): member={debuginfo['member']}", file=sys.stderr)
127+
nar_path = download_nar(build_id, debuginfo["archive"])
128+
debug_file = extract_debug_symbols(nar_path, debuginfo["member"], build_id)
129+
debug_files.append(debug_file)
130+
131+
if debug_files:
132+
print(f"Uploading {len(debug_files)} debug file(s) to Sentry...", file=sys.stderr)
133+
subprocess.run(
134+
["sentry-cli", "debug-files", "upload", "--project", args.project] + debug_files,
135+
check=True,
136+
)
137+
138+
139+
if __name__ == "__main__":
140+
main()

0 commit comments

Comments
 (0)