Skip to content

Add DI snapshot payload size pruning (RFC C6) - #6224

Draft
p-datadog wants to merge 3 commits into
masterfrom
di-payload-size-prune
Draft

Add DI snapshot payload size pruning (RFC C6)#6224
p-datadog wants to merge 3 commits into
masterfrom
di-payload-size-prune

Conversation

@p-datadog

@p-datadog p-datadog commented Aug 20, 2026

Copy link
Copy Markdown
Member

Implements backlog item payload-size-prune (DEBUG-6077). RFC area: C6 (per-event payload size cap + pruning/slicing). Design: design/payload-size-prune.md in p-datadog/claude-projects.

What

Caps each serialized DI snapshot at 1 MB and prunes an oversized snapshot (replaces its largest captured values with {"pruned":true}) so a portion of the captured data is still sent, instead of dropping it whole. Drops only when pruning cannot bring the snapshot under the cap.

How

  • New Datadog::DI::SnapshotPruner.prune(snapshot, max_size) — a Ruby Hash walker (not a JSON-string tokenizer): collects the top-level captured value objects ({type:...} under locals/arguments/throwable in the captures subtree), measures each by encoding it, and replaces the largest with {"pruned":true until the overage is reclaimed, then re-encodes once. Variable names are preserved; the structural envelope (service, debugger.snapshot.probe, stack) is never pruned.
  • transport/input.rb#send_input replaces drop-if-too-big with prune-if-too-big; switches the cap check from encoded.length (character count) to encoded.bytesize (the 1 MB network limit is a byte limit; .length undercounted multibyte UTF-8).
  • Each prune increments a dynamic_instrumentation.snapshots_pruned_by_payload_size telemetry counter.

Why a Hash walker, not a JSON-string pruner

The transport holds the snapshot as a Ruby Hash before encoder.encode. Walking the Hash and replacing captured values is idiomatic Ruby and avoids a hand-rolled JSON tokenizer. Node.js and Java prune the encoded string because that is the form they hold; Ruby adapts the same protocol to the form its transport holds (design rule: adapt architecture to the target language, do not copy another tracer's code structure).

Non-goals / follow-ups

  • Byte-bounded queue (RFC C11), queue-pressure → degradation (RFC C12) — separate RFC areas, absent in every tracer.
  • Canonical guardrail telemetry (RFC Appendix B) — RFC-new, absent in every tracer.
  • Per-element pruning of a large collection (keeping some elements) — the whole captured collection variable is pruned as one unit; refinement deferred.
  • Partial-string pruning of a single huge primitive leaf — replaced wholesale with {"pruned":true}, matching Node.js.
  • Configurable cap — the 1 MB cap is a hard constant (RFC "hard safety ceiling"), matching the other tracers and the existing MAX_SERIALIZED_SNAPSHOT_SIZE.

Tests

Local (Ruby 3.3.12): snapshot_pruner (8), transport/input (15), plus the full DI suite (instrumenter, builder, capture_expression_evaluator, circuit_breaker, el/evaluator, serializer, probe) — 389 examples, 0 failures. standard/steep deferred to PR CI.

System test Test_Debugger_Snapshot_Size_Guardrail (DataDog/system-tests#7287) is missing_feature until the Ruby weblog gains the /debugger/snapshot/limits fixture and SnapshotLimits line mapping and the manifest is flipped (separate system-tests work).

Customer documentation

docs/DynamicInstrumentation.md (Data Capture Limits → Snapshot Size) now
states that a snapshot exceeding 1 MB is pruned (largest captured variables
replaced with a {"pruned": true} marker) and delivered partially, and
dropped only when pruning cannot bring it under the limit.

Change log entry

Yes. Dynamic Instrumentation: a snapshot exceeding the 1 MB payload limit is
now pruned and delivered partially instead of being dropped entirely; it is
dropped only when pruning cannot bring it under the limit.

@dd-octo-sts

dd-octo-sts Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

👋 Hey @p-datadog, please fill "Change log entry" section in the pull request description.

If changes need to be present in CHANGELOG.md you can state it this way

**Change log entry**

Yes. A brief summary to be placed into the CHANGELOG.md

(possible answers Yes/Yep/Yeah)

Or you can opt out like that

**Change log entry**

None.

(possible answers No/Nope/None)

Visited at: 2026-08-27 19:38:44 UTC

@dd-octo-sts dd-octo-sts Bot added the debugger Live Debugger (+Dynamic Instrumentation, +Symbol Database) label Aug 20, 2026
@datadog-official

datadog-official Bot commented Aug 20, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 3 Pipeline jobs failed

Check Pull Request CI Status | all-jobs-are-green — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

Linting errors and type-checking failures detected. View full logs for specific issues.

Static Analysis | standard/lint — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

118 offenses detected, 117 offenses autocorrectable in 1957 files inspected; failing due to lint violations.

Static Analysis | steep/typecheck — 🔧 Needs a code fix, caused by this PR

View more details · View in GitHub Actions

Type error in snapshot_pruner.rb:57. Cannot pass a value of type V as an argument of type ::_ToJson.

📋 Copy fix prompt
CI on my pull request is failing. Help me find and fix the root cause of each failing job below — they were flagged as caused by changes in this PR, so focus on the diff. For each job, explain the failure and propose a fix.

Before you start, set up the Datadog software-delivery tooling so you can
query the CI data yourself:

1. Check whether you already have the Datadog software-delivery MCP tools
   (e.g. a `search_datadog_ci_pipeline_events` tool) and the `unblock-pr` skill.
2. If either is missing, STOP and ask me for permission before installing
   anything. Do not install or run anything until I have said yes.
3. Only with my explicit approval, set up the Datadog software-delivery MCP
   server and skills by following:
     https://docs.datadoghq.com/getting_started/software_delivery_mcp_tools/
   then restart so the skill is picked up.
4. If I decline, skip all of the above and work from the context below alone.

Then run /unblock-pr — it will pull the CI data itself. The job context below is what we already know.

If /unblock-pr is not available — because I declined the setup above, or it did not install — work from the context below instead.

Datadog has already classified this failure as caused by changes in this PR.
Take that as given and work the fix:

1. Locate the change. Diff this branch against its base and find the change
   that produces this error. Explain the mechanism, don't just name a file:
     git fetch origin && git diff $(git merge-base origin/master HEAD)...HEAD
2. Reproduce it locally. Run the failing job's command or test before
   proposing anything.
3. Propose the smallest fix that addresses the root cause — not a workaround,
   not a broadened assertion, not a disabled or skipped test.
4. Re-run the same command to confirm, and say exactly what you ran.
5. If the failure turns out to be intermittent rather than deterministic, say
   so plainly instead of "fixing" it — that is a flaky test, and patching it
   hides the problem.

If the right move is to re-run the job rather than change code, use the job
link in the context below. For GitHub Actions: `gh run rerun <run-id> --failed`,
where the run ID is the number after `/runs/` in that URL (not the trailing
number, which is the job ID).

Branch: di-payload-size-prune

Check Pull Request CI Status | all-jobs-are-green
Commit: 5a51e1abfe3a766949041174c3c73696428bb812
Error (code / quality):
Linting errors and type-checking failures detected. View full logs for specific issues.
CI job: https://github.com/DataDog/dd-trace-rb/actions/runs/33109199326/job/98647183358

Static Analysis | standard/lint
Commit: 5a51e1abfe3a766949041174c3c73696428bb812
Error (code / quality):
118 offenses detected, 117 offenses autocorrectable in 1957 files inspected; failing due to lint violations.
CI job: https://github.com/DataDog/dd-trace-rb/actions/runs/33109199294/job/98647293325

Static Analysis | steep/typecheck
Commit: 5a51e1abfe3a766949041174c3c73696428bb812
Error (code / build):
Type error in snapshot_pruner.rb:57. Cannot pass a value of type `V` as an argument of type `::_ToJson`.
CI job: https://github.com/DataDog/dd-trace-rb/actions/runs/33109199294/job/98647293491

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 22.45%
Overall Coverage: 90.27% (+0.02%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 246cbdd | Docs | View more details | Give us feedback!

@pr-commenter

pr-commenter Bot commented Aug 20, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-24 14:34:43

Comparing candidate commit 1db49c8 in PR branch di-payload-size-prune with baseline commit 9616dec in branch master.

📊 Benchmarking dashboard

Found 0 performance improvements and 0 performance regressions! Performance is the same for 48 metrics, 1 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:tracing - Tracing.continue_trace!

  • unstable throughput [-2374.160op/s; +1518.248op/s] or [-6.136%; +3.924%]

Add Datadog::DI::SnapshotPruner.prune, a Hash walker that fits an
oversized serialized snapshot under the 1 MB per-event cap by replacing
the largest top-level captured values (the {type:...} objects under
locals/arguments/throwable in the captures subtree) with
{"pruned":true} markers, preserving variable names and the structural
envelope. Measures each captured value by encoding it (JSON.dump is
deterministic, so a subtree's standalone encoding is byte-identical to
its span in the full dump), sorts by size descending, and replaces the
largest until the overage is reclaimed, then re-encodes once. Returns
nil when no captured values can be pruned or pruning cannot fit.
Replace the drop-if-too-big check in
DI::Transport::Input::Transport#send_input with prune-if-too-big:
an oversized snapshot is pruned (captured values replaced with
{"pruned":true}) and sent when it fits under
MAX_SERIALIZED_SNAPSHOT_SIZE, and dropped only when pruning cannot
bring it under the cap. Switch the cap check from encoded.length
(character count) to encoded.bytesize (the 1 MB network limit is a
byte limit; .length undercounted multibyte UTF-8). Increment a
snapshots_pruned_by_payload_size telemetry counter on each prune.
@p-datadog
p-datadog force-pushed the di-payload-size-prune branch from 51cbbfd to 1db49c8 Compare August 24, 2026 14:07
Oversized snapshots are pruned (largest captured variables replaced with a
pruned marker) and delivered partially, and dropped only when pruning cannot
bring them under the 1 MB limit.
@dd-octo-sts

dd-octo-sts Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Typing analysis

Note: Ignored files are excluded from the next sections.

Untyped methods

This PR introduces 6 partially typed methods, and clears 31 partially typed methods. It increases the percentage of typed methods from 70.06% to 71.16% (+1.1%).

Partially typed methods (+6-31)Introduced:
sig/datadog/open_feature/evaluation_engine.rbs:20
└── def fetch_value: (
        ::String flag_key,
        default_value: untyped,
        expected_type: ::Symbol,
        ?evaluation_context: ::OpenFeature::SDK::EvaluationContext?
      ) -> ResolutionDetails
sig/datadog/open_feature/native_evaluator.rbs:14
└── def get_assignment: (
        ::String flag_key,
        default_value: untyped,
        context: ::OpenFeature::SDK::EvaluationContext::fields_t,
        expected_type: ::Symbol
      ) -> ResolutionDetails
sig/datadog/open_feature/native_evaluator.rbs:25
└── def build_resolution_details: (
        Core::FeatureFlags::ResolutionDetails result,
        untyped default_value
      ) -> ResolutionDetails
sig/datadog/open_feature/provider.rbs:74
└── def sdk_success_details: (
        ResolutionDetails result,
        ::Hash[::String, untyped] flag_meta
      ) -> ::OpenFeature::SDK::Provider::ResolutionDetails
sig/datadog/open_feature/provider.rbs:87
└── def build_flag_metadata: (
        ResolutionDetails result,
        ::Integer eval_time_ms
      ) -> ::Hash[::String, untyped]
sig/datadog/profiling/collectors/thread_context.rbs:27
└── def self.for_testing: (
          recorder: Datadog::Profiling::StackRecorder,
          ?max_frames: ::Integer,
          ?tracer: Datadog::Tracing::Tracer?,
          ?endpoint_collection_enabled: bool,
          ?otel_context_enabled: (::Symbol? | bool),
          ?native_filenames_enabled: bool,
          ?show_classes: bool,
          ?trigger_global_reset: bool,
          **untyped
        ) -> Datadog::Profiling::Collectors::ThreadContext
Cleared:
sig/datadog/open_feature/evaluation_engine.rbs:20
└── def fetch_value: (
        ::String flag_key,
        default_value: untyped,
        expected_type: ::Symbol,
        ?evaluation_context: ::OpenFeature::SDK::EvaluationContext?
      ) -> (ResolutionDetails | Core::FeatureFlags::ResolutionDetails)
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:69
└── def record: (
          flag_key: ::String,
          variant: ::String?,
          allocation_key: ::String?,
          targeting_key: ::String?,
          eval_time_ms: ::Integer,
          attrs: ::Hash[::String, untyped]?,
          ?error_message: ::String?,
          ?runtime_default: bool?
        ) -> void
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:80
└── def flush_and_reset: () -> ::Hash[::Symbol, untyped]
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:82
└── def prune_context: (::Hash[::String, untyped]? attrs) -> ::Hash[::String, untyped]
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:84
└── def self.prune_context: (::Hash[::String, untyped]? attrs) -> ::Hash[::String, untyped]
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:86
└── def self.flatten_context: (::Hash[::String, untyped]? attrs) -> ::Hash[::String, untyped]
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:88
└── def self.flatten_value: (
          ::String prefix,
          untyped value,
          ::Hash[::String, untyped] output,
          ::Hash[::Integer, bool] seen,
          ::Integer depth
        ) -> void
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:96
└── def canonical_context_key: (::Hash[::String, untyped]? attrs) -> ::String
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:100
└── def context_value_bytes: (untyped value) -> ::String
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:104
└── def new_entry: (
          ::Integer evaluation_time_ms,
          runtime_default: bool,
          ?error_message: ::String?,
          ?targeting_key: ::String?,
          ?context_attrs: ::Hash[::String, untyped]?
        ) -> ::Hash[::Symbol, untyped]
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:112
└── def observe: (::Hash[::Symbol, untyped] entry, ::Integer evaluation_time_ms) -> void
sig/datadog/open_feature/flag_evaluation/writer.rbs:65
└── def enqueue: (**untyped event) -> void
sig/datadog/open_feature/flag_evaluation/writer.rbs:75
└── def snapshot_context_value: (
          untyped value,
          ::Hash[::Integer, bool] seen,
          ::Integer depth
        ) -> untyped
sig/datadog/open_feature/flag_evaluation/writer.rbs:103
└── def build_events: (::Hash[::Symbol, untyped] snapshot) -> ::Array[::Hash[::String, untyped]]
sig/datadog/open_feature/flag_evaluation/writer.rbs:105
└── def build_event: (
          flag_key: ::String,
          variant: ::String?,
          allocation_key: ::String?,
          targeting_key: ::String?,
          entry: ::Hash[::Symbol, untyped],
          flush_time_ms: ::Integer,
          tier: ::Symbol
        ) -> ::Hash[::String, untyped]
sig/datadog/open_feature/flag_evaluation/writer.rbs:115
└── def send_payload_batches: (::Array[::Hash[::String, untyped]] events) -> void
sig/datadog/open_feature/flag_evaluation/writer.rbs:117
└── def send_payload_batch: (::Array[::Hash[::String, untyped]] events) -> untyped
sig/datadog/open_feature/flag_evaluation/writer.rbs:119
└── def encoded_event_for_payload: (
          ::Hash[::String, untyped] event,
          ::Integer base_payload_size
        ) -> [::Hash[::String, untyped], ::Integer, bool]?
sig/datadog/open_feature/flag_evaluation/writer.rbs:124
└── def encoded_event: (::Hash[::String, untyped] event) -> [::Hash[::String, untyped], ::Integer]
sig/datadog/open_feature/flag_evaluation/writer.rbs:128
└── def degrade_event_for_payload_limit: (
          ::Hash[::String, untyped] event
        ) -> ::Hash[::String, untyped]?
sig/datadog/open_feature/flag_evaluation/writer.rbs:134
└── def event_count: (::Hash[::String, untyped] event) -> ::Integer
sig/datadog/open_feature/hooks/flag_eval_evp_hook.rbs:15
└── def finally: (
          hook_context: untyped,
          evaluation_details: untyped,
          **untyped _opts
        ) -> void
sig/datadog/open_feature/hooks/flag_eval_evp_hook.rbs:23
└── def extract_targeting_key: (untyped evaluation_context) -> ::String?
sig/datadog/open_feature/hooks/flag_eval_evp_hook.rbs:25
└── def extract_attributes: (untyped evaluation_context) -> ::Hash[::String, untyped]
sig/datadog/open_feature/hooks/flag_eval_evp_hook.rbs:27
└── def extract_allocation_key: (untyped evaluation_details) -> ::String?
sig/datadog/open_feature/hooks/flag_eval_evp_hook.rbs:29
└── def extract_error_message: (untyped evaluation_details) -> ::String?
sig/datadog/open_feature/hooks/flag_eval_evp_hook.rbs:31
└── def runtime_default?: (untyped evaluation_details) -> bool
sig/datadog/open_feature/native_evaluator.rbs:10
└── def get_assignment: (
        ::String flag_key,
        default_value: untyped,
        context: ::OpenFeature::SDK::EvaluationContext::fields_t,
        expected_type: ::Symbol
      ) -> (Core::FeatureFlags::ResolutionDetails | ResolutionDetails)
sig/datadog/open_feature/provider.rbs:74
└── def sdk_success_details: (
        (ResolutionDetails | Core::FeatureFlags::ResolutionDetails) result,
        ::Hash[::String, untyped] flag_meta
      ) -> ::OpenFeature::SDK::Provider::ResolutionDetails
sig/datadog/open_feature/provider.rbs:87
└── def build_flag_metadata: (
        (ResolutionDetails | Core::FeatureFlags::ResolutionDetails) result,
        ::Integer eval_time_ms
      ) -> ::Hash[::String, untyped]
sig/datadog/profiling/collectors/thread_context.rbs:29
└── def self.for_testing: (
          recorder: Datadog::Profiling::StackRecorder,
          ?max_frames: ::Integer,
          ?tracer: Datadog::Tracing::Tracer?,
          ?endpoint_collection_enabled: bool,
          ?waiting_for_gvl_threshold_ns: ::Integer,
          ?otel_context_enabled: (::Symbol? | bool),
          ?native_filenames_enabled: bool,
          ?show_classes: bool,
          ?trigger_global_reset: bool,
          **untyped
        ) -> Datadog::Profiling::Collectors::ThreadContext

Untyped other declarations

This PR clears 2 partially typed other declarations. It increases the percentage of typed other declarations from 85.33% to 85.68% (+0.35%).

Partially typed other declarations (+0-2)Cleared:
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:51
└── @full: ::Hash[::Array[untyped], ::Hash[::Symbol, untyped]]
sig/datadog/open_feature/flag_evaluation/aggregator.rbs:53
└── @degraded: ::Hash[::Array[untyped], ::Hash[::Symbol, untyped]]

If you believe a method or an attribute is rightfully untyped or partially typed, you can add # untyped:accept on the line before the definition to remove it from the stats.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

debugger Live Debugger (+Dynamic Instrumentation, +Symbol Database)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants