Skip to content

fix(curl): enforce DD_TRACE_SPANS_LIMIT for curl_multi_exec parent spans (APMS-19944)#4030

Merged
Leiyks merged 4 commits into
masterfrom
leiyks/fix-apms-19944
Jul 3, 2026
Merged

fix(curl): enforce DD_TRACE_SPANS_LIMIT for curl_multi_exec parent spans (APMS-19944)#4030
Leiyks merged 4 commits into
masterfrom
leiyks/fix-apms-19944

Conversation

@Leiyks

@Leiyks Leiyks commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What

Skip creating the curl_multi_exec parent span when the tracer is already limited (DD_TRACE_SPANS_LIMIT reached / memory pressure).

Why (APMS-19944)

Follow-up to #3691 (APMS-18744). That PR limited the child header-injection spans but left the parent curl_multi_exec span unbounded.

The parent span is opened via the user-facing DDTrace\start_span() (CurlIntegration.php), which #3691 deliberately left exempt from the span limit (guarding it broke testTracerFlushedWhenSpanLimitExceeded and Guzzle's isolateTracer()). Distributed-header injection then flags that span DDTRACE_SPAN_FLAG_NOT_DROPPABLE (handlers_http.h), so the post-hook's try_drop_span() can never reclaim it.

In long-running CLI workloads that repeatedly call curl_multi_exec (e.g. AWS SDK SQS sendMessage batches), one un-droppable parent span accumulates per call, growing memory 1:1 with calls until OOM.

Reported: PHP 8.2, ECS Fargate ARM64, reproduced on 1.17.0 and 1.22.0 — 10,000 iterations → ~10,500 spans in one trace.

Fix

Gate the internal integration use of start_span() with dd_trace_tracer_is_limited(), without touching the user-facing API (so the tests behind the #3691 revert stay green). When limited, the C handler still injects distributed tracing headers against the active span, so propagation is preserved; child request spans are already limited.

Added regression test tests/ext/integrations/curl/curl_multi_exec_span_limit.phpt — PASS with the fix, FAIL (LEAK) without it.

Fixes: APMS-19944

…ans (APMS-19944)

The curl_multi_exec integration opens its parent span via the user-facing
DDTrace\start_span(), which intentionally bypasses DD_TRACE_SPANS_LIMIT
(see #3691). Distributed-header injection then flags that span NOT_DROPPABLE,
so the post-hook's try_drop_span() can never reclaim it. In long-running CLI
workloads that repeatedly call curl_multi_exec (e.g. AWS SDK SQS batches),
one un-droppable span accumulates per call, growing memory 1:1 with calls
until OOM.

Skip creating the parent span when the tracer is already limited. Distributed
tracing headers are still injected by the C handler against the active span,
so propagation is preserved, and the child request spans are already limited.

Fixes: APMS-19944
@datadog-prod-us1-6

datadog-prod-us1-6 Bot commented Jul 2, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

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

Leiyks added 2 commits July 2, 2026 15:09
dd_trace_tracer_is_limited() reflects the open+closed span counters at the
moment of the call, which depends on end-of-run flush/cleanup timing and
varies across CI environments and PHP versions (7.3/7.4/8.2-ASAN all reported
bool(false) at script end). The span-count assertion (BOUNDED) is the real
regression guard: with the fix the retained curl_multi_exec spans stay capped
near DD_TRACE_SPANS_LIMIT, without it they scale 1:1 with iterations. Bump to
100 iterations for a clearer margin and drop the fragile check.
@pr-commenter

pr-commenter Bot commented Jul 2, 2026

Copy link
Copy Markdown

Benchmarks [ tracer ]

Benchmark execution time: 2026-07-03 16:04:36

Comparing candidate commit 06bcdf2 in PR branch leiyks/fix-apms-19944 with baseline commit 0271b2a in branch master.

Found 0 performance improvements and 5 performance regressions! Performance is the same for 189 metrics, 0 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 ----------------------------------'

scenario:MessagePackSerializationBench/benchMessagePackSerialization-opcache

  • 🟥 execution_time [+3.068µs; +4.412µs] or [+2.912%; +4.188%]

scenario:SamplingRuleMatchingBench/benchRegexMatching1

  • 🟥 execution_time [+93.841ns; +177.359ns] or [+6.429%; +12.151%]

scenario:SamplingRuleMatchingBench/benchRegexMatching2

  • 🟥 execution_time [+90.593ns; +157.407ns] or [+6.183%; +10.743%]

scenario:SamplingRuleMatchingBench/benchRegexMatching3

  • 🟥 execution_time [+76.076ns; +150.724ns] or [+5.130%; +10.163%]

scenario:SamplingRuleMatchingBench/benchRegexMatching4

  • 🟥 execution_time [+96.957ns; +159.043ns] or [+6.674%; +10.947%]

@Leiyks
Leiyks marked this pull request as ready for review July 2, 2026 14:45
@Leiyks
Leiyks requested review from a team as code owners July 2, 2026 14:45
@Leiyks
Leiyks requested review from tabgok and removed request for a team July 2, 2026 14:45
Comment thread src/DDTrace/Integrations/Curl/CurlIntegration.php Outdated
Address review: move the ddtrace_tracer_is_limited() guard to the start of
the begin hook so the limited path returns immediately, skipping the args
lookup and ObjectKVStore reuse check. Spans for a multi-op are only created
on the first curl_multi_exec call, so a new handle has nothing to reuse when
limited; the boundary case (parent created just before the limit) is still
finalized by CurlSpanInfo::__destruct().
@Leiyks
Leiyks merged commit 0c09f3c into master Jul 3, 2026
121 of 619 checks passed
@Leiyks
Leiyks deleted the leiyks/fix-apms-19944 branch July 3, 2026 14:51
@github-actions github-actions Bot added this to the 1.23.0 milestone Jul 3, 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.

2 participants