diff --git a/src/sentry/src/Tracing/Aspect/GuzzleHttpClientAspect.php b/src/sentry/src/Tracing/Aspect/GuzzleHttpClientAspect.php index 39035f6da..53d1a9f3d 100644 --- a/src/sentry/src/Tracing/Aspect/GuzzleHttpClientAspect.php +++ b/src/sentry/src/Tracing/Aspect/GuzzleHttpClientAspect.php @@ -19,9 +19,11 @@ use Hyperf\Di\Aop\ProceedingJoinPoint; use Psr\Container\ContainerInterface; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; use Sentry\State\Scope; use Sentry\Tracing\SpanContext; use Sentry\Tracing\SpanStatus; +use Throwable; use function FriendsOfHyperf\Sentry\trace; @@ -31,6 +33,8 @@ */ class GuzzleHttpClientAspect extends AbstractAspect { + private const MAX_RESPONSE_BODY_SIZE = 8192; // 8 KB + public array $classes = [ Client::class . '::transfer', ]; @@ -116,23 +120,9 @@ function (Scope $scope) use ($proceedingJoinPoint, $options, $guzzleConfig, $req ]); if ($this->feature->isTracingTagEnabled('http.response.body.contents')) { - $isTextual = \preg_match( - '/^(text\/|application\/(json|xml|x-www-form-urlencoded))/i', - $response->getHeaderLine('Content-Type') - ) === 1; - $body = $response->getBody(); - - if ($isTextual && $body->isSeekable()) { - $pos = $body->tell(); - $span->setData([ - 'http.response.body.contents' => \GuzzleHttp\Psr7\Utils::copyToString($body, 8192), // 8KB 上限 - ]); - $body->seek($pos); - } else { - $span->setData([ - 'http.response.body.contents' => '[binary omitted]', - ]); - } + $span->setData([ + 'http.response.body.contents' => $this->getResponsePayload($response, $options), + ]); } $span->setHttpStatus($response->getStatusCode()); @@ -156,4 +146,58 @@ function (Scope $scope) use ($proceedingJoinPoint, $options, $guzzleConfig, $req ->setOrigin('auto.http.client') ); } + + protected function getResponsePayload(ResponseInterface $response, array $options = []): mixed + { + if (isset($options['stream']) && $options['stream'] === true) { + return '[Streamed Response]'; + } + + // Determine if the response is textual based on the Content-Type header. + $contentType = $response->getHeaderLine('Content-Type'); + $isTextual = $contentType === '' || \preg_match( + '/^(text\/|application\/(json|xml|x-www-form-urlencoded|grpc))/i', + $contentType + ) === 1; + + // If the response is not textual or the stream is not seekable, we will return a placeholder. + if (! $isTextual) { + return '[Binary Omitted]'; + } + + $stream = $response->getBody(); + $pos = null; + + try { + if ($stream->isSeekable()) { + $pos = $stream->tell(); + $stream->rewind(); + } + + $content = \GuzzleHttp\Psr7\Utils::copyToString( + $stream, + self::MAX_RESPONSE_BODY_SIZE + 1 // 多读 1 byte 用来判断是否截断 + ); + + if (strlen($content) > self::MAX_RESPONSE_BODY_SIZE) { + return substr( + $content, + 0, + self::MAX_RESPONSE_BODY_SIZE + ) . '… [truncated]'; + } + + return $content === '' ? '[Empty-String Response]' : $content; + } catch (Throwable $e) { + return '[Error Retrieving Response Content]'; + } finally { + if ($pos !== null) { + try { + $stream->seek($pos); + } catch (Throwable) { + // ignore: tracing must not break the request flow + } + } + } + } } diff --git a/src/telescope/src/Aspect/GuzzleHttpClientAspect.php b/src/telescope/src/Aspect/GuzzleHttpClientAspect.php index 127b85619..7476e5aac 100644 --- a/src/telescope/src/Aspect/GuzzleHttpClientAspect.php +++ b/src/telescope/src/Aspect/GuzzleHttpClientAspect.php @@ -60,7 +60,7 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint) // Add or override the on_stats option to record the request duration. $onStats = $options['on_stats'] ?? null; - $proceedingJoinPoint->arguments['keys']['options']['on_stats'] = function (TransferStats $stats) use ($onStats) { + $proceedingJoinPoint->arguments['keys']['options']['on_stats'] = function (TransferStats $stats) use ($onStats, $options) { try { $request = $stats->getRequest(); $response = $stats->getResponse(); @@ -75,7 +75,7 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint) $content['response_status'] = $response->getStatusCode(); $content['response_headers'] = $response->getHeaders(); $content['response_reason'] = $response->getReasonPhrase(); - $content['response_payload'] = $this->getResponsePayload($response); + $content['response_payload'] = $this->getResponsePayload($response, $options); } Telescope::recordClientRequest(IncomingEntry::make($content)); @@ -91,9 +91,26 @@ public function process(ProceedingJoinPoint $proceedingJoinPoint) return $proceedingJoinPoint->process(); } - public function getResponsePayload(ResponseInterface $response) + public function getResponsePayload(ResponseInterface $response, array $options = []): mixed { + if (isset($options['stream']) && $options['stream'] === true) { + return 'Streamed Response'; + } + + // Determine if the response is textual based on the Content-Type header. + $contentType = $response->getHeaderLine('Content-Type'); + $isTextual = $contentType === '' || \preg_match( + '/^(text\/|application\/(json|xml|x-www-form-urlencoded|grpc))/i', + $contentType + ) === 1; + + // If the response is not textual, we will return a placeholder. + if (! $isTextual) { + return 'Binary Response'; + } + $stream = $response->getBody(); + try { if ($stream->isSeekable()) { $stream->rewind(); @@ -102,23 +119,25 @@ public function getResponsePayload(ResponseInterface $response) $content = $stream->getContents(); if (is_string($content)) { + // Check if the content is within the size limits. if (! $this->contentWithinLimits($content)) { return 'Purged By Hyperf Telescope'; } + // Try to decode JSON responses and hide sensitive parameters. if ( is_array(json_decode($content, true)) && json_last_error() === JSON_ERROR_NONE ) { - return $this->contentWithinLimits($content) /* @phpstan-ignore-line */ - ? $this->hideParameters(json_decode($content, true), Telescope::$hiddenResponseParameters) - : 'Purged By Hyperf Telescope'; - } - if (Str::startsWith(strtolower($response->getHeaderLine('content-type') ?: ''), 'text/plain')) { - return $this->contentWithinLimits($content) ? $content : 'Purged By Hyperf Telescope'; /* @phpstan-ignore-line */ + return $this->hideParameters(json_decode($content, true), Telescope::$hiddenResponseParameters); } + // Return gRPC responses and plain text responses as is. if (Str::contains($response->getHeaderLine('content-type'), 'application/grpc') !== false) { return TelescopeContext::getGrpcResponsePayload() ?: 'Purged By Hyperf Telescope'; } + // Return plain text responses as is. + if (Str::startsWith(strtolower($response->getHeaderLine('content-type') ?: ''), 'text/plain')) { + return $content; + } } if (empty($content)) { @@ -127,9 +146,7 @@ public function getResponsePayload(ResponseInterface $response) } catch (Throwable $e) { return 'Purged By Hyperf Telescope: ' . $e->getMessage(); } finally { - if ($stream->isSeekable()) { - $stream->rewind(); - } + $stream->isSeekable() && $stream->rewind(); } return 'HTML Response';