diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 8f1351eba..cfc53004f 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -25,6 +25,7 @@ jobs: 'Instrumentation/CakePHP', 'Instrumentation/CodeIgniter', 'Instrumentation/Curl', + 'Instrumentation/Doctrine', 'Instrumentation/ExtAmqp', 'Instrumentation/ExtRdKafka', 'Instrumentation/Guzzle', @@ -55,18 +56,21 @@ jobs: 'Symfony', ] exclude: - - project: 'Instrumentation/IO' - php-version: 8.1 + - project: 'Instrumentation/Curl' php-version: 8.1 - - project: 'Instrumentation/MySqli' - php-version: 8.1 - - project: 'Instrumentation/PDO' + - project: 'Instrumentation/Doctrine' php-version: 8.1 - project: 'Instrumentation/ExtAmqp' php-version: 8.1 - project: 'Instrumentation/ExtRdKafka' php-version: 8.1 + - project: 'Instrumentation/IO' + php-version: 8.1 + - project: 'Instrumentation/MySqli' + php-version: 8.1 + - project: 'Instrumentation/PDO' + php-version: 8.1 steps: - uses: actions/checkout@v4 diff --git a/.gitsplit.yml b/.gitsplit.yml index 23ee30cc0..77a10900a 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -58,6 +58,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-cakephp.git" - prefix: "src/Instrumentation/Yii" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-yii.git" + - prefix: "src/Instrumentation/Doctrine" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-doctrine.git" - prefix: "src/Context/Swoole" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/context-swoole.git" - prefix: "src/AutoInstrumentationInstaller" diff --git a/src/Instrumentation/Doctrine/.gitattributes b/src/Instrumentation/Doctrine/.gitattributes new file mode 100644 index 000000000..1676cf825 --- /dev/null +++ b/src/Instrumentation/Doctrine/.gitattributes @@ -0,0 +1,12 @@ +* text=auto + +*.md diff=markdown +*.php diff=php + +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/psalm.xml.dist export-ignore +/tests export-ignore diff --git a/src/Instrumentation/Doctrine/.php-cs-fixer.php b/src/Instrumentation/Doctrine/.php-cs-fixer.php new file mode 100644 index 000000000..bbfa04e61 --- /dev/null +++ b/src/Instrumentation/Doctrine/.php-cs-fixer.php @@ -0,0 +1,43 @@ +exclude('vendor') + ->exclude('var/cache') + ->in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + 'concat_space' => ['spacing' => 'one'], + 'declare_equal_normalize' => ['space' => 'none'], + 'is_null' => true, + 'modernize_types_casting' => true, + 'ordered_imports' => true, + 'php_unit_construct' => true, + 'single_line_comment_style' => true, + 'yoda_style' => false, + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => true, + 'cast_spaces' => true, + 'declare_strict_types' => true, + 'type_declaration_spaces' => true, + 'include' => true, + 'lowercase_cast' => true, + 'new_with_parentheses' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'echo_tag_syntax' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_types' => true, + 'short_scalar_cast' => true, + 'blank_lines_before_namespace' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + ]) + ->setRiskyAllowed(true) + ->setFinder($finder); diff --git a/src/Instrumentation/Doctrine/README.md b/src/Instrumentation/Doctrine/README.md new file mode 100644 index 000000000..6587063ee --- /dev/null +++ b/src/Instrumentation/Doctrine/README.md @@ -0,0 +1,25 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-auto-doctrine/releases) +[![Issues](https://img.shields.io/badge/issues-pink)](https://github.com/open-telemetry/opentelemetry-php/issues) +[![Source](https://img.shields.io/badge/source-contrib-green)](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Instrumentation/Doctrine) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-auto-doctrine) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-auto-doctrine/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-doctrine/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-auto-doctrine/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-doctrine/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry Doctrine auto-instrumentation + +Please read https://opentelemetry.io/docs/instrumentation/php/automatic/ for instructions on how to +install and configure the extension and SDK. + +## Overview +Auto-instrumentation hooks are registered via composer, and spans will automatically be created for +selected `Doctrine\DBAL\Driver` and `Doctrine\DBAL\Driver\Connection` methods. + +## Configuration + +The extension can be disabled via [runtime configuration](https://opentelemetry.io/docs/instrumentation/php/sdk/#configuration): + +```shell +OTEL_PHP_DISABLED_INSTRUMENTATIONS=doctrine +``` diff --git a/src/Instrumentation/Doctrine/_register.php b/src/Instrumentation/Doctrine/_register.php new file mode 100644 index 000000000..5baecbbeb --- /dev/null +++ b/src/Instrumentation/Doctrine/_register.php @@ -0,0 +1,18 @@ + + + + + + + src + + + + + + + + + + + + + tests/Integration + + + + diff --git a/src/Instrumentation/Doctrine/psalm.xml.dist b/src/Instrumentation/Doctrine/psalm.xml.dist new file mode 100644 index 000000000..5a04b34d7 --- /dev/null +++ b/src/Instrumentation/Doctrine/psalm.xml.dist @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/Instrumentation/Doctrine/src/AttributesResolver.php b/src/Instrumentation/Doctrine/src/AttributesResolver.php new file mode 100644 index 000000000..b3731d2be --- /dev/null +++ b/src/Instrumentation/Doctrine/src/AttributesResolver.php @@ -0,0 +1,149 @@ + 'db2', + 'derby', + 'edb', + 'firebird', + 'h2', + 'hsqldb', + 'ingres', + 'interbase', + 'mariadb', + 'maxdb', + 'sqlsrv' => 'mssql', + 'mssqlcompact', + 'mysqli' => 'mysql', + 'oci8' => 'oracle', + 'pervasive', + 'pgsql' => 'postgresql', + 'sqlite3' => 'sqlite', + 'trino', + ]; + + public static function get(string $attributeName, array $params): string + { + $method = 'get' . str_replace('.', '', ucwords($attributeName, '.')); + + if (!method_exists(AttributesResolver::class, $method)) { + throw new Exception(sprintf('Attribute %s not supported by Doctrine', $attributeName)); + } + + return self::{$method}($params); + } + + /** + * Resolve attribute `server.address` + */ + private static function getServerAddress(array $params): string + { + return $params[1][0]['host'] ?? 'unknown'; + } + + /** + * Resolve attribute `server.port` + */ + private static function getServerPort(array $params): string + { + return $params[1][0]['port'] ?? 'unknown'; + } + + /** + * Resolve attribute `db.system` + */ + private static function getDbSystem(array $params) + { + $dbSystem = $params[1][0]['driver'] ?? null; + + if ($dbSystem && strpos($dbSystem, 'pdo_') !== false) { + // Remove pdo_ word to ignore it while searching well-known db.system + $dbSystem = ltrim($dbSystem, 'pdo_'); + } + + if (in_array($dbSystem, self::DB_SYSTEMS_KNOWN)) { + return $dbSystem; + } + + // Fetch the db system using the alias if exists + if (isset(self::DB_SYSTEMS_KNOWN[$dbSystem])) { + return self::DB_SYSTEMS_KNOWN[$dbSystem]; + } + + return 'other_sql'; + } + + /** + * Resolve attribute `db.collection.name` + */ + private static function getDbCollectionName(array $params): string + { + return $params[1][0]['dbname'] ?? 'unknown'; + } + + /** + * Resolve attribute `db.query.text` + * No sanitization is implemented because implicitly the query is expected to be expressed as a preparated statement + * which happen automatically in Doctrine if parameters are bound to the query. + */ + private static function getDbQueryText(array $params): string + { + return $params[1][0] ?? 'undefined'; + } + + private static function getDbNamespace(array $params): string + { + return $params[1][0]['dbname'] ?? 'unknown'; + } + + /** + * Resolve attribute `db.query.summary` + * See https://opentelemetry.io/docs/specs/semconv/database/database-spans/#generating-a-summary-of-the-query-text + */ + public static function getDbQuerySummary(array $params): string + { + $query = $params[0] ?? null; + + if (!$query) { + return ''; + } + + // Fetch operation name + $operationName = explode(' ', $query); + $operationName = $operationName[0]; + + // Fetch target name + $matches = []; + preg_match_all('/( from| into| update| join)\s*([a-zA-Z0-9`"[\]_]+)/i', $query, $matches); + + $targetName = null; + if (strtolower($operationName) == 'select') { + if ($matches[2]) { + $targetName = implode(' ', $matches[2]); + } + } elseif ($matches) { + $targetName = $matches[2][0] ?? ''; + } + + return $operationName . ($targetName ? ' ' . $targetName : ''); + } +} diff --git a/src/Instrumentation/Doctrine/src/DoctrineInstrumentation.php b/src/Instrumentation/Doctrine/src/DoctrineInstrumentation.php new file mode 100644 index 000000000..5534c6100 --- /dev/null +++ b/src/Instrumentation/Doctrine/src/DoctrineInstrumentation.php @@ -0,0 +1,181 @@ +setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::SERVER_ADDRESS, AttributesResolver::get(TraceAttributes::SERVER_ADDRESS, func_get_args())) + ->setAttribute(TraceAttributes::SERVER_PORT, AttributesResolver::get(TraceAttributes::SERVER_PORT, func_get_args())) + ->setAttribute(TraceAttributes::DB_SYSTEM, AttributesResolver::get(TraceAttributes::DB_SYSTEM, func_get_args())) + ->setAttribute(TraceAttributes::DB_NAMESPACE, AttributesResolver::get(TraceAttributes::DB_NAMESPACE, func_get_args())); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver $driver, array $params, ?\Doctrine\DBAL\Driver\Connection $connection, ?Throwable $exception) { + self::end($exception); + } + ); + + hook( + \Doctrine\DBAL\Driver\Connection::class, + 'query', + pre: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = self::makeBuilder($instrumentation, AttributesResolver::getDbQuerySummary($params), $function, $class, $filename, $lineno) + ->setSpanKind(SpanKind::KIND_CLIENT); + $builder->setAttribute(TraceAttributes::DB_QUERY_TEXT, AttributesResolver::get(TraceAttributes::DB_QUERY_TEXT, func_get_args())); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, mixed $statement, ?Throwable $exception) { + self::end($exception); + } + ); + + hook( + \Doctrine\DBAL\Driver\Connection::class, + 'exec', + pre: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = self::makeBuilder($instrumentation, AttributesResolver::getDbQuerySummary($params), $function, $class, $filename, $lineno) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::DB_QUERY_TEXT, AttributesResolver::get(TraceAttributes::DB_QUERY_TEXT, func_get_args())); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, mixed $statement, ?Throwable $exception) { + self::end($exception); + } + ); + + hook( + \Doctrine\DBAL\Driver\Connection::class, + 'prepare', + pre: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = self::makeBuilder($instrumentation, AttributesResolver::getDbQuerySummary($params), $function, $class, $filename, $lineno) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::DB_QUERY_TEXT, AttributesResolver::get(TraceAttributes::DB_QUERY_TEXT, func_get_args())); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, mixed $statement, ?Throwable $exception) { + self::end($exception); + } + ); + + hook( + \Doctrine\DBAL\Driver\Connection::class, + 'beginTransaction', + pre: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = self::makeBuilder($instrumentation, 'Doctrine\DBAL\Driver\Connection::beginTransaction', $function, $class, $filename, $lineno) + ->setSpanKind(SpanKind::KIND_CLIENT); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, mixed $statement, ?Throwable $exception) { + self::end($exception); + } + ); + + hook( + \Doctrine\DBAL\Driver\Connection::class, + 'commit', + pre: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = self::makeBuilder($instrumentation, 'Doctrine\DBAL\Driver\Connection::commit', $function, $class, $filename, $lineno) + ->setSpanKind(SpanKind::KIND_CLIENT); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, mixed $statement, ?Throwable $exception) { + self::end($exception); + } + ); + + hook( + \Doctrine\DBAL\Driver\Connection::class, + 'rollBack', + pre: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) { + /** @psalm-suppress ArgumentTypeCoercion */ + $builder = self::makeBuilder($instrumentation, 'Doctrine\DBAL\Driver\Connection::rollBack', $function, $class, $filename, $lineno) + ->setSpanKind(SpanKind::KIND_CLIENT); + $parent = Context::getCurrent(); + $span = $builder->startSpan(); + Context::storage()->attach($span->storeInContext($parent)); + }, + post: static function (\Doctrine\DBAL\Driver\Connection $connection, array $params, mixed $statement, ?Throwable $exception) { + self::end($exception); + } + ); + } + private static function makeBuilder( + CachedInstrumentation $instrumentation, + string $name, + string $function, + string $class, + ?string $filename, + ?int $lineno + ): SpanBuilderInterface { + /** @psalm-suppress ArgumentTypeCoercion */ + return $instrumentation->tracer() + ->spanBuilder($name) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno); + } + private static function end(?Throwable $exception): void + { + $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()); + } + + $span->end(); + } +} diff --git a/src/Instrumentation/Doctrine/tests/Integration/DoctrineInstrumentationTest.php b/src/Instrumentation/Doctrine/tests/Integration/DoctrineInstrumentationTest.php new file mode 100644 index 000000000..36219490c --- /dev/null +++ b/src/Instrumentation/Doctrine/tests/Integration/DoctrineInstrumentationTest.php @@ -0,0 +1,150 @@ + */ + private ArrayObject $storage; + + private function createConnection(): \Doctrine\DBAL\Connection + { + $connectionParams = [ + 'driver' => 'sqlite3', + 'memory' => true, + ]; + + $conn = DriverManager::getConnection($connectionParams); + // Trigger internal connect + $conn->getServerVersion(); + + return $conn; + } + + private function fillDB(): string + { + return <<storage = new ArrayObject(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + + $this->scope = Configurator::create() + ->withTracerProvider($tracerProvider) + ->activate(); + } + + public function tearDown(): void + { + $this->scope->detach(); + } + + public function test_connection(): void + { + $this->assertCount(0, $this->storage); + $conn = self::createConnection(); + $this->assertCount(1, $this->storage); + $this->assertTrue($conn->isConnected()); + $span = $this->storage->offsetGet(0); + $this->assertSame('Doctrine\DBAL\Driver::connect', $span->getName()); + $this->assertEquals('sqlite', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM)); + } + + public function test_connection_exception(): void + { + $this->expectException(\Doctrine\DBAL\Exception::class); + $this->expectExceptionMessageMatches('/The given driver "unknown" is unknown/'); + /** + * @psalm-suppress InvalidArgument + * @phpstan-ignore argument.type + */ + DriverManager::getConnection([ + 'driver' => 'unknown', + ]); + } + + public function test_statement_execution(): void + { + $connection = self::createConnection(); + $statement = self::fillDB(); + + $connection->executeStatement($statement); + $span = $this->storage->offsetGet(1); + $this->assertSame('CREATE technology', $span->getName()); + $this->assertFalse($connection->isTransactionActive()); + $this->assertCount(2, $this->storage); + + $connection->prepare('SELECT * FROM `technology`'); + $span = $this->storage->offsetGet(2); + $this->assertSame('SELECT `technology`', $span->getName()); + $this->assertCount(3, $this->storage); + + $connection->executeQuery('SELECT * FROM `technology`'); + $span = $this->storage->offsetGet(3); + $this->assertSame('SELECT `technology`', $span->getName()); + $this->assertCount(4, $this->storage); + } + + public function test_transaction(): void + { + $connection = self::createConnection(); + $connection->beginTransaction(); + $span = $this->storage->offsetGet(1); + $this->assertSame('Doctrine\DBAL\Driver\Connection::beginTransaction', $span->getName()); + $this->assertCount(2, $this->storage); + + $statement = self::fillDB(); + $connection->executeStatement($statement); + $span = $this->storage->offsetGet(2); + $this->assertSame('CREATE technology', $span->getName()); + $connection->commit(); + $span = $this->storage->offsetGet(3); + $this->assertSame('Doctrine\DBAL\Driver\Connection::commit', $span->getName()); + $this->assertCount(4, $this->storage); + + $connection->beginTransaction(); + $this->assertTrue($connection->isTransactionActive()); + + $connection->executeStatement("INSERT INTO technology(`name`, `date`) VALUES('Java', '1995-05-23');"); + $connection->rollback(); + $span = $this->storage->offsetGet(6); + $this->assertSame('Doctrine\DBAL\Driver\Connection::rollBack', $span->getName()); + $this->assertCount(7, $this->storage); + $this->assertFalse($connection->isTransactionActive()); + + $sth = $connection->prepare('SELECT * FROM `technology`'); + $this->assertSame(2, count($sth->executeQuery()->fetchAllAssociative())); + } +}