Tracer Version(s)
3.19.0, 4.7.1, 4.12.2
Python Version(s)
Python 3.11.x, Python 3.13
Pip Version(s)
pip from python:3.13-slim-bookworm
Bug Report
Summary
When using Python runtime metrics (DD_RUNTIME_METRICS_ENABLED=true) with gunicorn (and similarly with Kubernetes single-step instrumentation / SSI, which enables products in the master before fork), runtime.python.mem.rss is often wrong by an order of magnitude: Datadog shows roughly master-sized RSS and usually one runtime-id, while worker processes each hold hundreds of MiB and the container cgroup is ~N× larger.
Separately, even when metrics emit, RuntimeWorker.periodic() can overwrite DogStatsD constant_tags and drop dd.internal.entity_id, so Kubernetes origin tags (pod_name, kube_namespace) never attach — despite DD_ENTITY_ID being set correctly.
Neither behavior is documented in the public Runtime Metrics (Python) docs (tracing/metrics/runtime_metrics/?tab=python) or the Kubernetes origin-detection docs. Operators comparing runtime.python.mem.rss to container.memory.rss will see a large “leak” that is actually a client fork / tagging bug.
Environment
- Gunicorn multi-worker APIs on AWS EKS (Bottlerocket, cgroup v2)
- Datadog Operator admission / SSI targeting Python tracer major 4 (injected ~4.12.2)
DD_RUNTIME_METRICS_ENABLED=true, DD_RUNTIME_METRICS_RUNTIME_ID_ENABLED=true
DD_ENTITY_ID from the downward API (pod UID); DD_AGENT_HOST = node Agent
Issue A — Gunicorn / pre-fork: RSS bound to master PID (primary)
Symptom
- Series exist and may have pod tags.
- Often one
runtime-id per pod.
runtime.python.mem.rss ≈ master VmRSS (tens of MiB).
- Each gunicorn worker’s
/proc/<pid>/status VmRSS is hundreds of MiB; cgroup RSS ≈ N× worker.
avg:runtime.python.mem.rss vs container.memory.rss looks like a multi‑GB discrepancy — they are not comparable until per-worker series exist.
Root cause (library behavior)
Two behaviors compound after fork:
-
RuntimeWorker.enable() is idempotent. If _instance is already set (master enabled via SSI / preload.py / config import), a later enable() in the worker is a no-op. import ddtrace.bootstrap.sitecustomize after preload is already in sys.modules also ends up calling enable() and does not rebind.
-
RSS collector freezes PID at enable time (psutil.Process(os.getpid()) in the metric collector). If enable runs in the master before fork(), workers inherit the singleton, the same runtime-id, and a collector still pointed at the master PID.
Typical SSI + gunicorn + ddtrace 4.x path:
- Master: SSI / preload →
RuntimeWorker.enable() before fork.
- Gunicorn forks workers; children inherit
_instance + master-bound collector.
- Worker
post_worker_init that only calls enable() again or import sitecustomize does not fix it.
- DogStatsD reports one
runtime-id with master-sized RSS.
Workaround that works
In every gunicorn post_worker_init:
from ddtrace.internal.runtime.runtime_metrics import RuntimeWorker
RuntimeWorker.disable()
RuntimeWorker.enable()
That issues a new runtime-id and rebinds psutil to the worker PID. After rebind you typically get 1 + N series (master + N workers) if the master also enabled pre-fork — that is expected and correct for summing process RSS.
Controlled Docker matrix (ddtrace 4.12.2, workers=4, ballast=64 MiB)
Full repro: https://github.com/asaf400/repro-ddtrace-gunicorn-rss
| Mode |
Master enable? |
Worker post_worker_init |
n runtime-ids |
dd_sum MiB |
Σ VmRSS MiB |
dd_sum / Σ VmRSS |
master_only |
yes |
none |
1 |
47.8 |
470.9 |
0.10 |
sitecustomize_post |
yes |
import sitecustomize only |
1 |
47.3 |
510.6 |
0.09 |
rebind_post |
yes |
disable(); enable() |
5 |
471.2 |
471.2 |
1.00 |
Correctness check is dd_sum ≈ Σ /proc VmRSS. Cgroup anon can be lower than Σ VmRSS after fork because of CoW shared pages — that gap is expected and is not a ddtrace bug.
Docs gap (Issue A)
The Runtime Metrics Python page documents enabling via env / ddtrace-run / auto instrumentation, but does not mention:
- gunicorn / prefork / uWSGI-style process models
- that enabling
RuntimeWorker in the master before fork makes worker RSS report the master
- that post-fork
import ddtrace.bootstrap.sitecustomize / enable() alone is insufficient after a successful master enable
- that
RuntimeWorker.disable(); enable() (or an official post-fork hook) is required for correct per-worker RSS
- how
runtime.python.mem.rss should be aggregated vs container.memory.rss (sum by runtime-id, not a single avg)
Issue B — RuntimeWorker.periodic() drops Kubernetes entity ID tags
Symptom
runtime.python.* appears in Metric Summary.
- Dashboards / explorers filtered by
pod_name or kube_namespace show nothing useful.
- Agent origin detection is already on (
DD_DOGSTATSD_ORIGIN_DETECTION=true, client mode).
- Pods already have
DD_AGENT_HOST and DD_ENTITY_ID=<pod-uid>.
Root cause
RuntimeWorker.periodic() rebuilds the DogStatsD client’s constant_tags and drops the entity-ID tag derived from DD_ENTITY_ID. Packets leave the pod without dd.internal.entity_id, so the Agent cannot attach Kubernetes origin tags.
Observed on both 3.19.x and 4.7.x / 4.12.x.
Controlled check from a pod: the same custom DogStatsD metric with dd.internal.entity_id:<pod-uid> gets pod_name + kube_namespace; without it, no origin tags. Agent-side config alone cannot fix client packets that never carry an entity ID. Cgroup v2 container-ID fallback also does not rescue this path when entity-ID handling is broken / overwritten.
Workaround
Re-assert the entity ID via DD_TAGS, which ddtrace re-applies after rewriting constant_tags:
DD_TAGS=dd.internal.entity_id:$(DD_ENTITY_ID)
(Kubernetes expands $(DD_ENTITY_ID) from the downward-API sibling env var.)
Docs gap (Issue B)
Docs describe DD_ENTITY_ID / origin detection, but do not warn that the Python runtime-metrics flusher can strip the entity tag, or that DD_TAGS=dd.internal.entity_id:… is a necessary workaround for runtime.python.* on Kubernetes.
Related documentation / product gaps (not separate library defects, but missing from docs)
These made the above much harder to diagnose:
-
patch_all() does not start RuntimeWorker. Runtime metrics are started from ddtrace/bootstrap/preload.py only (ddtrace-run, import ddtrace.auto, import ddtrace.bootstrap.sitecustomize, or successful SSI). Apps that only call ddtrace.patch_all(...) never enable runtime metrics even with DD_RUNTIME_METRICS_ENABLED=true. Docs discuss the env flag in the context of auto/ddtrace-run, but do not state clearly that patch_all alone never enables the product (and patch_all is deprecated in favour of import ddtrace.auto).
-
SSI major mismatch (3.x app + 4.x inject). Admission injects tracer major 4 on PYTHONPATH. If the app pins ddtrace 3.x, auto-bootstrap fails (e.g. ModuleNotFoundError: ddtrace.internal.settings) and the process continues on 3.x without preload — so no runtime metrics unless the app imports sitecustomize itself. This interaction is not called out next to Runtime Metrics + SSI docs.
Expected behavior
- After gunicorn fork, each worker should emit its own
runtime-id with RSS for that PID (or docs / SSI should provide an official post-fork rebind).
- Runtime-metrics flushes should preserve
dd.internal.entity_id derived from DD_ENTITY_ID so Agent origin detection can attach pod_name / kube_namespace.
- Public docs should document gunicorn/prefork caveats, the
patch_all vs preload distinction, and the entity-ID / DD_TAGS requirement for Kubernetes.
Actual behavior
- Pre-fork enable → workers inherit master collector → ~10% of real Σ process RSS in our matrix until
disable(); enable().
- Periodic flush can strip entity ID → metrics exist but are unusable for pod-scoped queries without
DD_TAGS workaround.
- Docs are silent on both.
Reproduction Code
Public repro (Docker Compose matrix): https://github.com/asaf400/repro-ddtrace-gunicorn-rss
git clone https://github.com/asaf400/repro-ddtrace-gunicorn-rss.git
cd repro-ddtrace-gunicorn-rss
DD_REPRO_MODE=master_only COMPARE_WAIT_S=45 docker compose run --rm --build repro
DD_REPRO_MODE=sitecustomize_post COMPARE_WAIT_S=45 docker compose run --rm repro
DD_REPRO_MODE=rebind_post COMPARE_WAIT_S=45 docker compose run --rm repro
Modes:
DD_REPRO_MODE |
Behavior |
master_only |
RuntimeWorker.enable() in master (config import) only — simulates SSI pre-fork |
sitecustomize_post |
Same + import ddtrace.bootstrap.sitecustomize in post_worker_init |
rebind_post |
Same + RuntimeWorker.disable(); enable() in post_worker_init |
Look at printed RATIO dd_sum/sum_workers. Expect ~0.1 for the first two modes and ~1.0 for rebind_post.
Minimal post-fork fix demonstrated in the repro:
def post_worker_init(_worker):
from ddtrace.internal.runtime.runtime_metrics import RuntimeWorker
RuntimeWorker.disable()
RuntimeWorker.enable()
Error Logs
No hard crash. Behavioral:
- Datadog: single / few
runtime-ids; runtime.python.mem.rss ≈ master VmRSS while workers are large.
- With SSI + app on ddtrace 3.x:
Error in sitecustomize; … ModuleNotFoundError: ddtrace.internal.settings (bootstrap aborts; process continues without RuntimeWorker).
- With entity-ID strip: metrics present in Metric Summary but empty when filtered by
pod_name / kube_namespace.
DD_TRACE_DEBUG=true was not required to observe the DogStatsD RSS undercount; the in-process StatsD sink in the repro captures raw runtime.python.mem.rss lines.
Libraries in Use
Repro image (Dockerfile):
ddtrace==4.12.2
gunicorn==23.0.0
uvicorn==0.34.0
starlette==0.46.2
Also observed in production with ddtrace 3.19.0 / 3.18.1 / 4.7.1 under gunicorn + FastAPI / Uvicorn workers on EKS.
Operating System
Bottlerocket v1.62.1
Tracer Version(s)
3.19.0, 4.7.1, 4.12.2
Python Version(s)
Python 3.11.x, Python 3.13
Pip Version(s)
pip from python:3.13-slim-bookworm
Bug Report
Summary
When using Python runtime metrics (
DD_RUNTIME_METRICS_ENABLED=true) with gunicorn (and similarly with Kubernetes single-step instrumentation / SSI, which enables products in the master before fork),runtime.python.mem.rssis often wrong by an order of magnitude: Datadog shows roughly master-sized RSS and usually oneruntime-id, while worker processes each hold hundreds of MiB and the container cgroup is ~N× larger.Separately, even when metrics emit,
RuntimeWorker.periodic()can overwrite DogStatsDconstant_tagsand dropdd.internal.entity_id, so Kubernetes origin tags (pod_name,kube_namespace) never attach — despiteDD_ENTITY_IDbeing set correctly.Neither behavior is documented in the public Runtime Metrics (Python) docs (tracing/metrics/runtime_metrics/?tab=python) or the Kubernetes origin-detection docs. Operators comparing
runtime.python.mem.rsstocontainer.memory.rsswill see a large “leak” that is actually a client fork / tagging bug.Environment
DD_RUNTIME_METRICS_ENABLED=true,DD_RUNTIME_METRICS_RUNTIME_ID_ENABLED=trueDD_ENTITY_IDfrom the downward API (pod UID);DD_AGENT_HOST= node AgentIssue A — Gunicorn / pre-fork: RSS bound to master PID (primary)
Symptom
runtime-idper pod.runtime.python.mem.rss≈ master VmRSS (tens of MiB)./proc/<pid>/statusVmRSS is hundreds of MiB; cgroup RSS ≈ N× worker.avg:runtime.python.mem.rssvscontainer.memory.rsslooks like a multi‑GB discrepancy — they are not comparable until per-worker series exist.Root cause (library behavior)
Two behaviors compound after fork:
RuntimeWorker.enable()is idempotent. If_instanceis already set (master enabled via SSI /preload.py/ config import), a laterenable()in the worker is a no-op.import ddtrace.bootstrap.sitecustomizeafter preload is already insys.modulesalso ends up callingenable()and does not rebind.RSS collector freezes PID at enable time (
psutil.Process(os.getpid())in the metric collector). If enable runs in the master beforefork(), workers inherit the singleton, the sameruntime-id, and a collector still pointed at the master PID.Typical SSI + gunicorn + ddtrace 4.x path:
RuntimeWorker.enable()before fork._instance+ master-bound collector.post_worker_initthat only callsenable()again orimport sitecustomizedoes not fix it.runtime-idwith master-sized RSS.Workaround that works
In every gunicorn
post_worker_init:That issues a new
runtime-idand rebindspsutilto the worker PID. After rebind you typically get 1 + N series (master + N workers) if the master also enabled pre-fork — that is expected and correct for summing process RSS.Controlled Docker matrix (ddtrace 4.12.2, workers=4, ballast=64 MiB)
Full repro: https://github.com/asaf400/repro-ddtrace-gunicorn-rss
post_worker_initmaster_onlysitecustomize_postimport sitecustomizeonlyrebind_postdisable(); enable()Correctness check is dd_sum ≈ Σ
/procVmRSS. Cgroup anon can be lower than Σ VmRSS after fork because of CoW shared pages — that gap is expected and is not a ddtrace bug.Docs gap (Issue A)
The Runtime Metrics Python page documents enabling via env /
ddtrace-run/ auto instrumentation, but does not mention:RuntimeWorkerin the master before fork makes worker RSS report the masterimport ddtrace.bootstrap.sitecustomize/enable()alone is insufficient after a successful master enableRuntimeWorker.disable(); enable()(or an official post-fork hook) is required for correct per-worker RSSruntime.python.mem.rssshould be aggregated vscontainer.memory.rss(sum byruntime-id, not a singleavg)Issue B —
RuntimeWorker.periodic()drops Kubernetes entity ID tagsSymptom
runtime.python.*appears in Metric Summary.pod_nameorkube_namespaceshow nothing useful.DD_DOGSTATSD_ORIGIN_DETECTION=true, client mode).DD_AGENT_HOSTandDD_ENTITY_ID=<pod-uid>.Root cause
RuntimeWorker.periodic()rebuilds the DogStatsD client’sconstant_tagsand drops the entity-ID tag derived fromDD_ENTITY_ID. Packets leave the pod withoutdd.internal.entity_id, so the Agent cannot attach Kubernetes origin tags.Observed on both 3.19.x and 4.7.x / 4.12.x.
Controlled check from a pod: the same custom DogStatsD metric with
dd.internal.entity_id:<pod-uid>getspod_name+kube_namespace; without it, no origin tags. Agent-side config alone cannot fix client packets that never carry an entity ID. Cgroup v2 container-ID fallback also does not rescue this path when entity-ID handling is broken / overwritten.Workaround
Re-assert the entity ID via
DD_TAGS, which ddtrace re-applies after rewritingconstant_tags:(Kubernetes expands
$(DD_ENTITY_ID)from the downward-API sibling env var.)Docs gap (Issue B)
Docs describe
DD_ENTITY_ID/ origin detection, but do not warn that the Python runtime-metrics flusher can strip the entity tag, or thatDD_TAGS=dd.internal.entity_id:…is a necessary workaround forruntime.python.*on Kubernetes.Related documentation / product gaps (not separate library defects, but missing from docs)
These made the above much harder to diagnose:
patch_all()does not startRuntimeWorker. Runtime metrics are started fromddtrace/bootstrap/preload.pyonly (ddtrace-run,import ddtrace.auto,import ddtrace.bootstrap.sitecustomize, or successful SSI). Apps that only callddtrace.patch_all(...)never enable runtime metrics even withDD_RUNTIME_METRICS_ENABLED=true. Docs discuss the env flag in the context of auto/ddtrace-run, but do not state clearly thatpatch_allalone never enables the product (andpatch_allis deprecated in favour ofimport ddtrace.auto).SSI major mismatch (3.x app + 4.x inject). Admission injects tracer major 4 on
PYTHONPATH. If the app pins ddtrace 3.x, auto-bootstrap fails (e.g.ModuleNotFoundError: ddtrace.internal.settings) and the process continues on 3.x without preload — so no runtime metrics unless the app imports sitecustomize itself. This interaction is not called out next to Runtime Metrics + SSI docs.Expected behavior
runtime-idwith RSS for that PID (or docs / SSI should provide an official post-fork rebind).dd.internal.entity_idderived fromDD_ENTITY_IDso Agent origin detection can attachpod_name/kube_namespace.patch_allvs preload distinction, and the entity-ID /DD_TAGSrequirement for Kubernetes.Actual behavior
disable(); enable().DD_TAGSworkaround.Reproduction Code
Public repro (Docker Compose matrix): https://github.com/asaf400/repro-ddtrace-gunicorn-rss
git clone https://github.com/asaf400/repro-ddtrace-gunicorn-rss.git cd repro-ddtrace-gunicorn-rss DD_REPRO_MODE=master_only COMPARE_WAIT_S=45 docker compose run --rm --build repro DD_REPRO_MODE=sitecustomize_post COMPARE_WAIT_S=45 docker compose run --rm repro DD_REPRO_MODE=rebind_post COMPARE_WAIT_S=45 docker compose run --rm reproModes:
DD_REPRO_MODEmaster_onlyRuntimeWorker.enable()in master (config import) only — simulates SSI pre-forksitecustomize_postimport ddtrace.bootstrap.sitecustomizeinpost_worker_initrebind_postRuntimeWorker.disable(); enable()inpost_worker_initLook at printed
RATIO dd_sum/sum_workers. Expect ~0.1 for the first two modes and ~1.0 forrebind_post.Minimal post-fork fix demonstrated in the repro:
Error Logs
No hard crash. Behavioral:
runtime-ids;runtime.python.mem.rss≈ master VmRSS while workers are large.Error in sitecustomize; … ModuleNotFoundError: ddtrace.internal.settings(bootstrap aborts; process continues withoutRuntimeWorker).pod_name/kube_namespace.DD_TRACE_DEBUG=truewas not required to observe the DogStatsD RSS undercount; the in-process StatsD sink in the repro captures rawruntime.python.mem.rsslines.Libraries in Use
Repro image (
Dockerfile):Also observed in production with
ddtrace3.19.0 / 3.18.1 / 4.7.1 under gunicorn + FastAPI / Uvicorn workers on EKS.Operating System
Bottlerocket v1.62.1