Skip to content

feat(profiling): expose profiler settings on each uploaded profile#18057

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 10 commits into
mainfrom
taegyunkim/prof-14617-profile-settings-metadata
May 15, 2026
Merged

feat(profiling): expose profiler settings on each uploaded profile#18057
gh-worker-dd-mergequeue-cf854d[bot] merged 10 commits into
mainfrom
taegyunkim/prof-14617-profile-settings-metadata

Conversation

@taegyunkim
Copy link
Copy Markdown
Contributor

@taegyunkim taegyunkim commented May 12, 2026

Description

Publishes a snapshot of the effective profiler configuration on each uploaded profile via the upload event's user-visible info channel (ddog_prof_Exporter_send_blocking's optional_info_json argument). Settings are nested under the info.profiler.settings header, matching dd-trace-go and dd-trace-php conventions. Each setting is keyed by its dotted config path relative to ProfilingConfig (e.g. stack.enabled, upload_interval).

Per the Confluence guidance, the internal channel is auto-indexed but not user-visible, while info is both indexed and visible. The profiling configuration knobs are already public (DDConfig entries shipped in the library source), and we already export profiler_config tag, which has some of those information. Adding more to profiler_config doesn't help with searching such bits of configurations. So we simply export them as info field.

Example shape of the uploaded info JSON:

{
  "profiler": {
    "settings": {
      "enabled": true,
      "upload_interval": 60.0,
      "stack.enabled": true,
      "stack.adaptive_sampling": true,
      "stack.adaptive_sampling_target_overhead": 1.0,
      "stack.adaptive_sampling_max_interval": 1000000,
      "stack.fast_copy": false,
      "stack.max_threads": 25,
      "exception.enabled": false,
      "exception.sampling_interval": 100,
      "lock.enabled": true,
      "memory.enabled": true,
      "heap.enabled": true,
      "...": "..."
    }
  }
}

Today, report_configuration() is the only path that surfaces profiler config externally, but it skips every entry declared with private=True. That excludes precisely the knobs that matter for interpreting a profile (stack.adaptive_sampling, stack.adaptive_sampling_target_overhead, stack.adaptive_sampling_max_interval, stack.fast_copy, stack.max_threads, etc.). Additionally, telemetry is a process-level aggregate, not per-profile.

Changes:

  • ddtrace/internal/settings/_core.py — adds DDConfig.dump_settings() (a method on the config) that walks the tree including private=True items and emits a JSON-safe {dotted_name: value} dict with keys relative to the config root (no __prefix__ baked in). Wrapping is a channel concern, handled at the call site.
  • dd_wrapper/include/profiler_state.hpp — adds a process-singleton profiler_settings_info_json string. Must live on ProfilerState rather than ProfilerStats because UploaderBuilder::build() does a std::swap of ProfilerStats on every upload, which would wipe the snapshot.
  • dd_wrapper/src/ddup_interface.cppddup_set_profiler_settings_json stores the JSON object verbatim on ProfilerState. Empty objects and inputs without matching braces are dropped silently.
  • dd_wrapper/src/uploader.cpp — builds a ddog_CharSlice from ProfilerState::profiler_settings_info_json and passes it as optional_info_json to ddog_prof_Exporter_send_blocking. Also writes a sibling *.info.json next to *.internal_metadata.json in export_to_file, used when output_filename is set for tests.
  • dd_wrapper/include/ddup_interface.hpp, ddtrace/internal/datadog/profiling/ddup/_ddup.pyx, _ddup.pyi — Cython binding set_profiler_settings_json(settings_json).
  • ddtrace/profiling/profiler.py — wraps the settings dict as {"profiler": {"settings": ...}} and calls the binding once after ddup.start() with json.dumps(...).

JIRA: PROF-14617

Cross-language references:

Testing

  • New unit tests in tests/profiling/test_profiling_config.py::TestDumpSettings: verify private keys are included, keys are bare dotted paths (no DD_, _DD_, or dd.profiling. prefix), all values are JSON-serializable, and env-var overrides are reflected.
  • End-to-end smoke test (manual): three back-to-back ddup.upload() calls in the same process. All three resulting *.info.json files carry info.profiler.settings with 35 entries and the expected env-override values applied (stack.adaptive_sampling = false after _DD_PROFILING_STACK_ADAPTIVE_SAMPLING_ENABLED=0). The *.internal_metadata.json files are unaffected, with only their original stats fields. Payload size went from ~1596 to ~1140 bytes per profile after dropping the redundant dd.profiling. prefix.
  • ruff format and ruff check clean on modified Python files.

Risks

  • Low. The settings ride on a separate info channel that was previously nullptr; no existing field of internal_metadata.json changes. If dump_settings or json.dumps raises, the call site swallows the exception and logs at debug level, so a serialization bug can't take down profiling startup.
  • Telemetry behavior is unchanged (still skips private=True).
  • The C++ side trusts json.dumps to produce a valid compact JSON object; it only does a basic front() == '{' && back() == '}' sanity check before storing. If a future caller passes a non-object payload, the field is cleared and optional_info_json is set to nullptr (the previous behavior).

Additional Notes

  • Settings names follow the dotted Python config path relative to ProfilingConfig (stack.adaptive_sampling, upload_interval, ...). The info.profiler.settings wrapping already carries the scope, so the dd.profiling. prefix is dropped to save payload.
  • The snapshot is one-shot at startup, mirroring dd-trace-go. Runtime-mutable values like the effective sampling interval are still exposed via the existing sampling_interval_us field in internal_metadata.json.
  • Storage on ProfilerState (not ProfilerStats) is deliberate: ProfilerStats is std::swap-ped on every upload (UploaderBuilder::build()), so a snapshot stored there would survive only one profile.

See this screenshot for how it shows up on the UI

image
PROMPTS.md
> Do we log anything or upload instrumentation telemetry whether adaptive sampling for profiling is turned off or not?

> How are the sampling interval related things are reported?

> I was looking at profile pages from other profiles, for example go, and it seems to output things like
>
> Profiler
> activation: manual
> settings.agentless: false
> ...
> ssi.mechanism: none
>
> which would have been built from some code in ~/dd/dd-trace-go/profiler/upload.go,
> especially the lines related to profilerInfo struct and relevant code
>
> I believe it would be also nice to move some of our config vars, especially those
> in ddtrace/internal/settings/profiling.py and some of those internal settings
> related to stack v2, what do you think?

> That sounds reasonable, write plan.md and go ahead to implement this

> So this is basically dumping everything at once, i.e. everything is under a single
> 'profiler_settings' key, then it wouldn't be easy to filter profiles using a single
> configuration knob. Each profiling config item should be a single key, for example
> dd.profiling.stack.enabled: true, dd.profiling.stack.adaptive_sampling_enabled: true,
> dd.profiling.lock.enabled: true, ... like these

> In this approach, we unescape json everytime upload happens, which is unnecessary.
> We should store the unescaped json key value pair strings, and just pass that when
> uploading.

> It would be more appropriate to update ddtrace/internal/settings/_core.py instead of
> ddtrace/internal/telemetry/__init__.py to define dump_settings

> According to https://datadoghq.atlassian.net/wiki/spaces/PROF/pages/4004775041/Add+extra+attributes+data+info+to+profiles
> internal field in the event.json are also auto-indexed and searchable but not visible
> to the users. Given that the configurations knobs are already public, part of the code,
> let's just export via the info field.

> Looking at dd-trace-php uploader.rs and dd-trace-go upload.go, it looks customary to
> have these headers (info.profiler), so for python, we'd also use profiler header, and
> could also trim dd.profiling. prefixes given that they're all common, and save a
> little bit of payload
PLAN.md
# PROF-14617 — Expose profiler settings on each profile

JIRA: https://datadoghq.atlassian.net/browse/PROF-14617
Branch: taegyunkim/prof-14617-profile-settings-metadata

## Approach (final)

1. Python: DDConfig.dump_settings() walks the tree (including private items)
   and emits a flat {dotted_name: value} dict. Keys are relative to the
   config root (no __prefix__ baked in). Lives on DDConfig in
   ddtrace/internal/settings/_core.py.
2. C++: ProfilerState owns a profiler_settings_info_json string (full JSON
   object). The setter stores the value verbatim after a basic shape check.
3. Uploader builds a ddog_CharSlice from that string and passes it as
   optional_info_json to ddog_prof_Exporter_send_blocking. The `info`
   channel is user-visible and auto-indexed on the backend.
4. Storage on ProfilerState (not ProfilerStats) because ProfilerStats is
   std::swap-ped on every upload in UploaderBuilder::build().
5. Binding: ddup_set_profiler_settings_json (interface + Cython).
6. Startup: profiler.py wraps the dict as {"profiler": {"settings": ...}}
   and calls the binding once after ddup.start() with json.dumps(...).
   Matches dd-trace-go and dd-trace-php conventions.
7. Tests + release note (category: other).

## Out of scope

- Reporting settings for the tracer (only profiler today).
- Refreshing settings mid-process.
- Changing telemetry's behavior. report_configuration() keeps skipping
  private items as before.

Embed a snapshot of the effective profiler configuration in the per-profile
internal metadata under the new `profiler_settings` key. This is the same
channel that already carries `sampling_interval_us`, `sample_count`, etc.,
and brings parity with dd-trace-go's `info.profiler.settings`, so the backend
can surface settings (including private knobs like adaptive sampling
parameters and stack v2 internals) on the profile page.

