From 799985e16e1df0013aa81dd41a42b7c963505e55 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 6 Nov 2024 14:19:19 +0100 Subject: [PATCH] Curl auto instrumentation distributed tracing headers propagation, request and response headers capturing (#1420) (#314) * Curl auto instrumentation distributed tracing headers propagation, request and response headers capturing (#1420) * Apply suggestions from code review Co-authored-by: Chris Lightfoot-Wild * Fixes after code review * Update src/Instrumentation/Curl/tests/Integration/CurlInstrumentationTest.php Co-authored-by: Brett McBride * Update src/Instrumentation/Curl/tests/Integration/CurlMultiInstrumentationTest.php Co-authored-by: Brett McBride --------- Co-authored-by: Chris Lightfoot-Wild Co-authored-by: Brett McBride --- src/Instrumentation/Curl/README.md | 37 +++ .../Curl/src/CurlHandleMetadata.php | 166 ++++++++++++ .../Curl/src/CurlInstrumentation.php | 252 ++++++++++++------ .../Curl/src/HeadersPropagator.php | 27 ++ .../Integration/CurlInstrumentationTest.php | 92 ++++++- .../CurlMultiInstrumentationTest.php | 126 +++++++++ 6 files changed, 616 insertions(+), 84 deletions(-) create mode 100644 src/Instrumentation/Curl/src/CurlHandleMetadata.php create mode 100644 src/Instrumentation/Curl/src/HeadersPropagator.php diff --git a/src/Instrumentation/Curl/README.md b/src/Instrumentation/Curl/README.md index 2aa32435..a2ccc261 100644 --- a/src/Instrumentation/Curl/README.md +++ b/src/Instrumentation/Curl/README.md @@ -14,14 +14,51 @@ install and configure the extension and SDK. ## Overview Auto-instrumentation hooks are registered via composer, and client kind spans will automatically be created when calling `curl_exec` or `curl_multi_exec` functions. +Additionally, distributed tracing is supported by setting the `traceparent` header. ## Limitations The curl_multi instrumentation is not resilient to shortcomings in the application and requires proper implementation. If the application does not call the curl_multi_info_read function, the instrumentation will be unable to measure the execution time for individual requests-time will be aggregated for all transfers. Similarly, error detection will be impacted, as the error code information will be missing in this case. In case of encountered issues, it is recommended to review the application code and adjust it to match example #1 provided in [curl_multi_exec documentation](https://www.php.net/manual/en/function.curl-multi-exec.php). +To ensure the stability of the monitored application, capturing request headers sent to the server works only if the application does not use the `CURLOPT_VERBOSE` option. + ## Configuration +### Disabling curl instrumentation + The extension can be disabled via [runtime configuration](https://opentelemetry.io/docs/instrumentation/php/sdk/#configuration): ```shell OTEL_PHP_DISABLED_INSTRUMENTATIONS=curl ``` + +### Request and response headers capturing + +Curl auto-instrumentation enables capturing headers from both requests and responses. This feature is disabled by default and be enabled through environment variables or array directives in the `php.ini` configuration file. + +To enable response header capture from the server, specify the required headers as shown in the example below. In this case, the "Content-Type" and "Server" headers will be captured. These options values are case-insensitive: + +#### Environment variables configuration + +```bash +OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=content-type,server +OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=host,accept +``` + +#### php.ini configuration + +```ini +OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=content-type,server +; or +otel.instrumentation.http.response_headers[]=content-type +otel.instrumentation.http.response_headers[]=server +``` + + +Similarly, to capture headers sent in a request to the server, use the following configuration: + +```ini +OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=host,accept +; or +otel.instrumentation.http.request_headers[]=host +otel.instrumentation.http.request_headers[]=accept +``` \ No newline at end of file diff --git a/src/Instrumentation/Curl/src/CurlHandleMetadata.php b/src/Instrumentation/Curl/src/CurlHandleMetadata.php new file mode 100644 index 00000000..90f57591 --- /dev/null +++ b/src/Instrumentation/Curl/src/CurlHandleMetadata.php @@ -0,0 +1,166 @@ +attributes = [TraceAttributes::HTTP_REQUEST_METHOD => 'GET']; + $this->headers = []; + $headersToPropagate = []; + } + + public function isVerboseEnabled(): bool + { + return $this->verboseEnabled; + } + + public function getAttributes(): array + { + return $this->attributes; + } + + public function setAttribute(string $key, mixed $value) + { + $this->attributes[$key] = $value; + } + + public function setHeaderToPropagate(string $key, $value): CurlHandleMetadata + { + $this->headersToPropagate[] = $key . ': ' . $value; + + return $this; + } + + public function getRequestHeadersToSend(): ?array + { + if (count($this->headersToPropagate) == 0) { + return null; + } + $headers = array_merge($this->headersToPropagate, $this->headers); + $this->headersToPropagate = []; + + return $headers; + } + + public function getCapturedResponseHeaders(): array + { + return $this->responseHeaders; + } + + public function getResponseHeaderCaptureFunction() + { + $this->responseHeaders = []; + $func = function (CurlHandle $handle, string $headerLine): int { + $header = trim($headerLine, "\n\r"); + + if (strlen($header) > 0) { + if (strpos($header, ': ') !== false) { + /** @psalm-suppress PossiblyUndefinedArrayOffset */ + [$key, $value] = explode(': ', $header, 2); + $this->responseHeaders[strtolower($key)] = $value; + } + } + + if ($this->originalHeaderFunction) { + return call_user_func($this->originalHeaderFunction, $handle, $headerLine); + } + + return strlen($headerLine); + }; + + return \Closure::bind($func, $this, self::class); + } + + public function updateFromCurlOption(int $option, mixed $value) + { + switch ($option) { + case CURLOPT_CUSTOMREQUEST: + $this->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $value); + + break; + case CURLOPT_HTTPGET: + // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L841 + $this->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, 'GET'); + + break; + case CURLOPT_POST: + $this->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, ($value == 1 ? 'POST' : 'GET')); + + break; + case CURLOPT_POSTFIELDS: + // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L269 + $this->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, 'POST'); + + break; + case CURLOPT_PUT: + $this->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, ($value == 1 ? 'PUT' : 'GET')); + + break; + case CURLOPT_NOBODY: + // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L269 + $this->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, ($value == 1 ? 'HEAD' : 'GET')); + + break; + case CURLOPT_URL: + $this->setAttribute(TraceAttributes::URL_FULL, self::redactUrlString($value)); + + break; + case CURLOPT_USERAGENT: + $this->setAttribute(TraceAttributes::USER_AGENT_ORIGINAL, $value); + + break; + case CURLOPT_HTTPHEADER: + $this->headers = $value; + + break; + case CURLOPT_HEADERFUNCTION: + $this->originalHeaderFunction = $value; + $this->verboseEnabled = false; + + break; + case CURLOPT_VERBOSE: + $this->verboseEnabled = $value; + + break; + } + } + + public static function redactUrlString(string $fullUrl) + { + $urlParts = parse_url($fullUrl); + if ($urlParts == false) { + return; + } + + $scheme = isset($urlParts['scheme']) ? $urlParts['scheme'] . '://' : ''; + $host = isset($urlParts['host']) ? $urlParts['host'] : ''; + $port = isset($urlParts['port']) ? ':' . $urlParts['port'] : ''; + $user = isset($urlParts['user']) ? 'REDACTED' : ''; + $pass = isset($urlParts['pass']) ? ':' . 'REDACTED' : ''; + $pass = ($user || $pass) ? "$pass@" : ''; + $path = isset($urlParts['path']) ? $urlParts['path'] : ''; + $query = isset($urlParts['query']) ? '?' . $urlParts['query'] : ''; + $fragment = isset($urlParts['fragment']) ? '#' . $urlParts['fragment'] : ''; + + return $scheme . $user . $pass . $host . $port . $path . $query . $fragment; + } + +} diff --git a/src/Instrumentation/Curl/src/CurlInstrumentation.php b/src/Instrumentation/Curl/src/CurlInstrumentation.php index c3ef4522..299639f1 100644 --- a/src/Instrumentation/Curl/src/CurlInstrumentation.php +++ b/src/Instrumentation/Curl/src/CurlInstrumentation.php @@ -6,6 +6,7 @@ use CurlHandle; use CurlMultiHandle; +use OpenTelemetry\API\Globals; use OpenTelemetry\API\Instrumentation\CachedInstrumentation; use OpenTelemetry\API\Trace\Span; use OpenTelemetry\API\Trace\SpanInterface; @@ -13,6 +14,7 @@ use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\Context\Context; use function OpenTelemetry\Instrumentation\hook; +use OpenTelemetry\SDK\Common\Configuration\Configuration; use OpenTelemetry\SemConv\TraceAttributes; use WeakMap; use WeakReference; @@ -23,7 +25,7 @@ class CurlInstrumentation public static function register(): void { - /** @var WeakMap */ + /** @var WeakMap */ $curlHandleToAttributes = new WeakMap(); /** @var WeakMap > @@ -38,6 +40,9 @@ public static function register(): void */ $curlMultiToHandle = new WeakMap(); + /** @var bool */ + $curlSetOptInstrumentationSuppressed = false; + $instrumentation = new CachedInstrumentation( 'io.opentelemetry.contrib.php.curl', null, @@ -50,9 +55,9 @@ public static function register(): void pre: null, post: static function ($obj, array $params, mixed $retVal) use ($curlHandleToAttributes) { if ($retVal instanceof CurlHandle) { - $curlHandleToAttributes[$retVal] = [TraceAttributes::HTTP_REQUEST_METHOD => 'GET']; - if (($handle = $params[0] ?? null) !== null) { - $curlHandleToAttributes[$retVal][TraceAttributes::URL_FULL] = self::redactUrlString($handle); + $curlHandleToAttributes[$retVal] = new CurlHandleMetadata(); + if (($fullUrl = $params[0] ?? null) !== null) { + $curlHandleToAttributes[$retVal]->setAttribute(TraceAttributes::URL_FULL, CurlHandleMetadata::redactUrlString($fullUrl)); } } } @@ -62,15 +67,12 @@ public static function register(): void null, 'curl_setopt', pre: null, - post: static function ($obj, array $params, mixed $retVal) use ($curlHandleToAttributes) { - if ($retVal != true) { + post: static function ($obj, array $params, mixed $retVal) use ($curlHandleToAttributes, &$curlSetOptInstrumentationSuppressed) { + if ($retVal != true || $curlSetOptInstrumentationSuppressed) { return; } - $attribute = self::getAttributeFromCurlOption($params[1], $params[2]); - if ($attribute) { - $curlHandleToAttributes[$params[0]][$attribute[0]] = $attribute[1]; - } + $curlHandleToAttributes[$params[0]]->updateFromCurlOption($params[1], $params[2]); } ); @@ -84,10 +86,7 @@ public static function register(): void } foreach ($params[1] as $option => $value) { - $attribute = self::getAttributeFromCurlOption($option, $value); - if ($attribute) { - $curlHandleToAttributes[$params[0]][$attribute[0]] = $attribute[1]; - } + $curlHandleToAttributes[$params[0]]->updateFromCurlOption($option, $value); } } ); @@ -119,7 +118,7 @@ public static function register(): void 'curl_reset', pre: static function ($obj, array $params) use ($curlHandleToAttributes) { if (count($params) > 0 && $params[0] instanceof CurlHandle) { - $curlHandleToAttributes[$params[0]] = [TraceAttributes::HTTP_REQUEST_METHOD => 'GET']; + $curlHandleToAttributes[$params[0]] = new CurlHandleMetadata(); } }, post: null @@ -128,26 +127,56 @@ public static function register(): void hook( null, 'curl_exec', - pre: static function ($obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno) use ($instrumentation, $curlHandleToAttributes) { + pre: static function ($obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno) use ($instrumentation, $curlHandleToAttributes, &$curlSetOptInstrumentationSuppressed) { if (!($params[0] instanceof CurlHandle)) { return; } - $spanName = $curlHandleToAttributes[$params[0]][TraceAttributes::HTTP_REQUEST_METHOD] ?? 'curl_exec'; + $spanName = $curlHandleToAttributes[$params[0]]->getAttributes()[TraceAttributes::HTTP_REQUEST_METHOD] ?? 'curl_exec'; + + $propagator = Globals::propagator(); + $parent = Context::getCurrent(); $builder = $instrumentation->tracer() ->spanBuilder($spanName) + ->setParent($parent) ->setSpanKind(SpanKind::KIND_CLIENT) ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) - ->setAttributes($curlHandleToAttributes[$params[0]]); + ->setAttributes($curlHandleToAttributes[$params[0]]->getAttributes()); - $parent = Context::getCurrent(); $span = $builder->startSpan(); - Context::storage()->attach($span->storeInContext($parent)); + $context = $span->storeInContext($parent); + $propagator->inject($curlHandleToAttributes[$params[0]], HeadersPropagator::instance(), $context); + + Context::storage()->attach($context); + + $curlSetOptInstrumentationSuppressed = true; + + $headers = $curlHandleToAttributes[$params[0]]->getRequestHeadersToSend(); + if ($headers) { + curl_setopt($params[0], CURLOPT_HTTPHEADER, $headers); + } + + if (self::isResponseHeadersCapturingEnabled()) { + curl_setopt($params[0], CURLOPT_HEADERFUNCTION, $curlHandleToAttributes[$params[0]]->getResponseHeaderCaptureFunction()); + } + if (self::isRequestHeadersCapturingEnabled()) { + if (!$curlHandleToAttributes[$params[0]]->isVerboseEnabled()) { // we let go of captuing request headers because CURLINFO_HEADER_OUT is disabling CURLOPT_VERBOSE + curl_setopt($params[0], CURLINFO_HEADER_OUT, true); + } + //TODO log? + + } + $curlSetOptInstrumentationSuppressed = false; + }, - post: static function ($obj, array $params, mixed $retVal) { + post: static function ($obj, array $params, mixed $retVal) use ($curlHandleToAttributes) { + if (!($params[0] instanceof CurlHandle)) { + return; + } + $scope = Context::storage()->scope(); if (!$scope) { return; @@ -157,17 +186,20 @@ public static function register(): void $span = Span::fromContext($scope->context()); if ($retVal !== false) { - if ($params[0] instanceof CurlHandle) { - self::setAttributesFromCurlGetInfo($params[0], $span); - } + self::setAttributesFromCurlGetInfo($params[0], $span); } else { - if ($params[0] instanceof CurlHandle) { - $errno = curl_errno($params[0]); - if ($errno != 0) { - $errorDescription = curl_strerror($errno) . ' (' . $errno . ')'; - $span->setStatus(StatusCode::STATUS_ERROR, $errorDescription); - } - $span->setAttribute(TraceAttributes::ERROR_TYPE, 'cURL error (' . $errno . ')'); + $errno = curl_errno($params[0]); + if ($errno != 0) { + $errorDescription = curl_strerror($errno) . ' (' . $errno . ')'; + $span->setStatus(StatusCode::STATUS_ERROR, $errorDescription); + } + $span->setAttribute(TraceAttributes::ERROR_TYPE, 'cURL error (' . $errno . ')'); + } + + $capturedHeaders = $curlHandleToAttributes[$params[0]]->getCapturedResponseHeaders(); + foreach (self::getResponseHeadersToCapture() as $headerToCapture) { + if (($value = $capturedHeaders[strtolower($headerToCapture)] ?? null) != null) { + $span->setAttribute(sprintf('http.response.header.%s', strtolower(string: $headerToCapture)), $value); } } @@ -227,7 +259,7 @@ public static function register(): void null, 'curl_multi_exec', pre: null, - post: static function ($obj, array $params, mixed $retVal) use ($curlMultiToHandle, $instrumentation, $curlHandleToAttributes) { + post: static function ($obj, array $params, mixed $retVal) use ($curlMultiToHandle, $instrumentation, $curlHandleToAttributes, &$curlSetOptInstrumentationSuppressed) { if ($retVal == CURLM_OK) { $mHandle = &$curlMultiToHandle[$params[0]]; @@ -235,16 +267,40 @@ public static function register(): void if (!$mHandle['started']) { // on first call to curl_multi_exec we're marking it's a transfer start for all curl handles attached to multi handle $parent = Context::getCurrent(); + $propagator = Globals::propagator(); + foreach ($handles as $cHandle => &$metadata) { - $spanName = $curlHandleToAttributes[$cHandle][TraceAttributes::HTTP_REQUEST_METHOD] ?? 'curl_multi_exec'; + $spanName = $curlHandleToAttributes[$cHandle]->getAttributes()[TraceAttributes::HTTP_REQUEST_METHOD] ?? 'curl_multi_exec'; $builder = $instrumentation->tracer() ->spanBuilder($spanName) + ->setParent($parent) ->setSpanKind(SpanKind::KIND_CLIENT) ->setAttribute(TraceAttributes::CODE_FUNCTION, 'curl_multi_exec') - ->setAttributes($curlHandleToAttributes[$cHandle]); + ->setAttributes($curlHandleToAttributes[$cHandle]->getAttributes()); $span = $builder->startSpan(); - Context::storage()->attach($span->storeInContext($parent)); + $context = $span->storeInContext($parent); + $propagator->inject($curlHandleToAttributes[$cHandle], HeadersPropagator::instance(), $context); + + Context::storage()->attach($context); + + $curlSetOptInstrumentationSuppressed = true; + $headers = $curlHandleToAttributes[$cHandle]->getRequestHeadersToSend(); + if ($headers) { + curl_setopt($cHandle, CURLOPT_HTTPHEADER, $headers); + } + if (self::isResponseHeadersCapturingEnabled()) { + curl_setopt($cHandle, CURLOPT_HEADERFUNCTION, $curlHandleToAttributes[$cHandle]->getResponseHeaderCaptureFunction()); + } + if (self::isRequestHeadersCapturingEnabled()) { + if (!$curlHandleToAttributes[$cHandle]->isVerboseEnabled()) { // we let go of captuing request headers because CURLINFO_HEADER_OUT is disabling CURLOPT_VERBOSE + curl_setopt($cHandle, CURLINFO_HEADER_OUT, true); + } + //TODO log? + + } + $curlSetOptInstrumentationSuppressed = false; + $metadata['span'] = WeakReference::create($span); } $mHandle['started'] = true; @@ -252,12 +308,11 @@ public static function register(): void $isRunning = $params[1]; if ($isRunning == 0) { - // it is the last call to multi - in case curl_multi_info_read might not not be called anytime, we need to finish all spans left foreach ($handles as $cHandle => &$metadata) { if ($metadata['finished'] == false) { $metadata['finished'] = true; - self::finishMultiSpan(CURLE_OK, $cHandle, $metadata['span']->get()); // there is no way to get information if it was OK or not without calling curl_multi_info_read + self::finishMultiSpan(CURLE_OK, $cHandle, $curlHandleToAttributes, $metadata['span']->get()); // there is no way to get information if it was OK or not without calling curl_multi_info_read } } @@ -265,6 +320,7 @@ public static function register(): void // https://curl.se/libcurl/c/libcurl-multi.html If you want to reuse an easy handle that was added to the multi handle for transfer, you must first remove it from the multi stack and then re-add it again (possibly after having altered some options at your own choice). unset($mHandle['handles']); $mHandle['handles'] = new WeakMap(); + } } } @@ -275,7 +331,7 @@ public static function register(): void null, 'curl_multi_info_read', pre: null, - post: static function ($obj, array $params, mixed $retVal) use ($curlMultiToHandle) { + post: static function ($obj, array $params, mixed $retVal) use ($curlMultiToHandle, $curlHandleToAttributes) { $mHandle = &$curlMultiToHandle[$params[0]]; if ($retVal != false) { @@ -290,15 +346,22 @@ public static function register(): void } $currentHandle['finished'] = true; - self::finishMultiSpan($retVal['result'], $retVal['handle'], $currentHandle['span']->get()); + self::finishMultiSpan($retVal['result'], $retVal['handle'], $curlHandleToAttributes, $currentHandle['span']->get()); } } } ); } - private static function finishMultiSpan(int $curlResult, CurlHandle $curlHandle, SpanInterface $span) + private static function finishMultiSpan(int $curlResult, CurlHandle $curlHandle, $curlHandleToAttributes, SpanInterface $span) { + $scope = Context::storage()->scope(); + $scope?->detach(); + + if (!$scope || $scope->context() === Context::getCurrent()) { + return; + } + if ($curlResult == CURLE_OK) { self::setAttributesFromCurlGetInfo($curlHandle, $span); } else { @@ -306,54 +369,37 @@ private static function finishMultiSpan(int $curlResult, CurlHandle $curlHandle, $span->setStatus(StatusCode::STATUS_ERROR, $errorDescription); $span->setAttribute(TraceAttributes::ERROR_TYPE, 'cURL error (' . $curlResult . ')'); } - $span->end(); - } - private static function redactUrlString(string $fullUrl) - { - $urlParts = parse_url($fullUrl); - if ($urlParts == false) { - return; + $capturedHeaders = $curlHandleToAttributes[$curlHandle]->getCapturedResponseHeaders(); + foreach (self::getResponseHeadersToCapture() as $headerToCapture) { + if (($value = $capturedHeaders[strtolower($headerToCapture)] ?? null) != null) { + $span->setAttribute(sprintf('http.response.header.%s', strtolower(string: $headerToCapture)), $value); + } } - $scheme = isset($urlParts['scheme']) ? $urlParts['scheme'] . '://' : ''; - $host = isset($urlParts['host']) ? $urlParts['host'] : ''; - $port = isset($urlParts['port']) ? ':' . $urlParts['port'] : ''; - $user = isset($urlParts['user']) ? 'REDACTED' : ''; - $pass = isset($urlParts['pass']) ? ':' . 'REDACTED' : ''; - $pass = ($user || $pass) ? "$pass@" : ''; - $path = isset($urlParts['path']) ? $urlParts['path'] : ''; - $query = isset($urlParts['query']) ? '?' . $urlParts['query'] : ''; - $fragment = isset($urlParts['fragment']) ? '#' . $urlParts['fragment'] : ''; - - return $scheme . $user . $pass . $host . $port . $path . $query . $fragment; + $span->end(); } - private static function getAttributeFromCurlOption(int $option, mixed $value): ?array + private static function transformHeaderStringToArray(string $header): array { - switch ($option) { - case CURLOPT_CUSTOMREQUEST: - return [TraceAttributes::HTTP_REQUEST_METHOD, $value]; - case CURLOPT_HTTPGET: - // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L841 - return [TraceAttributes::HTTP_REQUEST_METHOD, 'GET']; - case CURLOPT_POST: - return [TraceAttributes::HTTP_REQUEST_METHOD, ($value == 1 ? 'POST' : 'GET')]; - case CURLOPT_POSTFIELDS: - // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L269 - return [TraceAttributes::HTTP_REQUEST_METHOD, 'POST']; - case CURLOPT_PUT: - return [TraceAttributes::HTTP_REQUEST_METHOD, ($value == 1 ? 'PUT' : 'GET')]; - case CURLOPT_NOBODY: - // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L269 - return [TraceAttributes::HTTP_REQUEST_METHOD, ($value == 1 ? 'HEAD' : 'GET')]; - case CURLOPT_URL: - return [TraceAttributes::URL_FULL, self::redactUrlString($value)]; - case CURLOPT_USERAGENT: - return [TraceAttributes::USER_AGENT_ORIGINAL, $value]; + $lines = explode("\n", $header); + array_shift($lines); // skip request line + + $headersResult = []; + foreach ($lines as $line) { + $line = trim($line, "\r"); + if (empty($line)) { + continue; + } + + if (strpos($line, ': ') !== false) { + /** @psalm-suppress PossiblyUndefinedArrayOffset */ + [$key, $value] = explode(': ', $line, 2); + $headersResult[strtolower($key)] = $value; + } } - return null; + return $headersResult; } private static function setAttributesFromCurlGetInfo(CurlHandle $handle, SpanInterface $span) @@ -377,5 +423,51 @@ private static function setAttributesFromCurlGetInfo(CurlHandle $handle, SpanInt if (($value = $info['primary_port']) != 0) { $span->setAttribute(TraceAttributes::SERVER_PORT, $value); } + + /** @phpstan-ignore-next-line */ + if (($requestHeader = $info['request_header'] ?? null) != null) { + $capturedHeaders = self::transformHeaderStringToArray($requestHeader); + foreach (self::getRequestHeadersToCapture() as $headerToCapture) { + if (($value = $capturedHeaders[strtolower($headerToCapture)] ?? null) != null) { + $span->setAttribute(sprintf('http.request.header.%s', strtolower(string: $headerToCapture)), $value); + } + } + } + } + + private static function isRequestHeadersCapturingEnabled(): bool + { + if (class_exists('OpenTelemetry\SDK\Common\Configuration\Configuration') && count(Configuration::getList('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS', [])) > 0) { + return true; + } + + return get_cfg_var('otel.instrumentation.http.request_headers') !== false; + } + + private static function getRequestHeadersToCapture(): array + { + if (class_exists('OpenTelemetry\SDK\Common\Configuration\Configuration') && count($values = Configuration::getList('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS', [])) > 0) { + return $values; + } + + return (array) (get_cfg_var('otel.instrumentation.http.request_headers') ?: []); + } + + private static function isResponseHeadersCapturingEnabled(): bool + { + if (class_exists('OpenTelemetry\SDK\Common\Configuration\Configuration') && count(Configuration::getList('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS', [])) > 0) { + return true; + } + + return get_cfg_var('otel.instrumentation.http.response_headers') !== false; + } + + private static function getResponseHeadersToCapture(): array + { + if (class_exists('OpenTelemetry\SDK\Common\Configuration\Configuration') && count($values = Configuration::getList('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS', [])) > 0) { + return $values; + } + + return (array) (get_cfg_var('otel.instrumentation.http.response_headers') ?: []); } } diff --git a/src/Instrumentation/Curl/src/HeadersPropagator.php b/src/Instrumentation/Curl/src/HeadersPropagator.php new file mode 100644 index 00000000..40c33fb7 --- /dev/null +++ b/src/Instrumentation/Curl/src/HeadersPropagator.php @@ -0,0 +1,27 @@ +setHeaderToPropagate($key, $value); + } +} diff --git a/src/Instrumentation/Curl/tests/Integration/CurlInstrumentationTest.php b/src/Instrumentation/Curl/tests/Integration/CurlInstrumentationTest.php index 12951797..4ff18f10 100644 --- a/src/Instrumentation/Curl/tests/Integration/CurlInstrumentationTest.php +++ b/src/Instrumentation/Curl/tests/Integration/CurlInstrumentationTest.php @@ -6,6 +6,7 @@ use ArrayObject; use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\Context\ScopeInterface; use OpenTelemetry\SDK\Trace\ImmutableSpan; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; @@ -31,6 +32,7 @@ public function setUp(): void $this->scope = Configurator::create() ->withTracerProvider($tracerProvider) + ->withPropagator(TraceContextPropagator::getInstance()) ->activate(); } @@ -63,9 +65,22 @@ public function test_curl_setopt(): void $this->assertCount(1, $this->storage); $span = $this->storage->offsetGet(0); + $this->assertEquals('http://gugugaga.gugugaga/', $span->getAttributes()->get(TraceAttributes::URL_FULL)); $this->assertSame('POST', $span->getName()); $this->assertSame('Error', $span->getStatus()->getCode()); - $this->assertSame('Couldn\'t resolve host name (6)', $span->getStatus()->getDescription()); + $this->assertStringContainsString('resolve host', $span->getStatus()->getDescription()); + } + + public function test_curl_setopt_overrides_url(): void + { + $ch = curl_init('http://example.com'); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_URL, 'http://gugugaga.gugugaga/'); + curl_exec($ch); + + $this->assertCount(1, $this->storage); + $span = $this->storage->offsetGet(0); + $this->assertEquals('http://gugugaga.gugugaga/', $span->getAttributes()->get(TraceAttributes::URL_FULL)); } public function test_curl_setopt_array(): void @@ -78,7 +93,7 @@ public function test_curl_setopt_array(): void $span = $this->storage->offsetGet(0); $this->assertSame('POST', $span->getName()); $this->assertSame('Error', $span->getStatus()->getCode()); - $this->assertSame('Couldn\'t resolve host name (6)', $span->getStatus()->getDescription()); + $this->assertStringContainsString('resolve host', $span->getStatus()->getDescription()); } public function test_curl_copy_handle(): void @@ -95,7 +110,7 @@ public function test_curl_copy_handle(): void $span = $this->storage->offsetGet(0); $this->assertSame('POST', $span->getName()); $this->assertSame('Error', $span->getStatus()->getCode()); - $this->assertSame('Couldn\'t resolve host name (6)', $span->getStatus()->getDescription()); + $this->assertStringContainsString('resolve host', $span->getStatus()->getDescription()); } public function test_curl_exec_with_error(): void @@ -107,7 +122,7 @@ public function test_curl_exec_with_error(): void $span = $this->storage->offsetGet(0); $this->assertSame('GET', $span->getName()); $this->assertSame('Error', $span->getStatus()->getCode()); - $this->assertSame('Couldn\'t resolve host name (6)', $span->getStatus()->getDescription()); + $this->assertStringContainsString('resolve host', $span->getStatus()->getDescription()); $this->assertEquals('cURL error (6)', $span->getAttributes()->get(TraceAttributes::ERROR_TYPE)); $this->assertEquals('GET', $span->getAttributes()->get(TraceAttributes::HTTP_REQUEST_METHOD)); $this->assertEquals('http://gugugaga.gugugaga/', $span->getAttributes()->get(TraceAttributes::URL_FULL)); @@ -126,4 +141,73 @@ public function test_curl_exec(): void $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); $this->assertEquals(80, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); } + + public function test_curl_exec_calls_user_defined_headerfunc(): void + { + // test if response header capturing is not breaking user header func invocation + + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=server'); + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=host'); + + $ch = curl_init('http://example.com/'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + $func = function (\CurlHandle $ch, string $headerLine) { + return strlen($headerLine); + }; + + $mockedFunc = $this->getMockBuilder(\stdClass::class) + ->addMethods(['__invoke']) + ->getMock(); + + $mockedFunc->expects($this->atLeastOnce()) + ->method('__invoke') + ->willReturnCallback($func); + + curl_setopt($ch, CURLOPT_HEADERFUNCTION, $mockedFunc); + curl_exec($ch); + + $this->assertCount(1, $this->storage); + $span = $this->storage->offsetGet(0); + $this->assertSame('GET', $span->getName()); + $this->assertEquals(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); + $this->assertEquals(80, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); + } + + public function test_curl_exec_headers_capturing(): void + { + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=content-type'); + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=host'); + + $ch = curl_init('http://example.com/'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + curl_exec($ch); + + $this->assertCount(1, $this->storage); + $span = $this->storage->offsetGet(0); + $this->assertSame('GET', $span->getName()); + $this->assertEquals(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); + $this->assertStringContainsStringIgnoringCase('text/html', $span->getAttributes()->get('http.response.header.content-type')); + $this->assertEquals('example.com', $span->getAttributes()->get('http.request.header.host')); + } + + public function test_curl_exec_sets_traceparent(): void + { + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=traceparent'); + + $ch = curl_init('http://example.com/'); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); + + curl_exec($ch); + + $this->assertCount(1, $this->storage); + $span = $this->storage->offsetGet(0); + $this->assertSame('GET', $span->getName()); + $this->assertEquals(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); + $this->assertNotEmpty($span->getAttributes()->get('http.request.header.traceparent')); + } } diff --git a/src/Instrumentation/Curl/tests/Integration/CurlMultiInstrumentationTest.php b/src/Instrumentation/Curl/tests/Integration/CurlMultiInstrumentationTest.php index 2f07bbb2..ebdd7150 100644 --- a/src/Instrumentation/Curl/tests/Integration/CurlMultiInstrumentationTest.php +++ b/src/Instrumentation/Curl/tests/Integration/CurlMultiInstrumentationTest.php @@ -6,6 +6,7 @@ use ArrayObject; use OpenTelemetry\API\Instrumentation\Configurator; +use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\Context\ScopeInterface; use OpenTelemetry\SDK\Trace\ImmutableSpan; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; @@ -31,6 +32,7 @@ public function setUp(): void $this->scope = Configurator::create() ->withTracerProvider($tracerProvider) + ->withPropagator(TraceContextPropagator::getInstance()) ->activate(); } @@ -121,4 +123,128 @@ public function test_curl_multi_remove_handle() $span = $this->storage->offsetGet(0); $this->assertEquals('other://scheme.com/', actual: $span->getAttributes()->get(TraceAttributes::URL_FULL)); } + + public function test_curl_multi_exec_calls_user_defined_headerfunc(): void + { + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=content-type'); + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=host'); + + $mh = curl_multi_init(); + $ch1 = curl_init('http://example.com/'); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, 1); + + $func = function (\CurlHandle $ch, string $headerLine) { + return strlen($headerLine); + }; + + $mockedFunc = $this->getMockBuilder(\stdClass::class) + ->addMethods(['__invoke']) + ->getMock(); + + $mockedFunc->expects($this->atLeastOnce()) + ->method('__invoke') + ->willReturnCallback($func); + + curl_setopt($ch1, CURLOPT_HEADERFUNCTION, $mockedFunc); + + $ch2 = curl_copy_handle($ch1); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $running = null; + do { + curl_multi_exec($mh, $running); + + while (($info = curl_multi_info_read($mh)) !== false) { + } + } while ($running); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + $this->assertCount(2, $this->storage); + foreach ([0, 1] as $offset) { + $span = $this->storage->offsetGet($offset); + $this->assertSame('GET', $span->getName()); + $this->assertEquals(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); + $this->assertEquals(80, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); + } + } + + public function test_curl_multi_exec_headers_capturing(): void + { + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_RESPONSE_HEADERS=content-type'); + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=host'); + + $mh = curl_multi_init(); + $ch1 = curl_init('http://example.com/'); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, 1); + + $ch2 = curl_copy_handle($ch1); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $running = null; + do { + curl_multi_exec($mh, $running); + + while (($info = curl_multi_info_read($mh)) !== false) { + } + } while ($running); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + $this->assertCount(2, $this->storage); + foreach ([0, 1] as $offset) { + $span = $this->storage->offsetGet($offset); + $this->assertSame('GET', $span->getName()); + $this->assertEquals(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); + $this->assertEquals(80, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); + $this->assertStringContainsStringIgnoringCase('text/html', $span->getAttributes()->get('http.response.header.content-type')); + $this->assertEquals('example.com', $span->getAttributes()->get('http.request.header.host')); + } + } + + public function test_curl_multi_exec_sets_traceparent(): void + { + putenv('OTEL_PHP_INSTRUMENTATION_HTTP_REQUEST_HEADERS=traceparent'); + + $mh = curl_multi_init(); + $ch1 = curl_init('http://example.com/'); + curl_setopt($ch1, CURLOPT_RETURNTRANSFER, 1); + + $ch2 = curl_copy_handle($ch1); + + curl_multi_add_handle($mh, $ch1); + curl_multi_add_handle($mh, $ch2); + + $running = null; + do { + curl_multi_exec($mh, $running); + + while (($info = curl_multi_info_read($mh)) !== false) { + } + } while ($running); + + curl_multi_remove_handle($mh, $ch1); + curl_multi_remove_handle($mh, $ch2); + curl_multi_close($mh); + + $this->assertCount(2, $this->storage); + foreach ([0, 1] as $offset) { + $span = $this->storage->offsetGet($offset); + $this->assertSame('GET', $span->getName()); + $this->assertEquals(200, $span->getAttributes()->get(TraceAttributes::HTTP_RESPONSE_STATUS_CODE)); + $this->assertEqualsIgnoringCase('http', $span->getAttributes()->get(TraceAttributes::URL_SCHEME)); + $this->assertEquals(80, $span->getAttributes()->get(TraceAttributes::SERVER_PORT)); + $this->assertNotEmpty($span->getAttributes()->get('http.request.header.traceparent')); + } + } }