Skip to content

fix(curl): deduplicate propagation headers on outgoing request#655

Open
PuvaanRaaj wants to merge 1 commit into
open-telemetry:mainfrom
PuvaanRaaj:fix/1898-curl-dup-headers
Open

fix(curl): deduplicate propagation headers on outgoing request#655
PuvaanRaaj wants to merge 1 commit into
open-telemetry:mainfrom
PuvaanRaaj:fix/1898-curl-dup-headers

Conversation

@PuvaanRaaj

Copy link
Copy Markdown
Contributor

Problem

With both open-telemetry/opentelemetry-auto-guzzle and open-telemetry/opentelemetry-auto-curl enabled, every outgoing Guzzle request (which uses cURL as its default handler) ends up carrying traceparent/tracestate twice on the wire. Some third-party APIs reject requests with duplicate propagation headers.

Root cause

src/Instrumentation/Curl/src/CurlHandleMetadata.php, getRequestHeadersToSend() (line 57 on main):

$headers = array_merge($this->headersToPropagate, $this->headers);

Sequence of events:

  1. The Guzzle hook fires on Client::transfer() and calls inject() on the PSR-7 request, adding traceparent/tracestate to the request headers.
  2. Guzzle's CurlFactory::applyHeaders() copies those headers into CURLOPT_HTTPHEADER. The curl instrumentation's updateFromCurlOption() picks this list up and stores it in CurlHandleMetadata::$headers.
  3. The curl hook fires on curl_exec() and calls inject() again, this time on CurlHandleMetadata via HeadersPropagator, which appends a second set of propagation headers into $headersToPropagate.
  4. getRequestHeadersToSend() merges $headersToPropagate and $headers with array_merge(). Since CURLOPT_HTTPHEADER is a plain numerically-indexed list of "Name: value" strings (not an associative map keyed by header name), array_merge() just concatenates the two lists — both traceparent entries survive and both go out with CURLOPT_HTTPHEADER.