* `dump_settings(config)` walks a DDConfig tree including `private=True`
  entries and emits a JSON-safe `{dotted_name: value}` dict. It sits next
  to `report_configuration()` so telemetry behavior is unchanged.
* `ProfilerStats::set_profiler_settings_json(...)` stores the JSON blob and
  embeds it verbatim in `get_internal_metadata_json()`.
* New `ddup.set_profiler_settings_json()` Cython binding forwards into the
  C++ side. The profiler startup path calls it once with the serialized
  `ProfilingConfig`.

JIRA: PROF-14617

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cit-pr-commenter-54b7da
Copy link
Copy Markdown

cit-pr-commenter-54b7da Bot commented May 12, 2026

Codeowners resolved as

ddtrace/internal/datadog/profiling/dd_wrapper/include/ddup_interface.hpp  @DataDog/profiling-python
ddtrace/internal/datadog/profiling/dd_wrapper/include/profiler_state.hpp  @DataDog/profiling-python
ddtrace/internal/datadog/profiling/dd_wrapper/src/ddup_interface.cpp    @DataDog/profiling-python
ddtrace/internal/datadog/profiling/dd_wrapper/src/uploader.cpp          @DataDog/profiling-python
ddtrace/internal/datadog/profiling/ddup/_ddup.pyi                       @DataDog/profiling-python
ddtrace/internal/datadog/profiling/ddup/_ddup.pyx                       @DataDog/profiling-python
ddtrace/internal/settings/_core.py                                      @DataDog/apm-core-python
ddtrace/profiling/profiler.py                                           @DataDog/profiling-python
releasenotes/notes/profiling-per-profile-settings-metadata-b5aeab16b998e71f.yaml  @DataDog/apm-python
tests/profiling/test_profiling_config.py                                @DataDog/profiling-python

taegyunkim and others added 7 commits May 12, 2026 15:46
…keys

Embed each profiler setting as its own top-level key in internal_metadata
(e.g. `dd.profiling.stack.enabled`, `dd.profiling.upload_interval`)
instead of nesting them under a single `profiler_settings` object. With
the flat layout the backend can index and filter profiles on any
individual setting rather than treating the whole bag as one opaque blob.

* `dump_settings()` now prefixes each key with the config's
  `__prefix__` (e.g. `dd.profiling`) so callers get fully qualified names.
* `ProfilerStats::get_internal_metadata_json()` strips the outer braces
  of the stored settings JSON and splices the entries inline. Compact JSON
  produced by `json.dumps` is required and trusted.

JIRA: PROF-14617

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

Two cleanups that emerged after sanity-checking the previous commit:

1. Store the pre-formatted settings entries on ProfilerState (process
   singleton) instead of ProfilerStats. ProfilerStats is wholesale
   `std::swap`-ped on every upload in `UploaderBuilder::build()`, which
   wiped the snapshot after the first profile. Settings are static config
   that must outlive any single profile, so ProfilerState is the right
   owner. `ddup_set_profiler_settings_json` now strips the outer braces
   once and stores the inner entries directly; `get_internal_metadata_json`
   reads them back at serialization time without any per-upload string work.

2. Move the JSON-safe DDConfig dumper onto the config itself
   (`DDConfig.dump_settings()`) instead of standing it next to
   `report_configuration` in the telemetry package. It is a property of
   the config tree, not of telemetry, and lives where every other DDConfig
   helper lives.

Verified end-to-end with three back-to-back uploads in the same process,
all three carry the 35 `dd.profiling.*` keys (previously only the first
upload did).

JIRA: PROF-14617

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

The previous revision spliced the profiler settings into the upload event's
`internal` channel (`internal_metadata.json`). Per the Confluence guidance,
the `internal` channel is auto-indexed but not user-visible. Since the
profiling configuration knobs are already public (DDConfig entries shipped
in the library source), there is no reason to hide them. Switch publishing
to the `info` channel via the libdatadog exporter's `optional_info_json`
parameter, which the backend auto-indexes and renders on the profile UI.

Concretely:

* `ddup_set_profiler_settings_json` stores the JSON object verbatim on
  `ProfilerState::profiler_settings_info_json` (no more brace stripping).
* `Uploader::upload_unlocked()` builds a `ddog_CharSlice` from that string
  and passes it as `optional_info_json` to `ddog_prof_Exporter_send_blocking`.
* `get_internal_metadata_json` no longer touches settings, so the existing
  `internal_metadata.json` payload is restored to stats fields only.
* `export_to_file` (used when `output_filename` is set for tests) now also
  writes a sibling `*.info.json` so the local-file path mirrors the agent
  send.
