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);
+ }
+
+}