From 292d7564c63008923883c6955cb3bf052b6e304a Mon Sep 17 00:00:00 2001 From: Brett McBride Date: Thu, 26 Jun 2025 10:51:07 +1000 Subject: [PATCH] Slim: adding stable http server metrics --- src/Instrumentation/Slim/composer.json | 2 +- src/Instrumentation/Slim/phpstan.neon.dist | 5 ++ .../Slim/src/PsrServerRequestMetrics.php | 64 ++++++++++++++ .../Slim/src/SlimInstrumentation.php | 8 +- .../Integration/SlimInstrumentationTest.php | 83 ++++++++++++++++--- 5 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 src/Instrumentation/Slim/src/PsrServerRequestMetrics.php diff --git a/src/Instrumentation/Slim/composer.json b/src/Instrumentation/Slim/composer.json index 8850d55ab..dc92bebfd 100644 --- a/src/Instrumentation/Slim/composer.json +++ b/src/Instrumentation/Slim/composer.json @@ -13,7 +13,7 @@ "ext-opentelemetry": "*", "ext-reflection": "*", "open-telemetry/api": "^1.0", - "open-telemetry/sem-conv": "^1.32", + "open-telemetry/sem-conv": ">= 1.32.1", "slim/slim": "^4" }, "require-dev": { diff --git a/src/Instrumentation/Slim/phpstan.neon.dist b/src/Instrumentation/Slim/phpstan.neon.dist index 5506b9b3d..2e56ce2cb 100644 --- a/src/Instrumentation/Slim/phpstan.neon.dist +++ b/src/Instrumentation/Slim/phpstan.neon.dist @@ -10,3 +10,8 @@ parameters: excludePaths: analyseAndScan: - tests/Unit + ignoreErrors: + - + message: "#Access to an undefined property .*#" + paths: + - tests/Integration/SlimInstrumentationTest.php diff --git a/src/Instrumentation/Slim/src/PsrServerRequestMetrics.php b/src/Instrumentation/Slim/src/PsrServerRequestMetrics.php new file mode 100644 index 000000000..dc78bff4c --- /dev/null +++ b/src/Instrumentation/Slim/src/PsrServerRequestMetrics.php @@ -0,0 +1,64 @@ +createHistogram(HttpMetrics::HTTP_SERVER_REQUEST_DURATION, 's', 'Duration of HTTP server requests'); + + $startTime = $request->getServerParams()['REQUEST_TIME_FLOAT'] ?? null; + if ($startTime === null) { + // without start time, we cannot measure the request duration + return; + } + + if (self::$serverRequestDuration->isEnabled()) { + $duration = (microtime(true) - (float) $startTime); + $attributes = [ + TraceAttributes::HTTP_REQUEST_METHOD => $request->getMethod(), + TraceAttributes::URL_SCHEME => $request->getUri()->getScheme(), + ]; + if ($response && $response->getStatusCode() >= 500) { + //@see https://opentelemetry.io/docs/specs/semconv/http/http-spans/#status + $attributes[TraceAttributes::EXCEPTION_TYPE] = (string) $response->getStatusCode(); + } elseif ($exception) { + $attributes[TraceAttributes::EXCEPTION_TYPE] = $exception::class; + } + if ($response) { + $attributes[TraceAttributes::HTTP_RESPONSE_BODY_SIZE] = (int) $response->getHeaderLine('Content-Length'); + $attributes[TraceAttributes::NETWORK_PROTOCOL_VERSION] = $response->getProtocolVersion(); + $attributes[TraceAttributes::HTTP_RESPONSE_STATUS_CODE] = $response->getStatusCode(); + } + /** @psalm-suppress PossiblyInvalidArgument */ + self::$serverRequestDuration->record($duration, $attributes); + } + } + + /** + * @internal + */ + public static function reset(): void + { + self::$serverRequestDuration = null; + } +} diff --git a/src/Instrumentation/Slim/src/SlimInstrumentation.php b/src/Instrumentation/Slim/src/SlimInstrumentation.php index 025ab8aec..4dce94525 100644 --- a/src/Instrumentation/Slim/src/SlimInstrumentation.php +++ b/src/Instrumentation/Slim/src/SlimInstrumentation.php @@ -78,7 +78,8 @@ public static function register(): void return [$request]; }, - post: static function (App $app, array $params, ?ResponseInterface $response, ?Throwable $exception): ?ResponseInterface { + post: static function (App $app, array $params, ?ResponseInterface $response, ?Throwable $exception) use ($instrumentation): ?ResponseInterface { + $request = ($params[0] instanceof ServerRequestInterface) ? $params[0] : null; $scope = Context::storage()->scope(); if (!$scope) { return $response; @@ -95,7 +96,7 @@ public static function register(): void } $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()); $span->setAttribute(TraceAttributes::NETWORK_PROTOCOL_VERSION, $response->getProtocolVersion()); - $span->setAttribute(TraceAttributes::HTTP_RESPONSE_BODY_SIZE, $response->getHeaderLine('Content-Length')); + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_BODY_SIZE, (int) $response->getHeaderLine('Content-Length')); if (self::$supportsResponsePropagation) { // Propagate server-timing header to response, if ServerTimingPropagator is present @@ -112,6 +113,9 @@ public static function register(): void } } $span->end(); + if ($request) { + PsrServerRequestMetrics::generate($instrumentation->meter(), $request, $response, $exception); + } return $response; } diff --git a/src/Instrumentation/Slim/tests/Integration/SlimInstrumentationTest.php b/src/Instrumentation/Slim/tests/Integration/SlimInstrumentationTest.php index 4d7f5c1ee..eb698819b 100644 --- a/src/Instrumentation/Slim/tests/Integration/SlimInstrumentationTest.php +++ b/src/Instrumentation/Slim/tests/Integration/SlimInstrumentationTest.php @@ -10,9 +10,15 @@ use Nyholm\Psr7\ServerRequest; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\Context\ScopeInterface; +use OpenTelemetry\Contrib\Instrumentation\Slim\PsrServerRequestMetrics; +use OpenTelemetry\SDK\Metrics\Data\HistogramDataPoint; +use OpenTelemetry\SDK\Metrics\MeterProvider; +use OpenTelemetry\SDK\Metrics\MetricExporter\InMemoryExporter as InMemoryMetricsExporter; +use OpenTelemetry\SDK\Metrics\MetricReader\ExportingReader; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; +use OpenTelemetry\SemConv\TraceAttributes; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -28,25 +34,32 @@ class SlimInstrumentationTest extends TestCase { private ScopeInterface $scope; - private ArrayObject $storage; + private ArrayObject $traces; + private ArrayObject $metrics; + private ExportingReader $reader; public function setUp(): void { - $this->storage = new ArrayObject(); + $this->traces = new ArrayObject(); + $this->metrics = new ArrayObject(); $tracerProvider = new TracerProvider( new SimpleSpanProcessor( - new InMemoryExporter($this->storage) + new InMemoryExporter($this->traces) ) ); + $this->reader = new ExportingReader(new InMemoryMetricsExporter($this->metrics)); + $meterProvider = MeterProvider::builder()->addReader($this->reader)->build(); $this->scope = Configurator::create() ->withTracerProvider($tracerProvider) + ->withMeterProvider($meterProvider) ->activate(); } public function tearDown(): void { $this->scope->detach(); + PsrServerRequestMetrics::reset(); } /** @@ -67,8 +80,8 @@ public function performRouting(ServerRequestInterface $request): ServerRequestIn $routingMiddleware ); $app->handle($request->withAttribute(RouteContext::ROUTE, $route)); - $this->assertCount(1, $this->storage); - $span = $this->storage->offsetGet(0); // @var ImmutableSpan $span + $this->assertCount(1, $this->traces); + $span = $this->traces->offsetGet(0); // @var ImmutableSpan $span $this->assertSame($expected, $span->getName()); } @@ -97,7 +110,7 @@ public function routeProvider(): array public function test_invocation_strategy(): void { $strategy = $this->createMockStrategy(); - $this->assertCount(0, $this->storage); + $this->assertCount(0, $this->traces); $strategy->__invoke( function (): ResponseInterface { return new Response(); @@ -106,7 +119,7 @@ function (): ResponseInterface { $this->createMock(ResponseInterface::class), [] ); - $this->assertCount(1, $this->storage); + $this->assertCount(1, $this->traces); } public function test_routing_exception(): void @@ -129,8 +142,8 @@ public function performRouting(ServerRequestInterface $request): ServerRequestIn } catch (\Exception $e) { $this->assertSame('routing failed', $e->getMessage()); } - $this->assertCount(1, $this->storage); - $span = $this->storage->offsetGet(0); // @var ImmutableSpan $span + $this->assertCount(1, $this->traces); + $span = $this->traces->offsetGet(0); // @var ImmutableSpan $span $this->assertSame('GET', $span->getName(), 'span name was not updated because routing failed'); } @@ -143,13 +156,63 @@ public function test_response_propagation(): void $request = (new ServerRequest('GET', 'https://example.com/foo')); $app = $this->createMockApp(new Response(200, ['X-Foo' => 'foo'])); $response = $app->handle($request); - $this->assertCount(1, $this->storage); + $this->assertCount(1, $this->traces); $this->assertArrayHasKey('X-Foo', $response->getHeaders()); $this->assertArrayHasKey('server-timing', $response->getHeaders()); $this->assertStringStartsWith('traceparent;desc=', $response->getHeaderLine('server-timing')); $this->assertArrayHasKey('traceresponse', $response->getHeaders()); } + /** + * @psalm-suppress NoInterfaceProperties + */ + public function test_generate_metrics(): void + { + $request = (new ServerRequest( + method: 'GET', + uri: 'http://example.com/foo', + serverParams: [ + 'REQUEST_TIME_FLOAT' => microtime(true), + ], + ))->withHeader('Content-Length', '999'); + $route = Mockery::mock(RouteInterface::class)->allows([ + 'getName' => 'route.name', + 'getPattern' => '/foo', + ]); + + $routingMiddleware = new class($this->createMock(RouteResolverInterface::class), $this->createMock(RouteParserInterface::class)) extends RoutingMiddleware { + public function performRouting(ServerRequestInterface $request): ServerRequestInterface + { + return $request; + } + }; + $app = $this->createMockApp( + (new Response())->withHeader('Content-Length', '999'), + $routingMiddleware + ); + //execute twice to generate 2 data point values + $app->handle($request->withAttribute(RouteContext::ROUTE, $route)); + $app->handle($request->withAttribute(RouteContext::ROUTE, $route)); + $this->assertCount(0, $this->metrics); + $this->reader->collect(); + $this->assertCount(1, $this->metrics); + $metric = $this->metrics->offsetGet(0); + assert($metric instanceof \OpenTelemetry\SDK\Metrics\Data\Metric); + $this->assertSame('http.server.request.duration', $metric->name); + $this->assertCount(1, $metric->data->dataPoints); + $dataPoint = $metric->data->dataPoints[0]; + assert($dataPoint instanceof HistogramDataPoint); + $this->assertSame(2, $dataPoint->count); + $attributes = $dataPoint->attributes->toArray(); + $this->assertEqualsCanonicalizing([ + TraceAttributes::HTTP_REQUEST_METHOD => 'GET', + TraceAttributes::URL_SCHEME => 'http', + TraceAttributes::HTTP_RESPONSE_BODY_SIZE => 999, + TraceAttributes::NETWORK_PROTOCOL_VERSION => '1.1', + TraceAttributes::HTTP_RESPONSE_STATUS_CODE => 200, + ], $attributes); + } + public function createMockStrategy(): InvocationStrategyInterface { return new class() implements InvocationStrategyInterface {