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'];
}
}