feat(profiling): expose profiler settings on each uploaded profile#18057
Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 10 commits intoMay 15, 2026
Merged
Conversation
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>
Codeowners resolved as |
…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>
There was a problem hiding this comment.
💡 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".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
🎉 All green!❄️ No new flaky tests detected 🔗 Commit SHA: 8ae8640 | Docs | Datadog PR Page | Give us feedback! |
emmettbutler
approved these changes
May 14, 2026
Contributor
KowalskiThomas
left a comment
There was a problem hiding this comment.
Overall LGTM, can stamp once you confirm the testing in staging bit!
Addresses review feedback: keep one-shot imports out of test bodies. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KowalskiThomas
approved these changes
May 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Publishes a snapshot of the effective profiler configuration on each uploaded profile via the upload event's user-visible
infochannel (ddog_prof_Exporter_send_blocking'soptional_info_jsonargument). Settings are nested under theinfo.profiler.settingsheader, matching dd-trace-go and dd-trace-php conventions. Each setting is keyed by its dotted config path relative toProfilingConfig(e.g.stack.enabled,upload_interval).Per the Confluence guidance, the
internalchannel is auto-indexed but not user-visible, whileinfois both indexed and visible. The profiling configuration knobs are already public (DDConfig entries shipped in the library source), and we already exportprofiler_configtag, which has some of those information. Adding more toprofiler_configdoesn't help with searching such bits of configurations. So we simply export them asinfofield.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 withprivate=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— addsDDConfig.dump_settings()(a method on the config) that walks the tree includingprivate=Trueitems 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-singletonprofiler_settings_info_jsonstring. Must live onProfilerStaterather thanProfilerStatsbecauseUploaderBuilder::build()does astd::swapofProfilerStatson every upload, which would wipe the snapshot.dd_wrapper/src/ddup_interface.cpp—ddup_set_profiler_settings_jsonstores the JSON object verbatim onProfilerState. Empty objects and inputs without matching braces are dropped silently.dd_wrapper/src/uploader.cpp— builds addog_CharSlicefromProfilerState::profiler_settings_info_jsonand passes it asoptional_info_jsontoddog_prof_Exporter_send_blocking. Also writes a sibling*.info.jsonnext to*.internal_metadata.jsoninexport_to_file, used whenoutput_filenameis set for tests.dd_wrapper/include/ddup_interface.hpp,ddtrace/internal/datadog/profiling/ddup/_ddup.pyx,_ddup.pyi— Cython bindingset_profiler_settings_json(settings_json).ddtrace/profiling/profiler.py— wraps the settings dict as{"profiler": {"settings": ...}}and calls the binding once afterddup.start()withjson.dumps(...).JIRA: PROF-14617
Cross-language references:
profilerInfo(upload.go:141-143)Testing
tests/profiling/test_profiling_config.py::TestDumpSettings: verify private keys are included, keys are bare dotted paths (noDD_,_DD_, ordd.profiling.prefix), all values are JSON-serializable, and env-var overrides are reflected.ddup.upload()calls in the same process. All three resulting*.info.jsonfiles carryinfo.profiler.settingswith 35 entries and the expected env-override values applied (stack.adaptive_sampling = falseafter_DD_PROFILING_STACK_ADAPTIVE_SAMPLING_ENABLED=0). The*.internal_metadata.jsonfiles are unaffected, with only their original stats fields. Payload size went from ~1596 to ~1140 bytes per profile after dropping the redundantdd.profiling.prefix.ruff formatandruff checkclean on modified Python files.Risks
infochannel that was previouslynullptr; no existing field ofinternal_metadata.jsonchanges. Ifdump_settingsorjson.dumpsraises, the call site swallows the exception and logs at debug level, so a serialization bug can't take down profiling startup.private=True).json.dumpsto produce a valid compact JSON object; it only does a basicfront() == '{' && back() == '}'sanity check before storing. If a future caller passes a non-object payload, the field is cleared andoptional_info_jsonis set tonullptr(the previous behavior).Additional Notes
ProfilingConfig(stack.adaptive_sampling,upload_interval, ...). Theinfo.profiler.settingswrapping already carries the scope, so thedd.profiling.prefix is dropped to save payload.sampling_interval_usfield ininternal_metadata.json.ProfilerState(notProfilerStats) is deliberate:ProfilerStatsisstd::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
PROMPTS.md
PLAN.md