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' => ['*****'],
+ ]);
+});