diff --git a/README.md b/README.md index 9399068..b23376d 100644 --- a/README.md +++ b/README.md @@ -41,5 +41,11 @@ The extension can be disabled via runtime configuration: OTEL_PHP_DISABLED_INSTRUMENTATIONS=drupal ``` +### Query Explain Threshold Configuration +The EXPLAIN threshold feature allows you to set a minimum duration threshold for capturing EXPLAIN results in your traces. When `OTEL_PHP_DRUPAL_EXPLAIN_QUERIES` is enabled, you can use `OTEL_PHP_DRUPAL_EXPLAIN_THRESHOLD` to specify the minimum query duration (in milliseconds) that should trigger EXPLAIN capture. +For example: +``` +OTEL_PHP_DRUPAL_EXPLAIN_THRESHOLD=100 # Capture EXPLAIN for queries taking longer than 100ms +``` diff --git a/_register.php b/_register.php index fbbdd23..c96d7b1 100644 --- a/_register.php +++ b/_register.php @@ -2,10 +2,17 @@ declare(strict_types=1); +use OpenTelemetry\Contrib\Instrumentation\Drupal\CacheBackendInstrumentation; use OpenTelemetry\Contrib\Instrumentation\Drupal\DatabaseInstrumentation; +use OpenTelemetry\Contrib\Instrumentation\Drupal\DrupalAutoRootSpan; use OpenTelemetry\Contrib\Instrumentation\Drupal\DrupalKernelInstrumentation; +use OpenTelemetry\Contrib\Instrumentation\Drupal\EntityInstrumentation; +use OpenTelemetry\Contrib\Instrumentation\Drupal\InstrumentModules; use OpenTelemetry\Contrib\Instrumentation\Drupal\HttpClientCallInstrumentation; use OpenTelemetry\Contrib\Instrumentation\Drupal\HttpClientRequestInstrumentation; +use OpenTelemetry\Contrib\Instrumentation\Drupal\ViewsInstrumentation; +use OpenTelemetry\Context\Context; +use OpenTelemetry\Context\ContextStorage; use OpenTelemetry\SDK\Sdk; if (class_exists(Sdk::class) && Sdk::isInstrumentationDisabled(DrupalKernelInstrumentation::NAME) === TRUE) { @@ -18,14 +25,25 @@ return; } +// Force disable the FiberBoundContextStorage because of the conflict +// with Drupal Renderer service. +// @see https://www.drupal.org/project/opentelemetry/issues/3488173 +// @todo Make a proper fix to work well with the FiberBoundContextStorage. +$contextStorage = new ContextStorage(); +Context::setStorage($contextStorage); + try { + CacheBackendInstrumentation::register(); + DrupalAutoRootSpan::register(); DrupalKernelInstrumentation::register(); DatabaseInstrumentation::register(); + EntityInstrumentation::register(); HttpClientRequestInstrumentation::register(); HttpClientCallInstrumentation::register(); + InstrumentModules::registerModule(ViewsInstrumentation::class); } catch (Throwable $exception) { - \Drupal::logger("drupalInstrumentation")->error($exception->getMessage()); - - return; + throw $exception; + //\Drupal::logger("drupalInstrumentation")->error($exception->getMessage()); + //return; } diff --git a/composer.json b/composer.json index 129bdf9..2a4bc6c 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "open-telemetry/sdk": "^1", "open-telemetry/sem-conv": "^1.23", "symfony/http-client": "6.4.x-dev", - "nyholm/psr7": "^1.8@dev" + "nyholm/psr7": "^1.8@dev", + "performance-x/opentelemetry-php-instrumentation-trait": "^1.1.1" }, "license": "Apache-2.0", "minimum-stability": "dev", diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..b47d665 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,57 @@ + + + Coding standards for OpenTelemetry Instrumentation Trait + + + + + + + + + + + + + + + + ./src/ + ./tests/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CacheBackendInstrumentation.php b/src/CacheBackendInstrumentation.php new file mode 100644 index 0000000..9121bce --- /dev/null +++ b/src/CacheBackendInstrumentation.php @@ -0,0 +1,116 @@ +helperHook( + methodName: '__construct', + preHandler: function ($spanBuilder, $object, array $params, string $class) { + // Get actual constructor parameters at runtime. + $reflection = new \ReflectionClass($class); + $constructor = $reflection->getConstructor(); + if (!$constructor) { + return; + } + + foreach ($constructor->getParameters() as $position => $parameter) { + if ($parameter->getName() === 'bin' && isset($params[$position])) { + $bin = $params[$position]; + $spanBuilder->setAttribute($this->getAttributeName('bin'), $bin); + static::$cacheBins[spl_object_id($object)] = $bin; + break; + } + } + } + ); + + // Define operations. + $operations = [ + 'get' => [ + 'params' => ['cid' => 'cache_key'], + 'postHandler' => function ($span, $object, array $namedParams, $returnValue) { + $span->setAttribute($this->getAttributeName('hit'), $returnValue !== FALSE); + $span->setAttribute($this->getAttributeName('valid'), !empty($returnValue->valid)); + }, + ], + 'getMultiple' => [ + 'params' => ['cids' => 'cache_keys'], + 'postHandler' => function ($span, $object, array $namedParams, $returnValue) { + $missCount = count($namedParams['cids']); + $hitCount = count($returnValue); + $total = $missCount + $hitCount; + + $span->setAttribute($this->getAttributeName('hit_count'), $hitCount); + $span->setAttribute($this->getAttributeName('miss_count'), $missCount); + $span->setAttribute($this->getAttributeName('hit_ratio'), $total > 0 ? $hitCount / $total : 0); + }, + ], + 'set' => [ + 'params' => ['cid' => 'cache_key', 'tags'], + 'preHandler' => function ($spanBuilder, $object, array $namedParams) { + $spanBuilder->setAttribute($this->getAttributeName('ttl'), isset($namedParams['expire']) ? $namedParams['expire'] : -1); + $spanBuilder->setAttribute($this->getAttributeName('tag_count'), count($namedParams['tags'] ?? [])); + }, + ], + 'deleteMultiple' => [ + 'params' => ['cids' => 'cache_keys'], + 'preHandler' => function ($spanBuilder, $object, array $namedParams) { + $spanBuilder->setAttribute($this->getAttributeName('delete_count'), count($namedParams['cids'])); + }, + ], + 'invalidateMultiple' => [ + 'params' => ['cids' => 'cache_keys'], + 'preHandler' => function ($spanBuilder, $object, array $namedParams) { + $spanBuilder->setAttribute($this->getAttributeName('invalidate_count'), count($namedParams['cids'])); + }, + ], + 'delete' => [ + 'params' => ['cid' => 'cache_key'], + ], + 'invalidate' => [ + 'params' => ['cid' => 'cache_key'], + ], + 'deleteAll' => [], + 'invalidateAll' => [], + 'removeBin' => [], + ]; + + // Common handler for adding bin information. + $binHandler = function ($spanBuilder, $object) { + $objectId = spl_object_id($object); + $bin = static::$cacheBins[$objectId] ?? 'unknown'; + $spanBuilder->setAttribute($this->getAttributeName('bin'), $bin); + }; + + // Register all operations with common bin handling. + $this->registerOperations( + operations: $operations, + commonPreHandler: $binHandler + ); + } + +} diff --git a/src/DatabaseInstrumentation.php b/src/DatabaseInstrumentation.php index db970a2..0ed9caa 100644 --- a/src/DatabaseInstrumentation.php +++ b/src/DatabaseInstrumentation.php @@ -3,57 +3,113 @@ namespace OpenTelemetry\Contrib\Instrumentation\Drupal; use Drupal\Core\Database\Connection; -use OpenTelemetry\API\Instrumentation\CachedInstrumentation; use OpenTelemetry\API\Trace\SpanKind; -use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\TraceAttributes; -use function \OpenTelemetry\Instrumentation\hook; +/** + * Provides OpenTelemetry instrumentation for Drupal database operations. + */ class DatabaseInstrumentation extends InstrumentationBase { - + protected const CLASSNAME = Connection::class; public const DB_VARIABLES = 'db.variables'; + /** + * + */ public static function register(): void { + static::create( + name: 'io.opentelemetry.contrib.php.drupal', + prefix: 'drupal.database', + className: static::CLASSNAME + ); + } - $instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.drupal'); + protected bool $inQuery = FALSE; - hook( - Connection::class, - 'query', - static::preClosure($instrumentation), - static::postClosure() - ); + protected bool $explainQueries = FALSE; + /** + * Minimum duration threshold (in seconds) for capturing EXPLAIN results. + * + * When explainQueries is enabled, only queries that take longer than this + * threshold will have their EXPLAIN results captured in the span. This helps + * prevent unnecessary overhead for fast queries. + * + * Configure via OTEL_PHP_DRUPAL_EXPLAIN_THRESHOLD environment variable. + * Value should be in milliseconds, e.g.: + * OTEL_PHP_DRUPAL_EXPLAIN_THRESHOLD=100 // For 100ms threshold + * + * @var float Time in seconds (e.g., 0.1 for 100ms) + */ + protected float $explainQueriesThreshold = 0.0; + /** + * + */ + protected function registerInstrumentation(): void { + + $this->explainQueries = getenv('OTEL_PHP_DRUPAL_EXPLAIN_QUERIES') ? TRUE : FALSE; + $this->explainQueriesThreshold = (float) (getenv('OTEL_PHP_DRUPAL_EXPLAIN_THRESHOLD') ?? 0) / 1000; + + $operations = [ + 'query' => [ + 'preHandler' => function ($spanBuilder, Connection $connection, array $namedParams) { + if ($this->inQuery) { + return; + } + + $spanBuilder + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::DB_SYSTEM, 'mariadb') + ->setAttribute(TraceAttributes::DB_STATEMENT, $namedParams['query']); + + if (isset($namedParams['args']) === TRUE) { + $cleanVariables = array_map( + static fn ($value) => is_array($value) ? json_encode($value) : (string) $value, + $namedParams['args'] + ); + $spanBuilder->setAttribute(self::DB_VARIABLES, $cleanVariables); + } + + if ($this->explainQueries) { + $this->captureQueryExplain( + $spanBuilder, + $connection, + $namedParams['query'], + $namedParams['args'] ?? [] + ); + } + }, + ], + ]; + + $this->registerOperations($operations); } /** - * @param \OpenTelemetry\API\Instrumentation\CachedInstrumentation $instrumentation + * Captures and adds EXPLAIN query results as attributes to the span builder. * - * @return \Closure + * This function executes an EXPLAIN query and adds the results as attributes + * to the span builder if the query execution time exceeds the configured threshold. + * + * @param mixed $spanBuilder The span builder instance + * @param \Drupal\Core\Database\Connection $connection Database connection + * @param string $query The SQL query to explain + * @param array $args Query arguments */ - public static function preClosure(CachedInstrumentation $instrumentation): \Closure { - return static function (Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $parent = Context::getCurrent(); - - /** @var \OpenTelemetry\API\Trace\SpanBuilderInterface $span */ - $span = $instrumentation->tracer()->spanBuilder('database::query') - ->setParent($parent) - ->setSpanKind(SpanKind::KIND_CLIENT) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) - ->setAttribute(TraceAttributes::DB_SYSTEM, 'mariadb') - ->setAttribute(TraceAttributes::DB_STATEMENT, $params[0]); - if (isset($params[1]) === TRUE) { - $cleanVariables = array_map(static fn ($value) => is_array($value) ? json_encode($value) : (string) $value, $params[1]); - $span->setAttribute(self::DB_VARIABLES, $cleanVariables); - } - $span = $span->startSpan(); + protected function captureQueryExplain($spanBuilder, Connection $connection, string $query, array $args): void { + $this->inQuery = TRUE; + try { + $start = microtime(true); + $explain_results = $connection->query('EXPLAIN ' . $query, $args)->fetchAll(); + $duration = microtime(true) - $start; - Context::storage() - ->attach($span->storeInContext($parent)); - }; + if ($duration >= $this->explainQueriesThreshold) { + $spanBuilder->setAttribute($this->getAttributeName('explain'), json_encode($explain_results)); + } + } + catch (\Exception $e) { + } + $this->inQuery = FALSE; } } diff --git a/src/DrupalAutoRootSpan.php b/src/DrupalAutoRootSpan.php new file mode 100644 index 0000000..c2e687f --- /dev/null +++ b/src/DrupalAutoRootSpan.php @@ -0,0 +1,43 @@ +helperHook( + methodName: 'handle', + preHandler: function ($spanBuilder, DrupalKernel $kernel, array $params) { + $request = ($params[0] instanceof Request) ? $params[0] : NULL; + $spanName = \sprintf('%s %s', $request?->getScheme() ?? 'HTTP', $request?->getMethod() ?? 'unknown'); + + $spanBuilder->setSpanKind(SpanKind::KIND_SERVER); + + if ($request) { + $parent = Globals::propagator()->extract($request, RequestPropagationGetter::instance()); + $spanBuilder->setParent($parent); + $spanBuilder->setAttribute(TraceAttributes::HTTP_URL, $request->getUri()) + ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $request->getMethod()) + ->setAttribute(TraceAttributes::HTTP_REQUEST_CONTENT_LENGTH, $request->headers->get('Content-Length')) + ->setAttribute(TraceAttributes::HTTP_SCHEME, $request->getScheme()); + $request->attributes->set(SpanInterface::class, $spanBuilder); + } - hook( - DrupalKernel::class, - 'handle', - static::preClosure($instrumentation), - static::postClosure() - ); + return [$request]; + }, + postHandler: function ($span, DrupalKernel $kernel, array $params, ?Response $response) { + $request = ($params[0] instanceof Request) ? $params[0] : NULL; + + if ($request !== NULL) { + self::setSpanName($kernel, $request, $span); + $routeParameters = $request->attributes->get('_raw_variables'); + if ($routeParameters !== NULL && $routeParameters->count() > 0) { + $span->setAttribute(self::HTTP_ROUTE_PARAMETERS, json_encode($routeParameters->all())); + } + } + + if ($response === NULL) { + return; + } + + if ($response->getStatusCode() >= Response::HTTP_INTERNAL_SERVER_ERROR) { + $span->setStatus(StatusCode::STATUS_ERROR); + } + + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()); + $span->setAttribute(TraceAttributes::NETWORK_PROTOCOL_NAME, $response->getProtocolVersion()); + + if (is_string($response->getContent())) { + $contentLength = \strlen($response->getContent()); + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_CONTENT_LENGTH, $contentLength); + } + } + ); } /** @@ -85,93 +136,4 @@ private static function getRoutes(DrupalKernel $kernel, Request $request): \Arra return $routeCollection->getIterator(); } - /** - * @param \OpenTelemetry\API\Instrumentation\CachedInstrumentation $instrumentation - * - * @return \Closure - */ - public static function preClosure(CachedInstrumentation $instrumentation): \Closure { - return static function (DrupalKernel $kernel, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $request = ($params[0] instanceof Request) ? $params[0] : NULL; - /** @psalm-suppress ArgumentTypeCoercion */ - $spanName = \sprintf('%s %s', $request?->getScheme() ?? 'HTTP', $request?->getMethod() ?? 'unknown'); - $builder = $instrumentation - ->tracer() - ->spanBuilder($spanName) - ->setSpanKind(SpanKind::KIND_SERVER) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); - - $parent = Context::getCurrent(); - if ($request) { - $parent = Globals::propagator() - ->extract($request, RequestPropagationGetter::instance()); - $span = $builder - ->setParent($parent) - ->setAttribute(TraceAttributes::HTTP_URL, $request->getUri()) - ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $request->getMethod()) - ->setAttribute(TraceAttributes::HTTP_REQUEST_CONTENT_LENGTH, $request->headers->get('Content-Length')) - ->setAttribute(TraceAttributes::HTTP_SCHEME, $request->getScheme()) - ->startSpan(); - $request->attributes->set(SpanInterface::class, $span); - } - else { - $span = $builder->startSpan(); - } - Context::storage()->attach($span->storeInContext($parent)); - - return [$request]; - }; - } - - public static function postClosure(): \Closure { - return static function (DrupalKernel $kernel, array $params, ?Response $response, ?Throwable $exception) { - $scope = Context::storage()->scope(); - if (!$scope) { - return; - } - $scope->detach(); - $span = Span::fromContext($scope->context()); - - $request = ($params[0] instanceof Request) ? $params[0] : NULL; - if (NULL !== $request) { - self::setSpanName($kernel, $request, $span); - - $routeParameters = $request->attributes->get('_raw_variables'); - if ($routeParameters !== NULL && $routeParameters->count() > 0) { - $span->setAttribute(self::HTTP_ROUTE_PARAMETERS, json_encode($routeParameters->all())); - } - } - - if (NULL !== $exception) { - $span->recordException($exception, [ - TraceAttributes::EXCEPTION_ESCAPED => FALSE, - ]); - $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); - } - - if (NULL === $response) { - $span->end(); - - return; - } - - if ($response->getStatusCode() >= Response::HTTP_INTERNAL_SERVER_ERROR) { - $span->setStatus(StatusCode::STATUS_ERROR); - } - - $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()); - $span->setAttribute(TraceAttributes::NETWORK_PROTOCOL_NAME, $response->getProtocolVersion()); - /** @psalm-suppress PossiblyFalseArgument */ - if (is_string($response->getContent())) { - $contentLength = \strlen($response->getContent()); - $span->setAttribute(TraceAttributes::HTTP_RESPONSE_CONTENT_LENGTH, $contentLength); - } - - $span->end(); - }; - } - } diff --git a/src/EntityInstrumentation.php b/src/EntityInstrumentation.php new file mode 100644 index 0000000..3e1af2d --- /dev/null +++ b/src/EntityInstrumentation.php @@ -0,0 +1,73 @@ + [ + 'preHandler' => function ($spanBuilder, $storage, array $namedParams) { + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $namedParams['entity']; + $span = sprintf('Entity save (%s:%s)', $entity->getEntityTypeId(), $entity->isNew() ? 'new' : $entity->id()); + + $spanBuilder->setAttribute(static::UPDATE_NAME, $span) + ->setAttribute('entity.type', $entity->getEntityTypeId()) + ->setAttribute('entity.is_new', $entity->isNew()) + ->setAttribute('entity.id', $entity->id()) + ->setAttribute('entity.label', $entity->label()) + ->setAttribute('entity.bundle', $entity->bundle()); + }, + ], + 'delete' => [ + 'preHandler' => function ($spanBuilder, $storage, array $namedParams) { + /** @var \Drupal\Core\Entity\EntityInterface[] $entities */ + $entities = $namedParams['entities']; + if (count($entities) === 0) { + return; + } + + $spanBuilder->setAttribute(static::UPDATE_NAME, 'Entity delete'); + + $entitiesGrouped = array_reduce($entities, function (array $carry, EntityInterface $entity) { + $carry[$entity->getEntityTypeId()][] = $entity->id(); + return $carry; + }, []); + + $entitiesTag = []; + foreach ($entitiesGrouped as $entityTypeId => $entityIds) { + $entitiesTag[] = sprintf('%s: %s', $entityTypeId, implode(', ', $entityIds)); + } + $spanBuilder->setAttribute('entities.deleted', implode('; ', $entitiesTag)); + }, + ], + ]; + + $this->registerOperations($operations); + } + +} diff --git a/src/HttpClientCallInstrumentation.php b/src/HttpClientCallInstrumentation.php index f2b22f3..6048060 100644 --- a/src/HttpClientCallInstrumentation.php +++ b/src/HttpClientCallInstrumentation.php @@ -4,80 +4,52 @@ use GuzzleHttp\Client; use GuzzleHttp\Psr7\Response as GuzzleResponse; -use OpenTelemetry\API\Instrumentation\CachedInstrumentation; -use OpenTelemetry\API\Trace\Span; -use OpenTelemetry\API\Trace\SpanKind; -use OpenTelemetry\API\Trace\StatusCode; -use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\TraceAttributes; -use Throwable; -use function OpenTelemetry\Instrumentation\hook; - -class HttpClientCallInstrumentation extends InstrumentationBase{ - - public static function register(): void { - - $instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.drupal'); +use OpenTelemetry\API\Trace\SpanKind; - hook( - Client::class, - '__call', - static::preClosure($instrumentation), - static::postClosure() - ); - } +/** + * + */ +class HttpClientCallInstrumentation extends InstrumentationBase { + protected const CLASSNAME = Client::class; /** - * @param \OpenTelemetry\API\Instrumentation\CachedInstrumentation $instrumentation * - * @return \Closure */ - public static function preClosure(CachedInstrumentation $instrumentation): \Closure { - return static function (Client $client, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $url = is_array($params[1]) ? $params[1][0] ?? NULL : $params[1]; - - $host = filter_var($url, FILTER_VALIDATE_URL) - ? parse_url($url, PHP_URL_HOST) - : ($url ?? 'unknown-http-client'); - - $span = $instrumentation->tracer()->spanBuilder($host) - ->setSpanKind(SpanKind::KIND_CLIENT) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) - ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $params[0]) - ->setAttribute(TraceAttributes::URL_FULL, $params[1]) - ->setAttribute(TraceAttributes::SERVER_ADDRESS, $host) - ->startSpan(); - Context::storage() - ->attach($span->storeInContext(Context::getCurrent())); - }; + public static function register(): void { + static::create( + name: 'io.opentelemetry.contrib.php.drupal', + prefix: 'drupal.http_client', + className: static::CLASSNAME + ); } /** - * @return \Closure + * */ - public static function postClosure(): \Closure { - return static function (Client $client, array $params, $response, ?Throwable $exception) { - $scope = Context::storage()->scope(); - if (!$scope) { - return; - } - $scope->detach(); - $span = Span::fromContext($scope->context()); - - if ($response instanceof GuzzleResponse) { - $span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $response->getStatusCode()); + protected function registerInstrumentation(): void { + $this->helperHook( + methodName: '__call', + preHandler: function ($spanBuilder, $object, array $params) { + $url = is_array($params[1]) ? $params[1][0] ?? NULL : $params[1]; + + $host = filter_var($url, FILTER_VALIDATE_URL) + ? parse_url($url, PHP_URL_HOST) + : ($url ?? 'unknown-http-client'); + + $spanBuilder + ->setAttribute(static::UPDATE_NAME, $host) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $params[0]) + ->setAttribute(TraceAttributes::URL_FULL, $params[1]) + ->setAttribute(TraceAttributes::SERVER_ADDRESS, $host); + }, + postHandler: function ($span, $object, array $params, $response) { + if ($response instanceof GuzzleResponse) { + $span->setAttribute(TraceAttributes::HTTP_STATUS_CODE, $response->getStatusCode()); + } } - - if ($exception) { - $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => TRUE]); - $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); - } - - $span->end(); - }; + ); } } diff --git a/src/HttpClientRequestInstrumentation.php b/src/HttpClientRequestInstrumentation.php index 9649de9..da84c00 100644 --- a/src/HttpClientRequestInstrumentation.php +++ b/src/HttpClientRequestInstrumentation.php @@ -5,83 +5,57 @@ use GuzzleHttp\Client; use GuzzleHttp\Promise\Promise; use GuzzleHttp\Psr7\Response as GuzzleResponse; -use OpenTelemetry\API\Instrumentation\CachedInstrumentation; -use OpenTelemetry\API\Trace\Span; use OpenTelemetry\API\Trace\SpanKind; -use OpenTelemetry\API\Trace\StatusCode; -use OpenTelemetry\Context\Context; use OpenTelemetry\SemConv\TraceAttributes; -use function \OpenTelemetry\Instrumentation\hook; -class HttpClientRequestInstrumentation extends InstrumentationBase{ - - public static function register(): void { - - $instrumentation = new CachedInstrumentation('io.opentelemetry.contrib.php.drupal'); - - hook( - Client::class, - 'requestAsync', - static::preClosure($instrumentation), - static::postClosure() - ); - - } - - /** - * @param \OpenTelemetry\API\Instrumentation\CachedInstrumentation $instrumentation - * - * @return \Closure - */ - public static function preClosure(CachedInstrumentation $instrumentation): \Closure { - return static function (Client $client, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $host = $params[1]; - if (filter_var($params[1], FILTER_VALIDATE_URL)) { - $host = parse_url($params[1], PHP_URL_HOST); - } - - $span = $instrumentation->tracer()->spanBuilder($host) - ->setSpanKind(SpanKind::KIND_CLIENT) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) - ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $params[0]) - ->setAttribute(TraceAttributes::URL_FULL, $params[1]) - ->setAttribute(TraceAttributes::SERVER_ADDRESS, $host) - ->startSpan(); - Context::storage() - ->attach($span->storeInContext(Context::getCurrent())); - }; - } - - /** - * @return \Closure - */ - public static function postClosure(): \Closure { - return static function (Client $client, array $params, $response, ?Throwable $exception) { - $scope = Context::storage()->scope(); - if (!$scope) { - return; - } - $scope->detach(); - $span = Span::fromContext($scope->context()); - - if ($response instanceof Promise) { - $response = $response->wait(); - } - - if ($response instanceof GuzzleResponse) { - $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()); - } - - if ($exception) { - $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => TRUE]); - $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); - } - - $span->end(); - }; - } +/** + * + */ +class HttpClientRequestInstrumentation extends InstrumentationBase { + protected const CLASSNAME = Client::class; + + /** + * + */ + public static function register(): void { + static::create( + name: 'io.opentelemetry.contrib.php.drupal', + prefix: 'http.client', + className: static::CLASSNAME + ); + } + + /** + * + */ + protected function registerInstrumentation(): void { + $operations = [ + 'requestAsync' => [ + 'preHandler' => function ($spanBuilder, Client $client, array $namedParams) { + $host = $namedParams['uri']; + if (filter_var($namedParams['uri'], FILTER_VALIDATE_URL)) { + $host = parse_url($namedParams['uri'], PHP_URL_HOST); + } + + $spanBuilder->setAttribute(static::UPDATE_NAME, $host) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::HTTP_REQUEST_METHOD, $namedParams['method']) + ->setAttribute(TraceAttributes::URL_FULL, $namedParams['uri']) + ->setAttribute(TraceAttributes::SERVER_ADDRESS, $host); + }, + 'postHandler' => function ($span, Client $client, array $namedParams, $response) { + if ($response instanceof Promise) { + $response = $response->wait(); + } + + if ($response instanceof GuzzleResponse) { + $span->setAttribute(TraceAttributes::HTTP_RESPONSE_STATUS_CODE, $response->getStatusCode()); + } + }, + ], + ]; + + $this->registerOperations($operations); + } } diff --git a/src/InstrumentModules.php b/src/InstrumentModules.php new file mode 100644 index 0000000..6a9ee35 --- /dev/null +++ b/src/InstrumentModules.php @@ -0,0 +1,75 @@ +helperHook( + methodName: 'initializeContainer', + postHandler: function ($span, $object) { + // Register all collected module instrumentations. + foreach (static::$moduleInstrumentations as $instrumentationClass) { + try { + $instrumentationClass::register(); + } + catch (\Throwable $e) { + // Optionally log the error or handle it as needed. + error_log(sprintf( + 'Failed to register module instrumentation %s: %s', + $instrumentationClass, + $e->getMessage() + )); + } + } + } + ); + } + +} diff --git a/src/InstrumentationBase.php b/src/InstrumentationBase.php index cb88710..a656fc2 100644 --- a/src/InstrumentationBase.php +++ b/src/InstrumentationBase.php @@ -2,53 +2,163 @@ namespace OpenTelemetry\Contrib\Instrumentation\Drupal; -use Drupal\redis\Cache\CacheBase; -use OpenTelemetry\API\Instrumentation\CachedInstrumentation; -use OpenTelemetry\API\Trace\Span; -use OpenTelemetry\API\Trace\SpanInterface; -use OpenTelemetry\API\Trace\SpanKind; -use OpenTelemetry\API\Trace\StatusCode; -use OpenTelemetry\Context\Context; -use OpenTelemetry\SemConv\TraceAttributes; -use Throwable; +use PerformanceX\OpenTelemetry\Instrumentation\InstrumentationTrait; +/** + * + */ abstract class InstrumentationBase { + use InstrumentationTrait { + create as protected createClass; + } - public const NAME = 'drupal'; - - abstract public static function register(): void; - - public static function preClosure(CachedInstrumentation $instrumentation): \Closure { - return static function (CacheBase $cacheBase, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { - $span = $instrumentation->tracer()->spanBuilder('cache_backend::get') - ->setSpanKind(SpanKind::KIND_CLIENT) - ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) - ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) - ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) - ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) - ->setAttribute(TraceAttributes::DB_SYSTEM, 'redis') - ->setAttribute('cache.key', $params[0]) - ->startSpan(); - Context::storage() - ->attach($span->storeInContext(Context::getCurrent())); - }; + /** + * Creates and initializes the instrumentation. + */ + protected static function create(...$args): static { + $instance = static::createClass(...$args); + $instance->registerInstrumentation(); + return $instance; } - public static function postClosure(): \Closure { - return static function (mixed $base, array $params, $returnValue, ?Throwable $exception) { - $scope = Context::storage()->scope(); - if (!$scope) { - return; - } - $scope->detach(); - $span = Span::fromContext($scope->context()); - if ($exception) { - $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => TRUE]); - $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + /** + * Register the specific instrumentation logic. + */ + abstract protected function registerInstrumentation(): void; + + /** + * Resolves parameter information for a given method using reflection. + * + * This helper function creates a mapping between parameter names and their positions + * in the method signature. This is crucial for converting positional arguments + * to named parameters later in the instrumentation process. + * + * @param string $className + * The fully qualified class name. + * @param string $methodName + * The method name to analyze. + * + * @return array Map of parameter names to their positions + * Example: ['cid' => 0, 'data' => 1, 'expire' => 2]. + * + * @throws \ReflectionException If the method does not exist + */ + protected static function resolveMethodParameters(string $className, string $methodName): array { + $reflMethod = new \ReflectionMethod($className, $methodName); + $parameterPositions = []; + foreach ($reflMethod->getParameters() as $position => $parameter) { + $parameterPositions[$parameter->getName()] = $position; + } + return $parameterPositions; + } + + /** + * Creates a converter function that transforms positional parameters to named parameters. + * + * This helper creates a closure that can convert an array of positional arguments + * into an associative array that maintains both numeric indexes and named parameters. + * This allows for flexible parameter access either by position or name. + * + * Example: + * Input: [0 => 'value1', 1 => 'value2'] + * Parameter positions: ['name1' => 0, 'name2' => 1] + * Output: [0 => 'value1', 1 => 'value2', 'name1' => 'value1', 'name2' => 'value2'] + * + * @param array $parameterPositions + * Parameter name to position mapping. + * + * @return callable Function that converts positional to named parameters while preserving numeric indexes + * Signature: function(array $params): array. + */ + protected static function createNamedParamsConverter(array $parameterPositions): callable { + return function (array $params) use ($parameterPositions): array { + $namedParams = $params; + foreach ($parameterPositions as $name => $position) { + if (isset($params[$position])) { + $namedParams[$name] = $params[$position]; + } } + return $namedParams; + }; + } - $span->end(); + /** + * Creates a wrapped handler that combines common and specific handling logic. + * + * @param callable $converter + * Parameter converter function. + * @param callable|null $handler + * Specific handling logic. + * @param callable $allHandler + * Common handling logic. + * + * @return callable Combined handler. + */ + protected static function wrapHandler(callable $converter, ?callable $handler, callable $allHandler): callable { + return function (...$args) use ($converter, $handler, $allHandler) { + $params = $converter($args[2]); + + $allHandler(...$args); + if ($handler) { + $args[2] = $params; + $handler(...$args); + } }; } + /** + * Registers multiple operations with common handling logic. + * + * @param array $operations + * Array of operation configurations + * [ + * 'methodName' => [ + * 'params' => ['paramName' => 'attributeName'], + * 'preHandler' => callable, + * 'postHandler' => callable, + * 'returnValue' => string|null + * ] + * ]. + * @param callable|null $commonPreHandler + * Handler applied before all operations. + * @param callable|null $commonPostHandler + * Handler applied after all operations. + * + * @return this + */ + protected function registerOperations( + array $operations, + ?callable $commonPreHandler = NULL, + ?callable $commonPostHandler = NULL + ): self { + foreach ($operations as $method => $config) { + $parameterPositions = static::resolveMethodParameters($this->className, $method); + $converter = static::createNamedParamsConverter($parameterPositions); + + $preHandler = isset($config['preHandler']) || $commonPreHandler ? + static::wrapHandler( + $converter, + $config['preHandler'] ?? NULL, + $commonPreHandler ?? function () {} + ) : NULL; + + $postHandler = isset($config['postHandler']) || $commonPostHandler ? + static::wrapHandler( + $converter, + $config['postHandler'] ?? NULL, + $commonPostHandler ?? function () {} + ) : NULL; + + $this->helperHook( + methodName: $method, + paramMap: $config['params'] ?? [], + returnValueKey: $config['returnValue'] ?? NULL, + preHandler: $preHandler, + postHandler: $postHandler + ); + } + + return $this; + } + } diff --git a/src/RequestPropagationGetter.php b/src/RequestPropagationGetter.php index c5325d5..f94cfdb 100644 --- a/src/RequestPropagationGetter.php +++ b/src/RequestPropagationGetter.php @@ -4,7 +4,6 @@ namespace OpenTelemetry\Contrib\Instrumentation\Drupal; -use InvalidArgumentException; use OpenTelemetry\Context\Propagation\PropagationGetterInterface; use Symfony\Component\HttpFoundation\Request; @@ -13,6 +12,9 @@ */ final class RequestPropagationGetter implements PropagationGetterInterface { + /** + * + */ public static function instance(): self { static $instance; @@ -27,7 +29,7 @@ public function keys($carrier): array { return $carrier->headers->keys(); } - throw new InvalidArgumentException( + throw new \InvalidArgumentException( sprintf( 'Unsupported carrier type: %s.', is_object($carrier) ? get_class($carrier) : gettype($carrier), @@ -35,12 +37,15 @@ public function keys($carrier): array { ); } + /** + * + */ public function get($carrier, string $key) : ?string { if ($this->isSupportedCarrier($carrier)) { return $carrier->headers->get($key); } - throw new InvalidArgumentException( + throw new \InvalidArgumentException( sprintf( 'Unsupported carrier type: %s. Unable to get value associated with key:%s', is_object($carrier) ? get_class($carrier) : gettype($carrier), @@ -49,6 +54,9 @@ public function get($carrier, string $key) : ?string { ); } + /** + * + */ private function isSupportedCarrier($carrier): bool { return $carrier instanceof Request; } diff --git a/src/ViewsInstrumentation.php b/src/ViewsInstrumentation.php new file mode 100644 index 0000000..2fbf661 --- /dev/null +++ b/src/ViewsInstrumentation.php @@ -0,0 +1,55 @@ + [ + 'preHandler' => function ($spanBuilder, ViewExecutable $executable, array $namedParams) { + $display_id = $namedParams['display_id'] ?? NULL; + $name = NULL; + + if ($executable->storage) { + $name = $executable->storage->label(); + } + + $spanName = 'VIEW'; + if ($name) { + $spanName .= ' ' . $name; + } + + $spanBuilder->setAttribute(static::UPDATE_NAME, $spanName); + $spanBuilder->setAttribute($this->getAttributeName('name'), $name); + $spanBuilder->setAttribute($this->getAttributeName('display_id'), $display_id); + }, + ], + ]; + + $this->registerOperations($operations); + } + +}