Skip to content

Commit 0c09f3c

Browse files
authored
fix(curl): enforce DD_TRACE_SPANS_LIMIT for curl_multi_exec parent spans (APMS-19944) (#4030)
* fix(curl): enforce DD_TRACE_SPANS_LIMIT for curl_multi_exec parent spans (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 * test(curl): drop flaky is_limited assertion from span-limit test 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. * test(curl): remove comments from span-limit test * fix(curl): check span limit at top of curl_multi_exec hook 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().
1 parent d262f72 commit 0c09f3c

2 files changed

Lines changed: 43 additions & 1 deletion

File tree

src/DDTrace/Integrations/Curl/CurlIntegration.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ public static function init(): int
100100
]);
101101

102102
\DDTrace\install_hook('curl_multi_exec', static function (HookData $hook) {
103-
if (\count($hook->args) < 2) {
103+
if (\count($hook->args) < 2 || \dd_trace_tracer_is_limited()) {
104104
return;
105105
}
106106
$data = null;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
--TEST--
2+
curl_multi_exec parent spans honor DD_TRACE_SPANS_LIMIT (APMS-19944)
3+
--SKIPIF--
4+
<?php if (!extension_loaded('curl')) die('skip: curl extension required'); ?>
5+
<?php if (!getenv('HTTPBIN_HOSTNAME')) die('skip: HTTPBIN_HOSTNAME env var required'); ?>
6+
--ENV--
7+
DD_TRACE_AUTO_FLUSH_ENABLED=0
8+
DD_TRACE_GENERATE_ROOT_SPAN=1
9+
DD_TRACE_SPANS_LIMIT=20
10+
DD_TRACE_LOG_LEVEL=error
11+
--FILE--
12+
<?php
13+
$port = getenv('HTTPBIN_PORT') ?: '80';
14+
$url = 'http://' . getenv('HTTPBIN_HOSTNAME') . ':' . $port . '/';
15+
16+
$iterations = 100;
17+
for ($i = 0; $i < $iterations; $i++) {
18+
$ch = curl_init($url);
19+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
20+
21+
$mh = curl_multi_init();
22+
curl_multi_add_handle($mh, $ch);
23+
do {
24+
$status = curl_multi_exec($mh, $active);
25+
curl_multi_select($mh);
26+
} while ($active > 0 && $status === CURLM_OK);
27+
curl_multi_remove_handle($mh, $ch);
28+
curl_multi_close($mh);
29+
}
30+
31+
$spans = dd_trace_serialize_closed_spans();
32+
$multiCount = 0;
33+
foreach ($spans as $span) {
34+
if (($span['name'] ?? '') === 'curl_multi_exec') {
35+
$multiCount++;
36+
}
37+
}
38+
39+
echo ($multiCount <= $iterations / 2 ? 'BOUNDED' : 'LEAK') . "\n";
40+
?>
41+
--EXPECT--
42+
BOUNDED

0 commit comments

Comments
 (0)