diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3c067fe..e57a8d0 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,7 +20,7 @@ jobs: options: --entrypoint redis-server strategy: - fail-fast: true + fail-fast: false matrix: php: [ 8.1, 8.2, 8.3 ] laravel: [ ^9.0, ^10.0 ] diff --git a/README.md b/README.md index 2b6ba9f..eb5d178 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,16 @@ return [ Instrumentation\HttpServerInstrumentation::class => [ 'enabled' => env('OT_INSTRUMENTATION_HTTP_SERVER', true), 'excluded_paths' => [], + 'allowed_headers' => [], + 'sensitive_headers' => [], + ], + + Instrumentation\HttpClientInstrumentation::class => [ + 'enabled' => env('OT_INSTRUMENTATION_HTTP_CLIENT', true), + 'allowed_headers' => [], + 'sensitive_headers' => [], ], - Instrumentation\HttpClientInstrumentation::class => env('OT_INSTRUMENTATION_HTTP_CLIENT', true), - Instrumentation\QueryInstrumentation::class => env('OT_INSTRUMENTATION_QUERY', true), Instrumentation\RedisInstrumentation::class => env('OT_INSTRUMENTATION_REDIS', true), @@ -129,6 +135,8 @@ You can disable it by setting `OT_INSTRUMENTATION_HTTP_SERVER` to `false` or rem Configuration options: - `excluded_paths`: list of paths to exclude from tracing +- `allowed_headers`: list of headers to include in the trace +- `sensitive_headers`: list of headers with sensitive data to hide in the trace ### Http client @@ -138,6 +146,13 @@ To trace an outgoing http request call the `withTrace` method on the request bui Http::withTrace()->get('https://example.com'); ``` +You can disable it by setting `OT_INSTRUMENTATION_HTTP_CLIENT` to `false` or removing the `HttpClientInstrumentation::class` from the config file. + +Configuration options: + +- `allowed_headers`: list of headers to include in the trace +- `sensitive_headers`: list of headers with sensitive data to hide in the trace + ### Database Database queries are automatically traced. diff --git a/config/opentelemetry.php b/config/opentelemetry.php index e502652..444d38e 100644 --- a/config/opentelemetry.php +++ b/config/opentelemetry.php @@ -33,9 +33,15 @@ Instrumentation\HttpServerInstrumentation::class => [ 'enabled' => env('OT_INSTRUMENTATION_HTTP_SERVER', true), 'excluded_paths' => [], + 'allowed_headers' => [], + '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/phpunit.xml.dist b/phpunit.xml.dist index fb74f41..d3b29ad 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -36,4 +36,7 @@ + + + diff --git a/src/Instrumentation/HandlesHttpHeaders.php b/src/Instrumentation/HandlesHttpHeaders.php new file mode 100644 index 0000000..d0c4816 --- /dev/null +++ b/src/Instrumentation/HandlesHttpHeaders.php @@ -0,0 +1,69 @@ + + */ + 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; + } + + public static function headerIsAllowed(string $header): bool + { + return in_array($header, static::getAllowedHeaders()); + } + + /** + * @return array + */ + public static function getSensitiveHeaders(): array + { + return static::$sensitiveHeaders; + } + + public static function headerIsSensitive(string $header): bool + { + return in_array($header, static::getSensitiveHeaders()); + } + + /** + * @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 69f45d3..716d943 100644 --- a/src/Instrumentation/HttpServerInstrumentation.php +++ b/src/Instrumentation/HttpServerInstrumentation.php @@ -8,8 +8,13 @@ class HttpServerInstrumentation implements Instrumentation { + use HandlesHttpHeaders; + protected static array $excludedPaths = []; + /** + * @return array + */ public static function getExcludedPaths(): array { return static::$excludedPaths; @@ -22,6 +27,13 @@ public function register(array $options): void Arr::get($options, 'excluded_paths', []) ); + static::$allowedHeaders = $this->normalizeHeaders(Arr::get($options, 'allowed_headers', [])); + + static::$sensitiveHeaders = array_merge( + $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..a244b1d 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 (! HttpClientInstrumentation::headerIsAllowed($key)) { + continue; + } + + $value = HttpClientInstrumentation::headerIsSensitive($key) ? ['*****'] : $value; + + $span->setAttribute($prefix.$key, $value); + } + + return $span; + } } diff --git a/src/Support/HttpServer/TraceRequestMiddleware.php b/src/Support/HttpServer/TraceRequestMiddleware.php index 2a1e746..67be701 100644 --- a/src/Support/HttpServer/TraceRequestMiddleware.php +++ b/src/Support/HttpServer/TraceRequestMiddleware.php @@ -53,7 +53,7 @@ protected function startTracing(Request $request): SpanInterface $route = rescue(fn () => Route::getRoutes()->match($request)->uri(), $request->path(), false); $route = str_starts_with($route, '/') ? $route : '/'.$route; - return Tracer::newSpan($route) + $span = Tracer::newSpan($route) ->setSpanKind(SpanKind::KIND_SERVER) ->setParent($context) ->setAttribute(TraceAttributes::URL_FULL, $request->fullUrl()) @@ -69,6 +69,10 @@ protected function startTracing(Request $request): SpanInterface ->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $request->getProtocolVersion()) ->setAttribute(TraceAttributes::NETWORK_PEER_ADDRESS, $request->ip()) ->start(); + + $this->recordHeaders($span, $request); + + return $span; } protected function recordHttpResponseToSpan(SpanInterface $span, Response $response): void @@ -79,6 +83,8 @@ protected function recordHttpResponseToSpan(SpanInterface $span, Response $respo $span->setAttribute(TraceAttributes::HTTP_RESPONSE_BODY_SIZE, strlen($content)); } + $this->recordHeaders($span, $response); + if ($response->isSuccessful()) { $span->setStatus(StatusCode::STATUS_OK); } @@ -87,4 +93,26 @@ protected function recordHttpResponseToSpan(SpanInterface $span, Response $respo $span->setStatus(StatusCode::STATUS_ERROR); } } + + protected function recordHeaders(SpanInterface $span, Request|Response $http): SpanInterface + { + $prefix = match (true) { + $http instanceof Request => 'http.request.header.', + $http instanceof Response => 'http.response.header.', + }; + + foreach ($http->headers->all() as $key => $value) { + $key = strtolower($key); + + if (! HttpServerInstrumentation::headerIsAllowed($key)) { + continue; + } + + $value = HttpServerInstrumentation::headerIsSensitive($key) ? ['*****'] : $value; + + $span->setAttribute($prefix.$key, $value); + } + + return $span; + } } 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' => ['*****'], + ]); +}); diff --git a/tests/Instrumentation/HttpServerInstrumentationTest.php b/tests/Instrumentation/HttpServerInstrumentationTest.php index 68ff510..800a87a 100644 --- a/tests/Instrumentation/HttpServerInstrumentationTest.php +++ b/tests/Instrumentation/HttpServerInstrumentationTest.php @@ -8,7 +8,7 @@ use OpenTelemetry\API\Trace\StatusCode; beforeEach(function () { - Route::any('test-ok', fn () => Tracer::traceId()); + Route::any('test-ok', fn () => Tracer::traceId())->middleware(['web']); Route::any('test-exception', fn () => throw new Exception('test exception')); Route::any('test/{parameter}', fn () => Tracer::traceId()); }); @@ -140,3 +140,108 @@ expect($spans) ->toHaveCount(0); }); + +it('trace allowed request headers', function () { + app()->make(HttpServerInstrumentation::class)->register([ + 'allowed_headers' => [ + 'x-foo', + ], + ]); + + $response = $this->get('test-ok', [ + 'x-foo' => 'bar', + 'x-bar' => 'baz', + ]); + + $response->assertOk(); + + expect($response->content()) + ->not->toBeEmpty(); + + $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(HttpServerInstrumentation::class)->register([ + 'allowed_headers' => [ + 'content-type', + ], + ]); + + $response = $this->get('test-ok'); + + $response->assertOk(); + + expect($response->content()) + ->not->toBeEmpty(); + + $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(HttpServerInstrumentation::class)->register([ + 'allowed_headers' => [ + 'x-foo', + ], + 'sensitive_headers' => [ + 'x-foo', + ], + ]); + + $response = $this->get('test-ok', [ + 'x-foo' => 'bar', + ]); + + $response->assertOk(); + + expect($response->content()) + ->not->toBeEmpty(); + + $span = getRecordedSpans()[0]; + + expect($span->getAttributes()) + ->toMatchArray([ + 'http.request.header.x-foo' => ['*****'], + ]); +}); + +it('mark some headers as sensitive by default', function () { + app()->make(HttpServerInstrumentation::class)->register([ + 'allowed_headers' => [ + 'authorization', + 'cookie', + 'set-cookie', + ], + ]); + + $response = $this->get('test-ok', [ + 'authorization' => 'Bearer token', + 'cookie' => 'cookie', + ]); + + $response->assertOk(); + + expect($response->content()) + ->not->toBeEmpty(); + + $span = getRecordedSpans()[0]; + + expect($span->getAttributes()) + ->toMatchArray([ + 'http.request.header.authorization' => ['*****'], + 'http.request.header.cookie' => ['*****'], + 'http.response.header.set-cookie' => ['*****'], + ]); +});