agent/php/ElasticApm/Impl/AutoInstrument/CurlHandleTracker.php (418 lines of code) (raw):

<?php /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright * ownership. Elasticsearch B.V. licenses this file to you under * the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /** @noinspection PhpComposerExtensionStubsInspection */ declare(strict_types=1); namespace Elastic\Apm\Impl\AutoInstrument; use Closure; use Elastic\Apm\CustomErrorData; use Elastic\Apm\Impl\AutoInstrument\Util\AutoInstrumentationUtil; use Elastic\Apm\Impl\Constants; use Elastic\Apm\Impl\Log\LogCategory; use Elastic\Apm\Impl\Log\LoggableInterface; use Elastic\Apm\Impl\Log\LoggableTrait; use Elastic\Apm\Impl\Log\Logger; use Elastic\Apm\Impl\Tracer; use Elastic\Apm\Impl\Util\ArrayUtil; use Elastic\Apm\Impl\Util\Assert; use Elastic\Apm\Impl\Util\EnabledAssertProxy; use Elastic\Apm\Impl\Util\ExceptionUtil; use Elastic\Apm\Impl\Util\InternalFailureException; use Elastic\Apm\Impl\Util\UrlUtil; use Elastic\Apm\SpanInterface; use const CURLOPT_CUSTOMREQUEST; use const CURLOPT_HTTPGET; use const CURLOPT_HTTPHEADER; use const CURLOPT_NOBODY; use const CURLOPT_POST; use const CURLOPT_POSTFIELDS; use const CURLOPT_PUT; use const CURLOPT_URL; /** * Code in this file is part of implementation internals and thus it is not covered by the backward compatibility. * * @internal */ final class CurlHandleTracker implements LoggableInterface { use LoggableTrait; private const GET_HTTP_METHOD = 'GET'; private const HEAD_HTTP_METHOD = 'HEAD'; private const POST_HTTP_METHOD = 'POST'; private const PUT_HTTP_METHOD = 'PUT'; /** @var Tracer */ private $tracer; /** @var Logger */ private $logger; /** @var AutoInstrumentationUtil */ private $util; /** @var CurlHandleWrapped */ private $curlHandle; /** @var ?string */ private $url = null; /** @var ?string */ private $httpMethod = 'GET'; /** @var mixed[] */ private $headersSetByApp = []; /** @var SpanInterface */ private $span; public function __construct(Tracer $tracer) { $this->tracer = $tracer; $this->logger = $tracer->loggerFactory()->loggerForClass( LogCategory::AUTO_INSTRUMENTATION, __NAMESPACE__, __CLASS__, __FILE__ )->addContext('this', $this); $this->util = new AutoInstrumentationUtil($tracer->loggerFactory()); } public function copy(): CurlHandleTracker { $copy = new CurlHandleTracker($this->tracer); $copy->url = $this->url; $copy->httpMethod = $this->httpMethod; $copy->headersSetByApp = $this->headersSetByApp; return $copy; } /** * @param mixed[] $interceptedCallArgs */ public function curlInitPreHook(array $interceptedCallArgs): void { if (count($interceptedCallArgs) !== 0) { $this->setUrl($interceptedCallArgs[0]); } ($loggerProxy = $this->logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Exiting'); } /** * @param mixed $value */ private function setUrl($value): void { if (!$this->util->verifyIsString($value)) { return; } /** @var string $value */ $this->url = $value; } /** * @param mixed $curlHandle * * @return int|null */ public function setHandle($curlHandle): ?int { if ($curlHandle === false) { return null; } // Prior to PHP 8 $curlHandle is a resource // For PHP 8+ $curlHandle is an instance of CurlHandle class /** @var resource|object $curlHandle */ $this->curlHandle = new CurlHandleWrapped($curlHandle); return $this->curlHandle->asInt(); } /** * @param string $dbgFuncName * @param int $funcId * @param mixed[] $interceptedCallArgs */ public function preHook(string $dbgFuncName, int $funcId, array $interceptedCallArgs): void { ($assertProxy = Assert::ifEnabled()) && $this->assertCurlHandleInArgsMatches($assertProxy, $dbgFuncName, $interceptedCallArgs); switch ($funcId) { case CurlAutoInstrumentation::CURL_SETOPT_ID: case CurlAutoInstrumentation::CURL_SETOPT_ARRAY_ID: // nothing to do until post-hook when we will know if the call succeeded return; case CurlAutoInstrumentation::CURL_EXEC_ID: $this->curlExecPreHook(); return; default: throw new InternalFailureException( ExceptionUtil::buildMessage( 'Unexpected function name', ['functionName' => $dbgFuncName, 'this' => $this] ) ); } } /** * @param string $dbgFuncName * @param int $funcId * @param int $numberOfStackFramesToSkip * @param mixed[] $interceptedCallArgs * @param mixed $returnValue * * @phpstan-param 0|positive-int $numberOfStackFramesToSkip */ public function postHook(string $dbgFuncName, int $funcId, int $numberOfStackFramesToSkip, array $interceptedCallArgs, $returnValue): void { ($assertProxy = Assert::ifEnabled()) && $this->assertCurlHandleInArgsMatches($assertProxy, $dbgFuncName, $interceptedCallArgs); switch ($funcId) { case CurlAutoInstrumentation::CURL_SETOPT_ID: $this->curlSetOptPostHook($interceptedCallArgs, $returnValue); return; case CurlAutoInstrumentation::CURL_SETOPT_ARRAY_ID: $this->curlSetOptArrayPostHook($interceptedCallArgs, $returnValue); return; case CurlAutoInstrumentation::CURL_EXEC_ID: $this->curlExecPostHook($numberOfStackFramesToSkip + 1, $returnValue); return; default: throw new InternalFailureException(ExceptionUtil::buildMessage('Unexpected function name', ['funcId' => $funcId, 'this' => $this])); } } /** * @param EnabledAssertProxy $assertProxy * @param string $dbgFuncName * @param mixed[] $interceptedCallArgs * * @return bool */ private function assertCurlHandleInArgsMatches(EnabledAssertProxy $assertProxy, string $dbgFuncName, array $interceptedCallArgs): bool { $curlHandle = CurlAutoInstrumentation::extractCurlHandleFromArgs($this->logger, $dbgFuncName, $interceptedCallArgs); $thisCurlHandleAsInt = $this->curlHandle->asInt(); $argsCurlHandleAsInt = CurlHandleWrapped::nullableAsInt($curlHandle); return $assertProxy->that($thisCurlHandleAsInt === $argsCurlHandleAsInt) && $assertProxy->withContext( '$thisCurlHandleAsInt === $argsCurlHandleAsInt', [ '$thisCurlHandleAsInt' => $thisCurlHandleAsInt, '$argsCurlHandleAsInt' => $argsCurlHandleAsInt, 'this' => $this, ] ); } /** * @param mixed $returnValue * * @return bool */ private function isSuccess($returnValue): bool { if (!$this->util->verifyIsBool($returnValue)) { return false; } /** @var bool $returnValue */ return $returnValue; } /** * @param mixed[] $interceptedCallArgs * @param mixed $returnValue */ private function curlSetOptPostHook(array $interceptedCallArgs, $returnValue): void { if (!$this->isSuccess($returnValue)) { return; } if (!$this->util->verifyMinArgsCount(2, $interceptedCallArgs)) { return; } $this->processSetOpt( $interceptedCallArgs[1], /** * @param mixed &$optVal * * @return bool */ function (&$optVal) use ($interceptedCallArgs) { if (!$this->util->verifyMinArgsCount(3, $interceptedCallArgs)) { return false; } $optVal = $interceptedCallArgs[2]; return true; } ); } /** * @param mixed $optionId * @param Closure(mixed &$optVal): bool $getOptionValue */ private function processSetOpt($optionId, Closure $getOptionValue): void { if (!$this->util->verifyIsInt($optionId)) { return; } switch ($optionId) { case CURLOPT_CUSTOMREQUEST: $optionValue = null; if (!$getOptionValue(/* ref */ $optionValue)) { return; } if (!$this->util->verifyIsString($optionValue)) { return; } /** @var string $optionValue */ $this->httpMethod = $optionValue; break; case CURLOPT_HTTPHEADER: $optionValue = null; if (!$getOptionValue(/* ref */ $optionValue)) { return; } if (!$this->util->verifyIsArray($optionValue)) { return; } /** @var mixed[] $optionValue */ $this->headersSetByApp = $optionValue; break; case CURLOPT_HTTPGET: // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L841 $this->httpMethod = self::GET_HTTP_METHOD; break; case CURLOPT_NOBODY: $optionValue = null; if (!$getOptionValue(/* ref */ $optionValue)) { return; } // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L269 if ($optionValue) { $this->httpMethod = self::HEAD_HTTP_METHOD; } elseif ($this->httpMethod === self::HEAD_HTTP_METHOD) { $this->httpMethod = self::GET_HTTP_METHOD; } break; case CURLOPT_POST: // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L616 $optionValue = null; if (!$getOptionValue(/* ref */ $optionValue)) { return; } $this->httpMethod = $optionValue ? self::POST_HTTP_METHOD : self::GET_HTTP_METHOD; break; case CURLOPT_POSTFIELDS: // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L486 $this->httpMethod = self::POST_HTTP_METHOD; break; case CURLOPT_PUT: // Based on https://github.com/curl/curl/blob/curl-7_73_0/lib/setopt.c#L292 $optionValue = null; if (!$getOptionValue(/* ref */ $optionValue)) { return; } $this->httpMethod = $optionValue ? self::PUT_HTTP_METHOD : self::GET_HTTP_METHOD; break; case CURLOPT_URL: $optionValue = null; if (!$getOptionValue(/* ref */ $optionValue)) { return; } $this->setUrl($optionValue); break; } } /** * @param mixed[] $interceptedCallArgs * @param mixed $returnValue */ private function curlSetOptArrayPostHook(array $interceptedCallArgs, $returnValue): void { if (!$this->isSuccess($returnValue)) { return; } if (!$this->util->verifyMinArgsCount(2, $interceptedCallArgs)) { return; } $optionsIdToValue = $interceptedCallArgs[1]; if (!$this->util->verifyIsArray($optionsIdToValue)) { return; } /** @var array<array-key, mixed> $optionsIdToValue */ foreach ($optionsIdToValue as $optionId => $optionValue) { $this->processSetOpt( $optionId, /** * @param mixed &$optVal * * @return bool */ function (&$optVal) use ($optionValue): bool { $optVal = $optionValue; return true; } ); } } private function curlExecPreHook(): void { $httpMethod = $this->httpMethod ?? ('<' . 'UNKNOWN HTTP METHOD' . '>'); $host = null; if ($this->url !== null) { $host = UrlUtil::extractHostPart($this->url); } $host = $host ?? ('<' . 'UNKNOWN HOST' . '>'); $spanName = $httpMethod . ' ' . $host; $isHttp = ($this->url !== null) && UrlUtil::isHttp($this->url); $this->span = AutoInstrumentationUtil::beginCurrentSpan($spanName, Constants::SPAN_TYPE_EXTERNAL, /* subtype: */ $isHttp ? Constants::SPAN_SUBTYPE_HTTP : null); $this->setContextPreHook(); if ($isHttp) { $headersToInjectFormattedLines = []; $this->span->injectDistributedTracingHeaders( function (string $headerName, string $headerValue) use (&$headersToInjectFormattedLines): void { $headersToInjectFormattedLines[] = $headerName . ': ' . $headerValue; } ); if (!ArrayUtil::isEmpty($headersToInjectFormattedLines)) { $this->injectDistributedTracingHeaders($headersToInjectFormattedLines); } } } private static function appendOrSetString(?string &$accumStr, ?string $substrToAppend): void { if ($substrToAppend === null) { return; } if ($accumStr === null) { $accumStr = $substrToAppend; } else { $accumStr .= $substrToAppend; } } private function buildContextDestinationServiceName( ?string $scheme, ?string $host, ?int $port, ?int $defaultPortForScheme ): ?string { /** @var ?string $result */ $result = null; if ($scheme !== null) { $result = $scheme . '//'; } self::appendOrSetString(/* ref */ $result, $host); if ($port !== null && $port !== $defaultPortForScheme) { self::appendOrSetString(/* ref */ $result, ':' . $port); } return $result; } private function buildContextDestinationServiceResource( ?string $host, ?int $port, ?int $defaultPortForScheme ): ?string { /** @var ?string $result */ $result = null; self::appendOrSetString(/* ref */ $result, $host); /** @var ?string $portSuffix */ $portSuffix = null; if ($port === null) { if ($defaultPortForScheme !== null) { $portSuffix = ':' . $defaultPortForScheme; } } else { $portSuffix = ':' . $port; } self::appendOrSetString(/* ref */ $result, $portSuffix); return $result; } private function setContextDestinationService(): void { if ($this->url === null) { return; } $parsedUrl = parse_url($this->url); if (!is_array($parsedUrl)) { return; } $scheme = ArrayUtil::getValueIfKeyExistsElse('scheme', $parsedUrl, null); if ($scheme !== null && !is_string($scheme)) { $scheme = null; } $host = ArrayUtil::getValueIfKeyExistsElse('host', $parsedUrl, null); if ($host !== null && !is_string($host)) { $host = null; } $port = ArrayUtil::getValueIfKeyExistsElse('port', $parsedUrl, null); if ($port !== null && !is_int($port)) { $port = null; } $defaultPortForScheme = ($scheme === null) ? null : UrlUtil::defaultPortForScheme($scheme); $name = $this->buildContextDestinationServiceName($scheme, $host, $port, $defaultPortForScheme); $resource = $this->buildContextDestinationServiceResource($host, $port, $defaultPortForScheme); if ($name !== null && $resource !== null) { $this->span->context()->destination()->setService($name, $resource, Constants::SPAN_TYPE_EXTERNAL); } } private function setContextDestination(): void { $this->setContextDestinationService(); } private function setContextPreHook(): void { if ($this->httpMethod !== null) { $this->span->context()->http()->setMethod($this->httpMethod); } if ($this->url !== null) { $this->span->context()->http()->setUrl($this->url); } $this->setContextDestination(); } private function setContextPostHook(): void { $errorCode = $this->curlHandle->errno(); if ($errorCode !== 0) { $this->span->setOutcome(Constants::OUTCOME_FAILURE); $customErrorData = new CustomErrorData(); $customErrorData->code = $errorCode; $customErrorData->type = curl_strerror($errorCode); $customErrorData->message = $this->curlHandle->error(); $this->span->createCustomError($customErrorData); return; } $responseStatusCode = $this->curlHandle->getResponseStatusCode(); if (is_int($responseStatusCode)) { $this->span->context()->http()->setStatusCode($responseStatusCode); $outcome = (400 <= $responseStatusCode && $responseStatusCode < 600) ? Constants::OUTCOME_FAILURE : Constants::OUTCOME_SUCCESS; $this->span->setOutcome($outcome); } else { ($loggerProxy = $this->logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Failed to get response status code', ['responseStatusCode' => $responseStatusCode]); } } /** * @param int $numberOfStackFramesToSkip * @param mixed $returnValue * * @phpstan-param 0|positive-int $numberOfStackFramesToSkip */ private function curlExecPostHook(int $numberOfStackFramesToSkip, $returnValue): void { $this->setContextPostHook(); AutoInstrumentationUtil::endSpan($numberOfStackFramesToSkip + 1, $this->span, /* hasExitedByException */ false, $returnValue); } /** * @param string[] $headersToInjectFormattedLines */ private function injectDistributedTracingHeaders(array $headersToInjectFormattedLines): void { $headers = array_merge($this->headersSetByApp, $headersToInjectFormattedLines); $logger = $this->logger->inherit()->addAllContext( [ 'headersToInjectFormattedLines' => $headersToInjectFormattedLines, 'headers' => $this->logger->possiblySecuritySensitive($headers), ] ); ($loggerProxy = $logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Injecting outgoing HTTP request headers for distributed tracing...'); $setOptRetVal = $this->curlHandle->setOpt(CURLOPT_HTTPHEADER, $headers); if ($setOptRetVal) { ($loggerProxy = $logger->ifTraceLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Successfully injected outgoing HTTP request headers for distributed tracing'); } else { ($loggerProxy = $logger->ifErrorLevelEnabled(__LINE__, __FUNCTION__)) && $loggerProxy->log('Failed to inject outgoing HTTP request headers for distributed tracing'); } } /** * @return string[] */ protected static function propertiesExcludedFromLog(): array { return ['tracer']; } }