diff --git a/src/Instrumentation/Curl/README.md b/src/Instrumentation/Curl/README.md index 2aa32435..1b7ebc0e 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 captuing + +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..a11dcc6a --- /dev/null +++ b/src/Instrumentation/Curl/src/CurlHandleMetadata.php @@ -0,0 +1,139 @@ +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 */ + list($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; + // no break + case CURLOPT_VERBOSE: + $this->verboseEnabled = false; + } + } +} diff --git a/src/Instrumentation/Curl/src/CurlInstrumentation.php b/src/Instrumentation/Curl/src/CurlInstrumentation.php index c3ef4522..cf8f6d56 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, self::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,6 +369,14 @@ private static function finishMultiSpan(int $curlResult, CurlHandle $curlHandle, $span->setStatus(StatusCode::STATUS_ERROR, $errorDescription); $span->setAttribute(TraceAttributes::ERROR_TYPE, 'cURL error (' . $curlResult . ')'); } + + $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); + } + } + $span->end(); } @@ -329,31 +400,26 @@ private static function redactUrlString(string $fullUrl) return $scheme . $user . $pass . $host . $port . $path . $query . $fragment; } - 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 */ + list($key, $value) = explode(': ', $line, 2); + $headersResult[strtolower($key)] = $value; + } } - return null; + return $headersResult; } private static function setAttributesFromCurlGetInfo(CurlHandle $handle, SpanInterface $span) @@ -377,5 +443,55 @@ private static function setAttributesFromCurlGetInfo(CurlHandle $handle, SpanInt if (($value = $info['primary_port']) != 0) { $span->setAttribute(TraceAttributes::SERVER_PORT, $value); } + + 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..62001163 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(); } @@ -65,7 +67,7 @@ public function test_curl_setopt(): 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_setopt_array(): void @@ -78,7 +80,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 +97,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 +109,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 +128,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_captuing(): 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..2c314c8e 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,129 @@ 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_captuing(): 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')); + } + } + }