Skip to content

Commit 33e53d4

Browse files
docs(profiling): address native heap PR review feedback
Clarify that native heap profiling is an additive profiler feature (DD_PROFILING_ENABLED required), needs the eBPF profiler for collection, and uses OpenTelemetry/Datadog Host Profiler terminology. Add an idempotent restart test for post-fork profiler wiring.
1 parent 68c6e48 commit 33e53d4

5 files changed

Lines changed: 182 additions & 12 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Activator for native (C/C++) heap allocation profiling via GOT rewriting.
2+
3+
This module dlopen's libdatadog's ``libdd-profiling-heap-gotter-ffi`` cdylib
4+
(staged as ``liblibdd_profiling_heap_gotter_ffi<EXT_SUFFIX>.so``; see
5+
``src/native_heap_gotter/``) and drives it through the shared libdatadog C ABI:
6+
7+
ddog_VoidResult ddog_heap_gotter_install(void);
8+
bool ddog_heap_gotter_is_installed(void);
9+
10+
Calling ``install()`` patches the process's GOT entries for heap allocation
11+
symbols so that Datadog's ``ddheap:alloc`` (Phase 1: allocation-only) USDT probe
12+
sites fire on sampled allocations. The OpenTelemetry eBPF profiler or Datadog
13+
Host Profiler then attaches uprobes to those sites to collect native allocation
14+
flamegraphs. There is nothing to collect or upload from the Python side — this
15+
only *arms* the probes.
16+
17+
Fail-closed by design: if the cdylib is missing (the default, since it only
18+
ships when built with ``DD_PROFILING_NATIVE_HEAP_BUILD=1``) or anything goes
19+
wrong loading it, ``is_available`` is ``False`` and ``install()`` is a no-op
20+
returning ``False``. Loading this module must never raise.
21+
22+
Permanence: installation cannot be undone (the patched GOT entries point at
23+
functions inside the cdylib), so the library must stay mapped for the life of
24+
the process. We keep the ``ctypes.CDLL`` handle at module scope and never unload
25+
it. After ``fork()`` the child inherits both the mapping and the patched GOT, so
26+
a re-install in the child is a harmless idempotent no-op.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import ctypes
32+
import os
33+
import sysconfig
34+
35+
36+
# Upstream libdatadog FFI artifact base name (double-`lib` prefix).
37+
_LIBRARY_BASENAME = "liblibdd_profiling_heap_gotter_ffi"
38+
39+
# Mirror cbindgen tags for ddog_VoidResult (common.h).
40+
DDOG_VOID_RESULT_OK = 0
41+
DDOG_VOID_RESULT_ERR = 1
42+
43+
44+
class _DdogVecU8(ctypes.Structure):
45+
_fields_ = [
46+
("ptr", ctypes.c_void_p),
47+
("len", ctypes.c_size_t),
48+
("capacity", ctypes.c_size_t),
49+
]
50+
51+
52+
class _DdogError(ctypes.Structure):
53+
_fields_ = [("message", _DdogVecU8)]
54+
55+
56+
class _DdogVoidResult(ctypes.Structure):
57+
_fields_ = [
58+
("tag", ctypes.c_uint32),
59+
("err", _DdogError),
60+
]
61+
62+
63+
# Mirror the ddup/stack modules: importers (notably settings/profiling.py) read
64+
# these two attributes to decide whether the feature can run.
65+
is_available: bool = False
66+
failure_msg: str = ""
67+
68+
_lib: ctypes.CDLL | None = None # kept alive for process lifetime; never dlclose'd
69+
70+
71+
def _library_path() -> str:
72+
suffix: str = sysconfig.get_config_var("EXT_SUFFIX") or ".so"
73+
profiling_dir: str = os.path.dirname(os.path.dirname(__file__))
74+
return os.path.join(profiling_dir, _LIBRARY_BASENAME + suffix)
75+
76+
77+
def _void_result_ok(result: _DdogVoidResult) -> bool:
78+
if result.tag == DDOG_VOID_RESULT_OK:
79+
return True
80+
if _lib is not None:
81+
try:
82+
_lib.ddog_Error_drop(ctypes.byref(result.err))
83+
except Exception: # nosec: B110
84+
pass
85+
return False
86+
87+
88+
try:
89+
# Native heap profiling via the gotter is Linux-only; on every other
90+
# platform the underlying library is a no-op, so don't even try to load.
91+
if os.name != "posix" or os.uname().sysname != "Linux":
92+
raise OSError("native heap gotter is only supported on Linux")
93+
94+
_path: str = _library_path()
95+
if not os.path.exists(_path):
96+
raise FileNotFoundError(_path)
97+
98+
# RTLD_GLOBAL so the loaded code is unambiguously resolvable; RTLD_NOW so any
99+
# unresolved symbol fails here (fail-closed) rather than at first call.
100+
_lib = ctypes.CDLL(_path, mode=ctypes.RTLD_GLOBAL | getattr(os, "RTLD_NOW", 0))
101+
102+
_lib.ddog_heap_gotter_install.argtypes = []
103+
_lib.ddog_heap_gotter_install.restype = _DdogVoidResult
104+
_lib.ddog_heap_gotter_is_installed.argtypes = []
105+
_lib.ddog_heap_gotter_is_installed.restype = ctypes.c_bool
106+
_lib.ddog_Error_drop.argtypes = [ctypes.POINTER(_DdogError)]
107+
_lib.ddog_Error_drop.restype = None
108+
109+
is_available = True
110+
111+
except Exception as e:
112+
failure_msg = str(e)
113+
_lib = None
114+
115+
116+
def install() -> bool:
117+
"""Install the native heap GOT overrides. Returns True if now installed.
118+
119+
Idempotent and safe to call more than once (e.g. after fork). No-op that
120+
returns False when the cdylib is unavailable.
121+
"""
122+
if not is_available or _lib is None:
123+
return False
124+
try:
125+
return _void_result_ok(_lib.ddog_heap_gotter_install())
126+
except Exception:
127+
return False
128+
129+
130+
def is_installed() -> bool:
131+
"""Return whether native heap GOT overrides are currently installed."""
132+
if not is_available or _lib is None:
133+
return False
134+
try:
135+
return bool(_lib.ddog_heap_gotter_is_installed())
136+
except Exception:
137+
return False

