diff --git a/config/opentelemetry.php b/config/opentelemetry.php index a95b383..444d38e 100644 --- a/config/opentelemetry.php +++ b/config/opentelemetry.php @@ -37,7 +37,11 @@ 'sensitive_headers' => [], ], - Instrumentation\HttpClientInstrumentation::class => env('OT_INSTRUMENTATION_HTTP_CLIENT', true), + Instrumentation\HttpClientInstrumentation::class => [ + 'enabled' => env('OT_INSTRUMENTATION_HTTP_CLIENT', true), + 'allowed_headers' => [], + 'sensitive_headers' => [], + ], Instrumentation\QueryInstrumentation::class => env('OT_INSTRUMENTATION_QUERY', true), diff --git a/src/Instrumentation/HandlesHttpHeaders.php b/src/Instrumentation/HandlesHttpHeaders.php new file mode 100644 index 0000000..c5a411e --- /dev/null +++ b/src/Instrumentation/HandlesHttpHeaders.php @@ -0,0 +1,59 @@ + + */ + protected array $defaultSensitiveHeaders = [ + 'authorization', + 'php-auth-pw', + 'cookie', + 'set-cookie', + ]; + + /** + * @var array + */ + protected static array $allowedHeaders = []; + + /** + * @var array + */ + protected static array $sensitiveHeaders = []; + + /** + * @return array + */ + public static function getAllowedHeaders(): array + { + return static::$allowedHeaders; + } + + /** + * @return array + */ + public static function getSensitiveHeaders(): array + { + return static::$sensitiveHeaders; + } + + /** + * @param array $headers + * @return array + */ + protected function normalizeHeaders(array $headers): array + { + return Arr::map( + $headers, + fn (string $header) => strtolower(trim($header)), + ); + } +} diff --git a/src/Instrumentation/HttpClientInstrumentation.php b/src/Instrumentation/HttpClientInstrumentation.php index 3726970..eca837e 100644 --- a/src/Instrumentation/HttpClientInstrumentation.php +++ b/src/Instrumentation/HttpClientInstrumentation.php @@ -3,12 +3,22 @@ namespace Keepsuit\LaravelOpenTelemetry\Instrumentation; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Support\Arr; use Keepsuit\LaravelOpenTelemetry\Support\HttpClient\GuzzleTraceMiddleware; class HttpClientInstrumentation implements Instrumentation { + use HandlesHttpHeaders; + public function register(array $options): void { + static::$allowedHeaders = $this->normalizeHeaders(Arr::get($options, 'allowed_headers', [])); + + static::$sensitiveHeaders = array_merge( + $this->normalizeHeaders(Arr::get($options, 'sensitive_headers', [])), + $this->defaultSensitiveHeaders + ); + $this->registerWithTraceMacro(); } diff --git a/src/Instrumentation/HttpServerInstrumentation.php b/src/Instrumentation/HttpServerInstrumentation.php index b403437..716d943 100644 --- a/src/Instrumentation/HttpServerInstrumentation.php +++ b/src/Instrumentation/HttpServerInstrumentation.php @@ -8,19 +8,10 @@ class HttpServerInstrumentation implements Instrumentation { - protected const DEFAULT_SENSITIVE_HEADERS = [ - 'authorization', - 'php-auth-pw', - 'cookie', - 'set-cookie', - ]; + use HandlesHttpHeaders; protected static array $excludedPaths = []; - protected static array $allowedHeaders = []; - - protected static array $sensitiveHeaders = []; - /** * @return array */ @@ -29,22 +20,6 @@ public static function getExcludedPaths(): array return static::$excludedPaths; } - /** - * @return array - */ - public static function getAllowedHeaders(): array - { - return static::$allowedHeaders; - } - - /** - * @return array - */ - public static function getSensitiveHeaders(): array - { - return static::$sensitiveHeaders; - } - public function register(array $options): void { static::$excludedPaths = array_map( @@ -52,16 +27,11 @@ public function register(array $options): void Arr::get($options, 'excluded_paths', []) ); - static::$allowedHeaders = array_map( - fn (string $header) => strtolower(trim($header)), - Arr::get($options, 'allowed_headers', []) - ); + static::$allowedHeaders = $this->normalizeHeaders(Arr::get($options, 'allowed_headers', [])); + static::$sensitiveHeaders = array_merge( - array_map( - fn (string $header) => strtolower(trim($header)), - Arr::get($options, 'sensitive_headers', []) - ), - self::DEFAULT_SENSITIVE_HEADERS + $this->normalizeHeaders(Arr::get($options, 'sensitive_headers', [])), + $this->defaultSensitiveHeaders ); $this->injectMiddleware(app(Kernel::class)); diff --git a/src/Support/HttpClient/GuzzleTraceMiddleware.php b/src/Support/HttpClient/GuzzleTraceMiddleware.php index 2e0773a..a36a198 100644 --- a/src/Support/HttpClient/GuzzleTraceMiddleware.php +++ b/src/Support/HttpClient/GuzzleTraceMiddleware.php @@ -4,12 +4,14 @@ use Closure; use GuzzleHttp\Promise\PromiseInterface; -use GuzzleHttp\Psr7\Response; use Keepsuit\LaravelOpenTelemetry\Facades\Tracer; +use Keepsuit\LaravelOpenTelemetry\Instrumentation\HttpClientInstrumentation; +use OpenTelemetry\API\Trace\SpanInterface; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\SemConv\TraceAttributes; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; class GuzzleTraceMiddleware { @@ -29,6 +31,8 @@ public static function make(): Closure ->setAttribute(TraceAttributes::SERVER_PORT, $request->getUri()->getPort()) ->start(); + static::recordHeaders($span, $request); + $context = $span->storeInContext(Tracer::currentContext()); foreach (Tracer::propagationHeaders($context) as $key => $value) { @@ -38,9 +42,14 @@ public static function make(): Closure $promise = $handler($request, $options); assert($promise instanceof PromiseInterface); - return $promise->then(function (Response $response) use ($span) { - $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()) - ->setAttribute(TraceAttributes::HTTP_REQUEST_BODY_SIZE, $response->getHeader('Content-Length')[0] ?? null); + return $promise->then(function (ResponseInterface $response) use ($span) { + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()); + + if (($contentLength = $response->getHeader('Content-Length')[0] ?? null) !== null) { + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_BODY_SIZE, $contentLength); + } + + static::recordHeaders($span, $response); if ($response->getStatusCode() >= 400) { $span->setStatus(StatusCode::STATUS_ERROR); @@ -53,4 +62,26 @@ public static function make(): Closure }; }; } + + protected static function recordHeaders(SpanInterface $span, RequestInterface|ResponseInterface $http): SpanInterface + { + $prefix = match (true) { + $http instanceof RequestInterface => 'http.request.header.', + $http instanceof ResponseInterface => 'http.response.header.', + }; + + foreach ($http->getHeaders() as $key => $value) { + $key = strtolower($key); + + if (! in_array($key, HttpClientInstrumentation::getAllowedHeaders())) { + continue; + } + + $value = in_array($key, HttpClientInstrumentation::getSensitiveHeaders()) ? ['*****'] : $value; + + $span->setAttribute($prefix.$key, $value); + } + + return $span; + } } diff --git a/src/Support/HttpServer/TraceRequestMiddleware.php b/src/Support/HttpServer/TraceRequestMiddleware.php index 92c0715..812fc86 100644 --- a/src/Support/HttpServer/TraceRequestMiddleware.php +++ b/src/Support/HttpServer/TraceRequestMiddleware.php @@ -102,6 +102,8 @@ protected function recordHeaders(SpanInterface $span, Request|Response $http): S }; foreach ($http->headers->all() as $key => $value) { + $key = strtolower($key); + if (! in_array($key, HttpServerInstrumentation::getAllowedHeaders())) { continue; } diff --git a/tests/Instrumentation/HttpClientInstrumentationTest.php b/tests/Instrumentation/HttpClientInstrumentationTest.php index 1a5c109..3114a85 100644 --- a/tests/Instrumentation/HttpClientInstrumentationTest.php +++ b/tests/Instrumentation/HttpClientInstrumentationTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; use Keepsuit\LaravelOpenTelemetry\Facades\Tracer; +use Keepsuit\LaravelOpenTelemetry\Instrumentation\HttpClientInstrumentation; use OpenTelemetry\API\Trace\SpanKind; use OpenTelemetry\API\Trace\StatusCode; @@ -89,3 +90,102 @@ 'http.response.status_code' => 500, ]); }); + +it('trace allowed request headers', function () { + app()->make(HttpClientInstrumentation::class)->register([ + 'allowed_headers' => [ + 'x-foo', + ], + ]); + + Server::enqueue([ + new Response(200, ['Content-Length' => 0]), + ]); + + Http::withHeaders([ + 'x-foo' => 'bar', + 'x-bar' => 'baz', + ])->withTrace()->get(Server::$url); + + $span = getRecordedSpans()[0]; + + expect($span->getAttributes()) + ->toMatchArray([ + 'http.request.header.x-foo' => ['bar'], + ]) + ->not->toHaveKey('http.request.header.x-bar'); +}); + +it('trace allowed response headers', function () { + app()->make(HttpClientInstrumentation::class)->register([ + 'allowed_headers' => [ + 'content-type', + ], + ]); + + Server::enqueue([ + new Response(200, ['Content-Length' => 0, 'Content-Type' => 'text/html; charset=UTF-8']), + ]); + + Http::withTrace()->get(Server::$url); + + $span = getRecordedSpans()[0]; + + expect($span->getAttributes()) + ->toMatchArray([ + 'http.response.header.content-type' => ['text/html; charset=UTF-8'], + ]) + ->not->toHaveKey('http.response.header.date'); +}); + +it('trace sensitive headers with hidden value', function () { + app()->make(HttpClientInstrumentation::class)->register([ + 'allowed_headers' => [ + 'x-foo', + ], + 'sensitive_headers' => [ + 'x-foo', + ], + ]); + + Server::enqueue([ + new Response(200, ['Content-Length' => 0]), + ]); + + Http::withHeaders(['x-foo' => 'bar'])->withTrace()->get(Server::$url); + + $span = getRecordedSpans()[0]; + + expect($span->getAttributes()) + ->toMatchArray([ + 'http.request.header.x-foo' => ['*****'], + ]); +}); + +it('mark some headers as sensitive by default', function () { + app()->make(HttpClientInstrumentation::class)->register([ + 'allowed_headers' => [ + 'authorization', + 'cookie', + 'set-cookie', + ], + ]); + + Server::enqueue([ + new Response(200, ['Content-Length' => 0, 'Set-Cookie' => 'cookie']), + ]); + + Http::withHeaders([ + 'authorization' => 'Bearer token', + 'cookie' => 'cookie', + ])->withTrace()->get(Server::$url); + + $span = getRecordedSpans()[0]; + + expect($span->getAttributes()) + ->toMatchArray([ + 'http.request.header.authorization' => ['*****'], + 'http.request.header.cookie' => ['*****'], + 'http.response.header.set-cookie' => ['*****'], + ]); +});