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 @@
+[](https://github.com/opentelemetry-php/contrib-auto-doctrine/releases)
+[](https://github.com/open-telemetry/opentelemetry-php/issues)
+[](https://github.com/open-telemetry/opentelemetry-php-contrib/tree/main/src/Instrumentation/Doctrine)
+[](https://github.com/opentelemetry-php/contrib-auto-doctrine)
+[](https://packagist.org/packages/open-telemetry/opentelemetry-auto-doctrine/)
+[](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()));
+ }
+}