Same class of bug applies to auto-psr18 + auto-curl (any instrumentation that injects into the PSR-7/PSR-18 request before curl's own hook runs).

Fix

Deduplicate the final header list by header name (case-insensitive) in getRequestHeadersToSend(), building an associative map keyed by lowercased header name from $this->headers first, then overlaying $this->headersToPropagate (so the freshly-injected, most-recent propagation context wins for any header name collision), then flattening back to the "Name: value" list form cURL expects. Non-propagation headers and their original casing/values are left untouched.

Invariant satisfied: exactly one traceparent/tracestate/baggage header reaches the wire, regardless of how many instrumentations injected into the request beforehand.

Tests

Added src/Instrumentation/Curl/tests/Unit/CurlHandleMetadataTest.php:

  • test_get_request_headers_to_send_deduplicates_already_propagated_header: seeds CURLOPT_HTTPHEADER with a traceparent/tracestate pair (simulating an upstream Guzzle/PSR-18 injection), then calls setHeaderToPropagate() again for the same header names (simulating curl's own inject), and asserts the resulting list has exactly one traceparent and one tracestate, with the freshly-injected value, plus the untouched Content-Type header.
  • test_get_request_headers_to_send_returns_null_when_nothing_to_propagate: unchanged existing behavior.

Red/green check: reverted CurlHandleMetadata.php to the array_merge() version on main and confirmed the new test fails (Failed asserting that actual size 5 matches expected size 3, i.e. duplicate headers present). Restored the fix and confirmed it passes.

Verification (Docker, PHP_VERSION=8.2)

  • make test (Instrumentation/Curl): 19 tests, 131 assertions, all green (existing integration tests unaffected).
  • make style (php-cs-fixer): clean, 0 files changed.
  • make psalm: no errors.
  • make phpstan: no errors.

No public API changes.

Fixes open-telemetry/opentelemetry-php#1898

When both auto-curl and an upstream injector (auto-guzzle/auto-psr18)
are enabled, propagation headers were injected twice and array_merge
appended both to CURLOPT_HTTPHEADER, putting duplicate traceparent/
tracestate on the wire (rejected by some APIs). Deduplicate the final
header list by name so exactly one set is sent.

Fixes open-telemetry/opentelemetry-php#1898
@PuvaanRaaj
PuvaanRaaj requested a review from a team as a code owner July 11, 2026 08:37
@PuvaanRaaj

Copy link
Copy Markdown
Contributor Author

cc @open-telemetry/php-approvers @brettmc for review 🙏

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.85%. Comparing base (03b5f93) to head (d03cd8f).

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff              @@
##               main     #655      +/-   ##
============================================
+ Coverage     80.09%   80.85%   +0.75%     
+ Complexity     1597     1450     -147     
============================================
  Files           118       95      -23     
  Lines          6179     5353     -826     
============================================
- Hits           4949     4328     -621     
+ Misses         1230     1025     -205     
Flag Coverage Δ
Context/Swoole 0.00% <ø> (ø)
Exporter/Instana 49.80% <ø> (ø)
Instrumentation/AwsSdk 82.14% <ø> (ø)
Instrumentation/CakePHP 20.42% <ø> (ø)
Instrumentation/CodeIgniter 79.31% <ø> (ø)
Instrumentation/Curl 87.90% <100.00%> (+1.01%) ⬆️
Instrumentation/Doctrine 92.82% <ø> (ø)
Instrumentation/ExtAmqp 88.80% <ø> (ø)
Instrumentation/Guzzle 79.76% <ø> (ø)
Instrumentation/HttpAsyncClient 78.94% <ø> (ø)
Instrumentation/HttpConfig 28.76% <ø> (ø)
Instrumentation/IO 0.00% <ø> (ø)
Instrumentation/Laravel ?
Instrumentation/Magento2 88.12% <ø> (ø)
Instrumentation/MongoDB 76.84% <ø> (ø)
Instrumentation/OpenAIPHP 86.71% <ø> (ø)
Instrumentation/PostgreSql 91.36% <ø> (ø)
Instrumentation/Psr14 77.41% <ø> (ø)
Instrumentation/Psr15 89.74% <ø> (ø)
Instrumentation/Psr16 97.43% <ø> (ø)
Instrumentation/Psr18 79.41% <ø> (ø)
Instrumentation/Psr6 97.56% <ø> (ø)
Instrumentation/ReactPHP 99.41% <ø> (ø)
Instrumentation/Session 94.28% <ø> (ø)
Instrumentation/Slim 84.21% <ø> (ø)
Propagation/CloudTrace 90.69% <ø> (ø)
Propagation/Instana 98.07% <ø> (ø)
Propagation/ServerTiming 94.73% <ø> (ø)
Propagation/TraceResponse 94.73% <ø> (ø)
ResourceDetectors/Azure 91.66% <ø> (ø)
ResourceDetectors/DigitalOcean 100.00% <ø> (ø)
Sampler/Xray 78.38% <ø> (ø)
Shims/OpenTracing 92.99% <ø> (ø)
SqlCommenter 95.58% <ø> (ø)
Utils/Test 87.79% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...rc/Instrumentation/Curl/src/CurlHandleMetadata.php 83.14% <100.00%> (+4.83%) ⬆️

... and 23 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 03b5f93...d03cd8f. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

Copy link
Copy Markdown

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: d03cd8f4b8

ℹ️ 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 on lines +62 to +64
foreach ($this->headers as $header) {
$name = strtolower(trim(explode(':', $header, 2)[0]));
$headers[$name] = $header;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unrelated duplicate request headers

When any propagation header is injected, this loop rekeys all existing CURLOPT_HTTPHEADER entries by lower-cased name before adding the propagation headers. If a caller intentionally configured repeated non-propagation headers, such as multiple Cookie or custom header entries, only the last one survives, whereas the previous array_merge($this->headersToPropagate, $this->headers) kept them all. Please restrict deduplication to the propagated header names so instrumentation does not alter unrelated user-supplied request headers.

Useful? React with 👍 / 👎.

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.

Duplicate propagation headers when auto-guzzle and auto-curl are both enabled

1 participant