Skip to content

Commit 5a832dc

Browse files
build(profiling): add native heap-gotter cdylib build (PROF-15750) (#19078)
## Description Adds a standalone Rust `cdylib` (`src/native_heap_gotter/`) wrapping libdatadog's crates.io crate `libdd-profiling-heap-gotter` `1.0.0` behind a stable, ddtrace-owned C ABI: `ddtrace_heap_gotter_install()` / `_is_installed()` (→ `bool`, idempotent/fork-safe) and `_test_hook_hits()` (→ `u64`, `test-support` feature only, never shipped). Owning the cdylib (`[lib] name = "dd_heap_gotter"`) yields a clean `libdd_heap_gotter.so` and fixed, unmangled symbols for the future `ctypes` activator. - **Build-gated:** built only with `DD_PROFILING_NATIVE_HEAP_BUILD=1` on Linux 64-bit. `setup.py` runs `cargo build --release`, stages `libdd_heap_gotter<EXT_SUFFIX>.so` under `ddtrace/internal/datadog/profiling/`, sets the soname (`patchelf`), and adds it to `package_data` only when the flag is set. Off by default → mainline wheels unaffected. - **Inert:** nothing loads or calls the cdylib yet → zero runtime behavior change. Activation lands in #19079 (gated by `DD_PROFILING_NATIVE_HEAP_ENABLED`). ## Testing - [ ] `cargo build --release --manifest-path src/native_heap_gotter/Cargo.toml` resolves against crates.io and produces `libdd_heap_gotter.so`. - [x] Wheel built with `DD_PROFILING_NATIVE_HEAP_BUILD=1` (Linux 64-bit) ships the staged `.so` with the expected soname. - [ ] Default build (flag unset / non-Linux / 32-bit) is unchanged and ships no `.so`. Co-authored-by: vlad.scherbich <vlad.scherbich@datadoghq.com>
1 parent 4f1023a commit 5a832dc

10 files changed

Lines changed: 692 additions & 6 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ vgcore.*
234234

235235
# Rust build artifacts
236236
src/native/target*
237+
src/native_heap_gotter/target*
238+
239+
# Profiling collector local CMake build dir
240+
ddtrace/profiling/collector/build-test/
237241

238242
# Fuzzing corpus, output and artifacts
239243
.fuzz/

.gitlab-ci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,10 @@ profiling_native:
680680
- src/native/**/*.toml
681681
- src/native/**/*.txt
682682
- src/native/**/Cargo.lock
683+
# Standalone heap-gotter cdylib crate (opt-in build via setup.py)
684+
- src/native_heap_gotter/**/*.rs
685+
- src/native_heap_gotter/**/*.toml
686+
- src/native_heap_gotter/**/Cargo.lock
683687
# Top-level build config
684688
- setup.py
685689
- pyproject.toml

.gitlab/native.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,21 @@ include:
2121
echo -e "\e[0Ksection_start:`date +%s`:cargo_test[collapsed=true]\r\e[0Kcargo test"
2222
cargo test --no-fail-fast --locked
2323
echo -e "\e[0Ksection_end:`date +%s`:cargo_test\r\e[0K"
24+
# The standalone heap-gotter cdylib crate lives outside src/native, so run
25+
# the same fmt/clippy/test gate against it here to keep it under native CI.
26+
- |
27+
cd ../native_heap_gotter
28+
echo -e "\e[0Ksection_start:`date +%s`:gotter_cargo_fmt[collapsed=true]\r\e[0Kheap-gotter cargo fmt"
29+
cargo fmt --all -- --check
30+
echo -e "\e[0Ksection_end:`date +%s`:gotter_cargo_fmt\r\e[0K"
31+
- |
32+
echo -e "\e[0Ksection_start:`date +%s`:gotter_cargo_clippy[collapsed=true]\r\e[0Kheap-gotter cargo clippy"
33+
cargo clippy --locked --all-features -- -D warnings
34+
echo -e "\e[0Ksection_end:`date +%s`:gotter_cargo_clippy\r\e[0K"
35+
- |
36+
echo -e "\e[0Ksection_start:`date +%s`:gotter_cargo_test[collapsed=true]\r\e[0Kheap-gotter cargo test"
37+
cargo test --no-fail-fast --locked
38+
echo -e "\e[0Ksection_end:`date +%s`:gotter_cargo_test\r\e[0K"
2439
2540
"clang-tidy profiling":
2641
stage: tests

.gitlab/scripts/build-wheel-helpers.sh

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,45 @@ build_wheel() {
9999
repair_wheel() {
100100
# Extract debug symbols
101101
section_start "extract_debug_symbols" "Extracting debug symbols"
102-
uv run --no-project scripts/extract_debug_symbols.py "${BUILT_WHEEL_FILE}" --output-dir "${DEBUG_WHEEL_DIR}"
102+
uv run --no-project scripts/extract_debug_symbols.py "${BUILT_WHEEL_FILE}" \
103+
--output-dir "${DEBUG_WHEEL_DIR}" \
104+
--ignore-patterns "libddwaf*,libdd_heap_gotter*"
103105
section_end "extract_debug_symbols"
104106

107+
# Heap-gotter cdylib debug symbols are extracted in setup.py (build_heap_gotter);
108+
# merge any staged .debug sidecars into the debug-symbols package.
109+
section_start "merge_heap_gotter_debug_symbols" "Merging heap-gotter debug symbols"
110+
uv run --no-project python - <<'PY'
111+
import glob
112+
import os
113+
import zipfile
114+
from pathlib import Path
115+
116+
project_dir = os.environ["PROJECT_DIR"]
117+
debug_dir = os.environ["DEBUG_WHEEL_DIR"]
118+
sidecars = sorted(Path(project_dir, "build").rglob("libdd_heap_gotter*.debug"))
119+
if not sidecars:
120+
print("No heap-gotter debug sidecars found")
121+
raise SystemExit(0)
122+
packages = glob.glob(os.path.join(debug_dir, "*-debug-symbols.zip"))
123+
if not packages:
124+
print("WARNING: no debug-symbols package to merge heap-gotter sidecars into")
125+
raise SystemExit(0)
126+
pkg = packages[0]
127+
with zipfile.ZipFile(pkg, "a", zipfile.ZIP_DEFLATED) as zf:
128+
existing = set(zf.namelist())
129+
for sidecar in sidecars:
130+
parts = sidecar.parts
131+
try:
132+
arc = str(Path(*parts[parts.index("ddtrace") :]))
133+
except ValueError:
134+
arc = sidecar.name
135+
if arc not in existing:
136+
zf.write(sidecar, arc)
137+
print(f"Added heap-gotter debug symbols: {arc}")
138+
PY
139+
section_end "merge_heap_gotter_debug_symbols"
140+
105141
# Strip wheel
106142
section_start "strip_wheel" "Stripping unneeded files"
107143
uv run --no-project scripts/zip_filter.py "${BUILT_WHEEL_FILE}" \*.c \*.cpp \*.cc \*.h \*.hpp \*.pyx \*.md
@@ -115,7 +151,69 @@ repair_wheel() {
115151
# Repair wheel (ONLY PLATFORM-SPECIFIC CODE)
116152
section_start "repair_wheel" "Repairing wheel"
117153
if [[ "$(uname -s)" == "Linux" ]]; then
154+
# The opt-in heap-gotter cdylib (DD_PROFILING_NATIVE_HEAP_BUILD=1) has
155+
# non-standard ELF versioning sections that trip auditwheel's iter_versions
156+
# parser. --exclude does not help: it only drops a SONAME from dependency
157+
# grafting, while repair still parses every ELF listed in the wheel's RECORD.
158+
# So the cdylib has to leave the wheel entirely and be reinserted after.
159+
GOTTER_STASH_DIR="${WORK_DIR}/heap_gotter_stash"
160+
GOTTER_PATTERN='*libdd_heap_gotter*.so'
161+
if unzip -l "${BUILT_WHEEL_FILE}" | grep -q 'libdd_heap_gotter.*\.so$'; then
162+
mkdir -p "${GOTTER_STASH_DIR}"
163+
unzip -q "${BUILT_WHEEL_FILE}" "${GOTTER_PATTERN}" -d "${GOTTER_STASH_DIR}"
164+
uv run --no-project scripts/zip_filter.py "${BUILT_WHEEL_FILE}" "${GOTTER_PATTERN}"
165+
fi
166+
118167
auditwheel repair -w "${TMP_WHEEL_DIR}" "${BUILT_WHEEL_FILE}"
168+
169+
if [[ -d "${GOTTER_STASH_DIR}" ]]; then
170+
REPAIRED_WHEEL_FILE=$(ls "${TMP_WHEEL_DIR}"/*.whl | head -n 1)
171+
GOTTER_STASH_DIR="${GOTTER_STASH_DIR}" REPAIRED_WHEEL_FILE="${REPAIRED_WHEEL_FILE}" \
172+
uv run --no-project python - <<'PY'
173+
import base64
174+
import csv
175+
import hashlib
176+
import io
177+
import os
178+
import zipfile
179+
from pathlib import Path
180+
181+
wheel = Path(os.environ["REPAIRED_WHEEL_FILE"])
182+
stash = Path(os.environ["GOTTER_STASH_DIR"])
183+
184+
additions = {str(p.relative_to(stash)): p for p in sorted(stash.rglob("*")) if p.is_file()}
185+
if not additions:
186+
print("No stashed heap-gotter cdylib to reinsert")
187+
raise SystemExit(0)
188+
189+
tmp_wheel = Path(f"{wheel}.tmp")
190+
with (
191+
zipfile.ZipFile(wheel, "r") as source_zip,
192+
zipfile.ZipFile(tmp_wheel, "w", zipfile.ZIP_DEFLATED) as temp_zip,
193+
):
194+
record = next((f for f in source_zip.infolist() if f.filename.endswith(".dist-info/RECORD")), None)
195+
if record is None:
196+
raise SystemExit(f"no RECORD found in {wheel}")
197+
# DEV: Use ZipInfo objects to ensure original file attributes are preserved
198+
for file in source_zip.infolist():
199+
if file.filename == record.filename or file.filename in additions:
200+
continue
201+
temp_zip.writestr(file, source_zip.read(file.filename))
202+
rows = [r for r in csv.reader(io.StringIO(source_zip.read(record.filename).decode("utf-8"))) if r]
203+
rows = [r for r in rows if r[0] != record.filename and r[0] not in additions]
204+
for arcname, path in additions.items():
205+
data = path.read_bytes()
206+
temp_zip.writestr(arcname, data)
207+
digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode("ascii")
208+
rows.append([arcname, f"sha256={digest}", str(len(data))])
209+
print(f"Reinserted heap-gotter cdylib: {arcname}")
210+
rows.append([record.filename, "", ""])
211+
output = io.StringIO()
212+
csv.writer(output, lineterminator="\n").writerows(rows)
213+
temp_zip.writestr(record, output.getvalue())
214+
os.replace(tmp_wheel, wheel)
215+
PY
216+
fi
119217
else
120218
# macOS
121219
MACOSX_DEPLOYMENT_TARGET=14.7 uvx --from="delocate" delocate-wheel \

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ prune .riot/
77
prune benchmarks/
88
prune releasenotes/
99
prune src/native/target*
10+
prune src/native_heap_gotter/target*

scripts/check_profiling_native_coverage.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727

2828
from pathlib import Path
2929
import re
30-
import subprocess
30+
import subprocess # nosec B404
3131
import sys
32+
from typing import Any
3233

3334
from ruamel.yaml import YAML
3435

@@ -39,6 +40,7 @@
3940
"ddtrace/internal/datadog/profiling",
4041
"ddtrace/profiling",
4142
"src/native",
43+
"src/native_heap_gotter",
4244
]
4345

4446
# File extensions that belong to the native build graph.
@@ -96,9 +98,9 @@ def extract_profiling_native_patterns(ci_path: Path) -> list[str]:
9698
"""Extract rules:changes patterns for the profiling_native job."""
9799
yaml: YAML = YAML()
98100
yaml.allow_duplicate_keys = True
99-
data: dict = yaml.load(ci_path)
101+
data: dict[Any, Any] = yaml.load(ci_path)
100102

101-
rules: list[dict] = data["profiling_native"]["rules"]
103+
rules: list[dict[Any, Any]] = data["profiling_native"]["rules"]
102104
for rule in rules:
103105
if "changes" in rule:
104106
return list(rule["changes"])
@@ -111,7 +113,7 @@ def tracked_files(dirs: list[str] | None = None) -> list[str]:
111113
if dirs is None:
112114
dirs = ["."]
113115

114-
result: subprocess.CompletedProcess[str] = subprocess.run(
116+
result: subprocess.CompletedProcess[str] = subprocess.run( # nosec B603
115117
["git", "ls-files", "--"] + dirs,
116118
capture_output=True,
117119
text=True,

0 commit comments

Comments
 (0)