ddtrace/internal/settings/profiling.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -547,11 +547,12 @@ class ProfilingConfigNativeHeap(DDConfig):
547547
help=(
548548
"Whether to arm native (C/C++) heap allocation profiling by installing GOT "
549549
"overrides for allocation symbols so Datadog's ``ddheap`` USDT probe sites "
550-
"fire on sampled allocations. Samples are collected out-of-band by the "
551-
"Datadog Full Host eBPF profiler; nothing is collected or uploaded by the "
552-
"tracer itself. Requires Linux and a wheel built with "
553-
"``DD_PROFILING_NATIVE_HEAP_BUILD=1``. Phase 1 is allocation-only. Disabled "
554-
"by default (experimental)."
550+
"fire on sampled allocations. Requires ``DD_PROFILING_ENABLED=true`` so the "
551+
"profiler starts and calls the activator; samples are collected out-of-band "
552+
"by the OpenTelemetry eBPF profiler or Datadog Host Profiler (nothing is "
553+
"collected or uploaded by the tracer itself). Requires Linux and a wheel "
554+
"built with ``DD_PROFILING_NATIVE_HEAP_BUILD=1``. Phase 1 is allocation-only. "
555+
"Disabled by default (experimental)."
555556
),
556557
)
557558

ddtrace/profiling/profiler.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,13 @@ def _start_service(self) -> None:
381381
"""Start the profiler."""
382382
# Arm native (C/C++) heap allocation profiling, if requested and
383383
# available. This installs process-global GOT overrides so Datadog's
384-
# ddheap USDT probe sites fire on sampled allocations; the Full Host
385-
# eBPF profiler collects them out-of-band. Installation is permanent and
386-
# idempotent, so re-running it after a fork restart is a harmless no-op.
384+
# ddheap USDT probe sites fire on sampled allocations; the OpenTelemetry
385+
# eBPF profiler or Datadog Host Profiler collects them out-of-band. This
386+
# only runs when the Python profiler is started (``DD_PROFILING_ENABLED``),
387+
# so native heap is an additive profiling feature rather than a standalone
388+
# toggle. Installation is permanent and idempotent: after ``fork()`` the
389+
# child inherits the patched GOT, and a post-fork ``_start_service()`` call
390+
# (e.g. uWSGI workers) is a harmless no-op.
387391
if profiling_config.native_heap.enabled:
388392
from ddtrace.internal.datadog.profiling import heap_gotter
389393

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
features:
22
- |
33
profiling: Add experimental, opt-in native (C/C++) heap allocation profiling that
4-
integrates with the Datadog eBPF profiler, enabled via
5-
``DD_PROFILING_NATIVE_HEAP_ENABLED=true``. Profile collection and processing run
6-
outside the application process. Allocation-only in this release, with live heap
7-
tracking planned for the future. It is a no-op when disabled.
4+
integrates with the OpenTelemetry eBPF profiler or Datadog Host Profiler, enabled via
5+
``DD_PROFILING_NATIVE_HEAP_ENABLED=true`` together with ``DD_PROFILING_ENABLED=true``.
6+
dd-trace-py arms ``ddheap:alloc`` USDT probes at profiler startup; the eBPF profiler
7+
collects and processes samples outside the application process. Allocation-only in
8+
this release, with live heap tracking planned for the future. It is a no-op when
9+
disabled.

tests/profiling/test_native_heap_gotter.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,32 @@ def test_native_heap_gotter_smoke() -> None:
4242
for i in range(200):
4343
blobs.append(("x" * 4096, i))
4444
assert len(blobs) == 200
45+
46+
47+
@pytest.mark.subprocess(env=dict(DD_PROFILING_ENABLED="true"))
48+
def test_profiler_start_native_heap_install_idempotent_on_restart() -> None:
49+
"""Post-fork profiler restarts call install() again; that must be harmless."""
50+
from unittest import mock
51+
52+
from ddtrace.internal.datadog.profiling import heap_gotter
53+
from ddtrace.internal.settings.profiling import config as profiling_config
54+
55+
profiling_config.native_heap.enabled = True # pyright: ignore[reportAttributeAccessIssue]
56+
57+
with mock.patch.object(heap_gotter, "install", return_value=True) as install:
58+
from ddtrace.profiling.profiler import Profiler
59+
60+
prof: Profiler = Profiler()
61+
prof.start()
62+
try:
63+
assert install.call_count == 1
64+
# Simulate uWSGI post-fork restart: _start_service() runs again.
65+
prof._profiler._start_service()
66+
assert install.call_count == 2
67+
finally:
68+
prof.stop(flush=False)
69+
70+
4571
@pytest.mark.subprocess(env=dict(DD_PROFILING_ENABLED="true"))
4672
def test_profiler_start_arms_native_heap_when_enabled() -> None:
4773
"""Starting the profiler with native heap enabled invokes the activator.

0 commit comments

Comments
 (0)