* Release note updated to refer to the `info` field.

Verified locally: across three back-to-back uploads, each `*.info.json`
file carries the full 35 `dd.profiling.*` keys (including env overrides),
while `*.internal_metadata.json` is restored to the original stats only.

JIRA: PROF-14617
Ref: https://datadoghq.atlassian.net/wiki/spaces/PROF/pages/4004775041

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier commits on this branch accidentally dropped the
`sample_capture_cpu_time_us` field and its accessors that were added in
065990d (#17774). This happened because mid-session I synced files
from an isolated jj workspace based on an older `main` snapshot into the
git worktree using `cp -f`, which clobbered the newer profiler_stats
files. The call to `add_sample_capture_cpu_time_us` in
`stack/src/sampler.cpp` was untouched, so a fresh build would fail with
"no member named 'add_sample_capture_cpu_time_us' in ProfilerStats".

Restore the field, accessors, `reset_state()` entry, and the
`sample_capture_cpu_time_us` key in `get_internal_metadata_json()` to
match origin/main exactly. Verified locally: the regenerated
`*.internal_metadata.json` files contain the expected stats keys
(`copy_memory_error_count`, `sample_capture_cpu_time_us`,
`sample_count`, `sampling_event_count`) while the `*.info.json` files
continue to carry the 35 `dd.profiling.*` settings.

JIRA: PROF-14617

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

Match dd-trace-go and dd-trace-php conventions: settings are wrapped under
``info.profiler.settings`` in the upload event, and the per-key
``dd.profiling.`` prefix is dropped since the ``profiler`` header already
conveys the scope. Trims ~450 bytes off each profile's info payload.

Concretely:

* ``DDConfig.dump_settings()`` now returns bare dotted keys (e.g.
  ``stack.adaptive_sampling``, ``upload_interval``) without prepending
  ``__prefix__``. Wrapping is a channel-specific concern and belongs at
  the call site.
* ``profiler.py`` wraps the result as ``{"profiler": {"settings": ...}}``
  before serializing and handing it to ``ddup.set_profiler_settings_json``.
* Tests + release note updated for the new shape.

Refs:
- https://github.com/DataDog/dd-trace-go/blob/937a8e83c8bfc28a9bfef9c2f53641bc57432a8e/profiler/upload.go#L141-L143
- https://github.com/DataDog/dd-trace-php/blob/2a78edc9e1a61c3c568b691f18a028c5e44b9325/profiling/src/profiling/uploader.rs#L90-L97

JIRA: PROF-14617

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User and process tags already ride on the upload event's dedicated tag
channel. Including the profiler config's `tags` dict in
`info.profiler.settings` would duplicate them as `profiling.tags.*`
fields on the profile, adding noise to the UI and bytes to the payload
without contributing new signal.

`ProfilingConfig.tags` is a single dict-typed entry, so a single
`settings.pop("tags", None)` at the call site removes the whole subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@taegyunkim taegyunkim marked this pull request as ready for review May 12, 2026 21:41
@taegyunkim taegyunkim requested review from a team as code owners May 12, 2026 21:41
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0508bb31f3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ddtrace/profiling/profiler.py
Comment thread ddtrace/internal/datadog/profiling/dd_wrapper/include/profiler_state.hpp Outdated
Comment thread ddtrace/internal/datadog/profiling/dd_wrapper/src/uploader.cpp
Comment thread ddtrace/internal/settings/_core.py
Comment thread ddtrace/profiling/profiler.py Outdated
Comment thread ddtrace/profiling/profiler.py Outdated
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@datadog-datadog-prod-us1-2
Copy link
Copy Markdown
Contributor

datadog-datadog-prod-us1-2 Bot commented May 13, 2026

Tests

🎉 All green!

❄️ No new flaky tests detected
🧪 All tests passed

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

Comment thread ddtrace/internal/settings/_core.py
Copy link
Copy Markdown
Contributor

@KowalskiThomas KowalskiThomas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM, can stamp once you confirm the testing in staging bit!

Comment thread ddtrace/internal/settings/_core.py
Comment thread tests/profiling/test_profiling_config.py Outdated
Comment thread ddtrace/profiling/profiler.py
Addresses review feedback: keep one-shot imports out of test bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gh-worker-dd-mergequeue-cf854d gh-worker-dd-mergequeue-cf854d Bot merged commit 01b34da into main May 15, 2026
394 checks passed
@gh-worker-dd-mergequeue-cf854d gh-worker-dd-mergequeue-cf854d Bot deleted the taegyunkim/prof-14617-profile-settings-metadata branch May 15, 2026 15:11
@taegyunkim taegyunkim added the Profiling Continous Profling label May 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Profiling Continous Profling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants