Skip to content

perf(profiling): reuse exporter across upload cycles - #19330

Closed
r1viollet wants to merge 5 commits into
mainfrom
r1viollet/reuse-profiler-exporter
Closed

perf(profiling): reuse exporter across upload cycles#19330
r1viollet wants to merge 5 commits into
mainfrom
r1viollet/reuse-profiler-exporter

Conversation

@r1viollet

Copy link
Copy Markdown
Contributor

Description

Supersedes #17584 (auto-closed by the stale-PR bot after ~1 month of inactivity while awaiting staging validation). Same 5 commits, rebased on current main. No content changes since the last review.

The profiler was creating a fresh ddog_prof_ProfileExporter on every upload cycle (~60s) and dropping it immediately after. Each creation allocates a tokio runtime, TLS connector and HTTP client on the Rust side (~10-15 native allocations per cycle) and churns memory. This PR caches the exporter on ProfilerState, reuses it across cycles, and drops it in prefork() on the parent side (while tokio threads are still alive) and in cleanup().

The exporter is a natural fit for reuse: endpoint (URL + timeout) and static identity tags (env, service, version, runtime, runtime_id, runtime_version, profiler_version, process_id, language) do not change during a process's lifetime. Per-upload user tags can change (manual Profiler() usage — e.g. Delancie Workers setting a per-task tag), so they are not baked into the exporter; they ride each send via optional_additional_tags instead.

Changes

  1. perf(profiling): reuse profile exporter across upload cycles — caches ddog_prof_ProfileExporter on ProfilerState; drops in prefork() (parent side, under upload_lock) and cleanup(). Uploader no longer owns the exporter; reaches it via ProfilerState::get().exporter.
  2. refactor(profiling): pass user tags per-send, drop "cached" qualifier — addresses Thomas's user-tag-mutation concern; renames cached_exporterexporter throughout.
  3. test(profiling): verify user tags differ per upload + restore family/language distinction — adds tag_rotation_program.py driver and test_per_upload_tags.py mock-agent test (three cycles, three phases; asserts each cycle carries its own tag AND no stale phase leaks into later cycles). Also restores g_family_name alongside g_language_name as distinct constants (they coincide here but are distinct concepts — eBPF profiler can be family=native sampling python).
  4. test(profiling): drop nonexistent stop_on_exit kwarg in tag rotation driverProfiler.start() takes no args in current main.
  5. chore(profiling): flag UploaderBuilder->ProfilerState hidden dep for follow-upAIDEV-NOTE at UploaderBuilder::build() documenting that the singleton reach-in is a pre-existing pattern (all 13 set_* methods do it) and pointing at a follow-up refactor (decouple via explicit config struct).

Testing

Risks

  • Fork handling: exporter is now dropped in prefork() on the parent side under upload_lock. Both parent and child re-create it lazily on the next upload via UploaderBuilder::build → ensure_exporter. This is required because the tokio worker threads owned by the Rust runtime do not survive fork; dropping post-fork in the child could deadlock or touch dead-thread state.
  • User tags: previously baked into the exporter at build time, now shipped per-send via optional_additional_tags. Manual Profiler() users mutating ddup.config(tags=…) between cycles will now see their new tags reflected on the next upload (was undefined/broken before). Verified by test_per_upload_tags.py.

Follow-up

  • AIDEV-NOTE at uploader_builder.cpp::UploaderBuilder::build() flags the pre-existing ProfilerState::get() reach-in pattern. Follow-up ticket (to be filed): thread an explicit config through the UploaderBuilder API to decouple it from the ProfilerState singleton.

r1viollet and others added 5 commits July 28, 2026 09:30
The profiler builds a new ddog_prof_ProfileExporter on every upload
(~60s). Each construction allocates a tokio runtime, TLS connector and
HTTP client on the Rust side — about 10–15 native allocations per cycle
that the allocator never gets to coalesce.

Cache the exporter in ProfilerState and reuse it across uploads,
matching ddprof's pattern. Lifecycle:

- Lazily created on first build, under upload_lock (held by ddup_upload).
- Dropped in prefork() while still in the parent (under upload_lock,
  with no upload in flight) — dropping it post-fork in the child is
  unsafe because the tokio worker threads do not survive fork.
- Dropped in cleanup() at exit.
- Recreated lazily on the next upload in both parent and child.

Uploader no longer owns the exporter; it reaches the cached one through
ProfilerState in upload_unlocked() instead. This keeps Uploader's move
semantics intact and confines the exporter's lifecycle to ProfilerState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review feedback:

- User-defined tags (DD_TAGS, dd_trace_api.profiling.tag, the per-task tag
  pattern used by manual Profiler usage) can change between uploads. Baking
  them into a long-lived exporter risks sending stale tags after such a
  change. Move them out of the exporter and pass them per-send via
  optional_additional_tags, which libdatadog exposes for exactly this case.
  The exporter still bakes the static identity tags (env, service, version,
  language, runtime, runtime_id, runtime_version, profiler_version,
  process_id) and the endpoint (URL, timeout). runtime_id and process_id
  remain correct after fork because we drop the exporter in prefork.

- Rename ProfilerState::cached_exporter to ProfilerState::exporter; drop
  "cached" from associated comments and the local in upload_unlocked.

- Lift the using-directive into the anonymous namespace in
  uploader_builder.cpp to remove the Datadog:: prefix noise.

- Comment polish: no parens on function references (prefork, cleanup),
  expand the upload_unlocked comment to mention upload_lock also
  serializing parallel uploads, drop the trailing
  "intentionally NOT dropped" comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…language distinction

- Introduce g_family_name constant and use it for the exporter's family slot,
  separate from g_language_name. They coincide for this profiler, but the
  concepts are different (an eBPF profiler can run in family `native` while
  sampling `python` code).
- Replace the literal "dd-trace-py" string with the existing g_library_name
  constant for the library-name slot.
- New tag_rotation_program.py driver: starts a Profiler, rotates a per-upload
  tag (phase=setup/warmup/production), forces an upload each cycle.
- New test_per_upload_tags.py: runs the driver under an in-process
  ThreadingHTTPServer mock, captures the multipart bodies, asserts each
  cycle's body contains the expected `phase:<value>` tag, AND asserts the
  previous cycle's tag does NOT appear in a later cycle's body. The negative
  assertion is the regression guard — if user tags were baked back into the
  cached exporter, the first cycle's tag would leak into all subsequent
  cycles' bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…driver

Profiler.start() takes no arguments in current main; the kwarg was a
leftover from earlier iterations. Verified end-to-end against a real
trace-agent on linux and via the mock-agent test_per_upload_tags.py
(3 cycles, each with its own phase:<value> tag, no leakage).
…follow-up

Reviewer flagged that UploaderBuilder::build() reaches into the
ProfilerState singleton instead of taking what it needs as an argument.
That pattern predates this PR (every set_* method + build_user_tag_vec
already does it), and untangling it cleanly means threading an explicit
config struct through the whole UploaderBuilder API surface. That is its
own refactor; punting via an AIDEV-NOTE so the follow-up has a clear
anchor.
@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

⚠️ Warnings

🚦 10 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v113741357-d2b8243-manylinux2014_aarch64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v126532182-233089d-musllinux_1_2_aarch64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux: [arm64, cp315-cp315, v126532182-233089d-musllinux_1_2_aarch64]   View in Datadog   GitLab

View all 10 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 0b3576e | Docs | Datadog PR Page | Give us feedback!

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.debug -> ddtrace.trace
ddtrace -> ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.debug -> ddtrace
ddtrace -> ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.debug -> ddtrace.internal.runtime.runtime_metrics -> ddtrace

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Codeowners resolved as

ddtrace/internal/datadog/profiling/dd_wrapper/src/uploader_builder.cpp  @DataDog/profiling-python

@r1viollet

Copy link
Copy Markdown
Contributor Author

I did not find time to test this, however I want to keep it in the back of my mind.

@r1viollet

Copy link
Copy Markdown
Contributor Author

reopening previous PR

@r1viollet r1viollet closed this Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant