diff --git a/.github/workflows/static-analysis.yaml b/.github/workflows/static-analysis.yaml index 423cfa70..cd1a1551 100644 --- a/.github/workflows/static-analysis.yaml +++ b/.github/workflows/static-analysis.yaml @@ -17,8 +17,6 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 - with: - php-version: '7.4' - name: Install dependencies run: composer update --no-progress --no-interaction --prefer-dist diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7b78eb5a..d8fa8d6a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -65,3 +65,59 @@ jobs: with: file: './coverage.xml' fail_ci_if_error: true + + missing-optional-packages-tests: + name: Tests without optional packages + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: + - '7.2' + - '8.0' + dependencies: + - lowest + - highest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Setup Problem Matchers for PHPUnit + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Determine Composer cache directory + id: composer-cache + run: echo "::set-output name=directory::$(composer config cache-dir)" + + - name: Cache Composer dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.directory }} + key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ matrix.dependencies }}-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer-${{ matrix.dependencies }}- + + - name: Remove optional packages + run: composer remove doctrine/dbal doctrine/doctrine-bundle symfony/messenger --dev --no-update + + - name: Install highest dependencies + run: composer update --no-progress --no-interaction --prefer-dist + if: ${{ matrix.dependencies == 'highest' }} + + - name: Install lowest dependencies + run: composer update --no-progress --no-interaction --prefer-dist --prefer-lowest + if: ${{ matrix.dependencies == 'lowest' }} + + - name: Run tests + run: vendor/bin/phpunit --coverage-clover=build/coverage-report.xml + + - name: Upload code coverage + uses: codecov/codecov-action@v1 + with: + file: build/coverage-report.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c2e0ba..404716f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add support for distributed tracing of SQL queries while using Doctrine DBAL (#426) - Added missing `capture-soft-fails` config schema option (#417) - Deprecate the `Sentry\SentryBundle\EventListener\ConsoleCommandListener` class in favor of its parent class `Sentry\SentryBundle\EventListener\ConsoleListener` (#429) diff --git a/composer.json b/composer.json index afc7ea97..cc2220d0 100644 --- a/composer.json +++ b/composer.json @@ -23,16 +23,18 @@ "jean85/pretty-package-versions": "^1.5 || ^2.0", "php-http/discovery": "^1.11", "sentry/sdk": "^3.1", - "symfony/config": "^3.4.43||^4.4.11||^5.0.11", - "symfony/console": "^3.4.43||^4.4.11||^5.0.11", - "symfony/dependency-injection": "^3.4.43||^4.4.11||^5.0.11", - "symfony/event-dispatcher": "^3.4.43||^4.4.11||^5.0.11", - "symfony/http-kernel": "^3.4.43||^4.4.11||^5.0.11", + "symfony/config": "^3.4.44||^4.4.11||^5.0.11", + "symfony/console": "^3.4.44||^4.4.11||^5.0.11", + "symfony/dependency-injection": "^3.4.44||^4.4.11||^5.0.11", + "symfony/event-dispatcher": "^3.4.44||^4.4.11||^5.0.11", + "symfony/http-kernel": "^3.4.44||^4.4.11||^5.0.11", "symfony/psr-http-message-bridge": "^2.0", - "symfony/security-core": "^3.4.43||^4.4.11||^5.0.11" + "symfony/security-core": "^3.4.44||^4.4.11||^5.0.11" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.17", + "doctrine/dbal": "^2.10||^3.0", + "doctrine/doctrine-bundle": "^1.12||^2.0", + "friendsofphp/php-cs-fixer": "^2.18", "jangregor/phpstan-prophecy": "^0.8", "monolog/monolog": "^1.3||^2.0", "phpspec/prophecy": "!=1.11.0", @@ -41,17 +43,19 @@ "phpstan/phpstan": "^0.12", "phpstan/phpstan-phpunit": "^0.12", "phpunit/phpunit": "^8.5||^9.0", - "symfony/browser-kit": "^3.4.43||^4.4.11||^5.0.11", - "symfony/framework-bundle": "^3.4.43||^4.4.11||^5.0.11", + "symfony/browser-kit": "^3.4.44||^4.4.12||^5.0.11", + "symfony/dom-crawler": "^3.4.44||^4.4.12||^5.0.11", + "symfony/framework-bundle": "^3.4.44||^4.4.12||^5.0.11", "symfony/messenger": "^4.4.11||^5.0.11", "symfony/monolog-bundle": "^3.4", "symfony/phpunit-bridge": "^5.0", "symfony/polyfill-php80": "^1.22", - "symfony/yaml": "^3.4.43||^4.4.11||^5.0.11", + "symfony/yaml": "^3.4.44||^4.4.11||^5.0.11", "vimeo/psalm": "^4.3" }, "suggest": { - "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.", + "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry." }, "autoload": { "files": [ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f25218ff..2552a4ad 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -10,11 +10,91 @@ parameters: count: 1 path: src/DependencyInjection/Configuration.php + - + message: "#^Call to an undefined method Symfony\\\\Component\\\\Config\\\\Definition\\\\Builder\\\\NodeParentInterface\\:\\:end\\(\\)\\.$#" + count: 1 + path: src/DependencyInjection/Configuration.php + - message: "#^Else branch is unreachable because previous condition is always true\\.$#" count: 1 path: src/EventListener/ErrorListener.php + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriver\\:\\:connect\\(\\) has parameter \\$driverOptions with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriver\\:\\:connect\\(\\) has parameter \\$password with no typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriver\\:\\:connect\\(\\) has parameter \\$username with no typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\|Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\Compatibility\\\\ExceptionConverterDriverInterface\\:\\:connect\\(\\)\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\|Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\Compatibility\\\\ExceptionConverterDriverInterface\\:\\:getDatabasePlatform\\(\\)\\.$#" + count: 2 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\|Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\Compatibility\\\\ExceptionConverterDriverInterface\\:\\:getSchemaManager\\(\\)\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriver\\:\\:convertException\\(\\) has parameter \\$message with no typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Call to an undefined method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\Compatibility\\\\ExceptionConverterDriverInterface\\:\\:convertException\\(\\)\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Parameter \\#2 \\$query of class Doctrine\\\\DBAL\\\\Exception\\\\DriverException constructor expects Doctrine\\\\DBAL\\\\Query\\|null, Doctrine\\\\DBAL\\\\Driver\\\\Exception given\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriver.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriverConnection\\:\\:prepare\\(\\) has parameter \\$sql with no typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriverConnection.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriverConnection\\:\\:query\\(\\) has parameter \\$args with no typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriverConnection.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriverConnection\\:\\:exec\\(\\) has parameter \\$sql with no typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriverConnection.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriverConnection\\:\\:errorCode\\(\\) has no return typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriverConnection.php + + - + message: "#^Call to an undefined method Doctrine\\\\DBAL\\\\Driver\\\\Connection\\:\\:errorCode\\(\\)\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriverConnection.php + + - + message: "#^Method Sentry\\\\SentryBundle\\\\Tracing\\\\Doctrine\\\\DBAL\\\\TracingDriverConnection\\:\\:errorInfo\\(\\) has no return typehint specified\\.$#" + count: 1 + path: src/Tracing/Doctrine/DBAL/TracingDriverConnection.php + - message: "#^Class Symfony\\\\Component\\\\HttpKernel\\\\Event\\\\GetResponseForExceptionEvent not found\\.$#" count: 1 @@ -115,3 +195,28 @@ parameters: count: 2 path: tests/EventListener/SubRequestListenerTest.php + - + message: "#^Trying to mock an undefined method getName\\(\\) on class Doctrine\\\\DBAL\\\\Driver\\.$#" + count: 1 + path: tests/Tracing/Doctrine/DBAL/TracingDriverTest.php + + - + message: "#^Trying to mock an undefined method getDatabase\\(\\) on class Doctrine\\\\DBAL\\\\Driver\\.$#" + count: 1 + path: tests/Tracing/Doctrine/DBAL/TracingDriverTest.php + + - + message: "#^Parameter \\#1 \\$driverException of class Doctrine\\\\DBAL\\\\Exception\\\\DriverException constructor expects Doctrine\\\\DBAL\\\\Driver\\\\Exception, string given\\.$#" + count: 1 + path: tests/Tracing/Doctrine/DBAL/TracingDriverTest.php + + - + message: "#^Parameter \\#2 \\$query of class Doctrine\\\\DBAL\\\\Exception\\\\DriverException constructor expects Doctrine\\\\DBAL\\\\Query\\|null, Doctrine\\\\DBAL\\\\Driver\\\\Exception&PHPUnit\\\\Framework\\\\MockObject\\\\MockObject given\\.$#" + count: 1 + path: tests/Tracing/Doctrine/DBAL/TracingDriverTest.php + + - + message: "#^Trying to mock an undefined method convertException\\(\\) on class Sentry\\\\SentryBundle\\\\Tests\\\\Tracing\\\\Doctrine\\\\DBAL\\\\StubExceptionConverterDriverInterface\\.$#" + count: 1 + path: tests/Tracing/Doctrine/DBAL/TracingDriverTest.php + diff --git a/phpstan.neon b/phpstan.neon index 2fb21049..18215d3d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -10,3 +10,4 @@ parameters: - tests/End2End/App dynamicConstantNames: - Symfony\Component\HttpKernel\Kernel::VERSION + - Doctrine\DBAL\Version::VERSION diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 65955abd..a38ae1f0 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + ConsoleListener @@ -8,4 +8,12 @@ public function __construct(HubInterface $hub) + + + getSchemaManager + + + ExceptionConverter + + diff --git a/src/DependencyInjection/Compiler/DbalTracingPass.php b/src/DependencyInjection/Compiler/DbalTracingPass.php new file mode 100644 index 00000000..91d4c7fe --- /dev/null +++ b/src/DependencyInjection/Compiler/DbalTracingPass.php @@ -0,0 +1,87 @@ +hasParameter('doctrine.connections')) { + return; + } + + /** @var string[] $connections */ + $connections = $container->getParameter('doctrine.connections'); + + /** @var string[] $connectionsToTrace */ + $connectionsToTrace = $container->getParameter('sentry.tracing.dbal.connections'); + + foreach ($connectionsToTrace as $connectionName) { + if (!\in_array(sprintf(self::CONNECTION_SERVICE_NAME_FORMAT, $connectionName), $connections, true)) { + continue; + } + + if (!interface_exists(ResultStatement::class)) { + $this->configureConnectionForDoctrineDBALVersion3($container, $connectionName); + } else { + $this->configureConnectionForDoctrineDBALVersion2($container, $connectionName); + } + } + } + + private function configureConnectionForDoctrineDBALVersion3(ContainerBuilder $container, string $connectionName): void + { + $configurationDefinition = $container->getDefinition(sprintf(self::CONNECTION_CONFIG_SERVICE_NAME_FORMAT, $connectionName)); + $setMiddlewaresMethodCallArguments = $this->getSetMiddlewaresMethodCallArguments($configurationDefinition); + $setMiddlewaresMethodCallArguments[0] = array_merge($setMiddlewaresMethodCallArguments[0] ?? [], [new Reference(TracingDriverMiddleware::class)]); + + $configurationDefinition + ->removeMethodCall('setMiddlewares') + ->addMethodCall('setMiddlewares', $setMiddlewaresMethodCallArguments); + } + + private function configureConnectionForDoctrineDBALVersion2(ContainerBuilder $container, string $connectionName): void + { + $connectionDefinition = $container->getDefinition(sprintf(self::CONNECTION_SERVICE_NAME_FORMAT, $connectionName)); + $connectionDefinition->setConfigurator([new Reference(ConnectionConfigurator::class), 'configure']); + } + + /** + * @return mixed[] + */ + private function getSetMiddlewaresMethodCallArguments(Definition $definition): array + { + foreach ($definition->getMethodCalls() as $methodCall) { + if ('setMiddlewares' === $methodCall[0]) { + return $methodCall[1]; + } + } + + return []; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 69f94459..c3386855 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -4,6 +4,7 @@ namespace Sentry\SentryBundle\DependencyInjection; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; use Sentry\Options; use Sentry\SentryBundle\ErrorTypesParser; @@ -124,10 +125,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - ->end() - ; + ->end(); $this->addMessengerSection($rootNode); + $this->addDistributedTracingSection($rootNode); return $treeBuilder; } @@ -144,4 +145,26 @@ private function addMessengerSection(ArrayNodeDefinition $rootNode): void ->end() ->end(); } + + private function addDistributedTracingSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('tracing') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('dbal') + ->canBeEnabled() + ->fixXmlConfig('connection') + ->children() + ->arrayNode('connections') + ->defaultValue(class_exists(DoctrineBundle::class) ? ['%doctrine.default_connection%'] : []) + ->scalarPrototype()->end() + ->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } } diff --git a/src/DependencyInjection/SentryExtension.php b/src/DependencyInjection/SentryExtension.php index 302e432a..03b99de3 100644 --- a/src/DependencyInjection/SentryExtension.php +++ b/src/DependencyInjection/SentryExtension.php @@ -4,7 +4,9 @@ namespace Sentry\SentryBundle\DependencyInjection; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; +use LogicException; use Sentry\Client; use Sentry\ClientBuilder; use Sentry\Integration\IgnoreErrorsIntegration; @@ -15,6 +17,8 @@ use Sentry\SentryBundle\EventListener\ErrorListener; use Sentry\SentryBundle\EventListener\MessengerListener; use Sentry\SentryBundle\SentryBundle; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\ConnectionConfigurator; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\TracingDriverMiddleware; use Sentry\Serializer\RepresentationSerializer; use Sentry\Serializer\Serializer; use Sentry\Transport\TransportFactoryInterface; @@ -57,6 +61,7 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container $this->registerConfiguration($container, $mergedConfig); $this->registerErrorListenerConfiguration($container, $mergedConfig); $this->registerMessengerListenerConfiguration($container, $mergedConfig['messenger']); + $this->registerTracingConfiguration($container, $mergedConfig['tracing']); } /** @@ -140,7 +145,7 @@ private function registerErrorListenerConfiguration(ContainerBuilder $container, */ private function registerMessengerListenerConfiguration(ContainerBuilder $container, array $config): void { - if (!$config['enabled']) { + if (!$this->isConfigEnabled($container, $config)) { $container->removeDefinition(MessengerListener::class); return; @@ -149,6 +154,25 @@ private function registerMessengerListenerConfiguration(ContainerBuilder $contai $container->getDefinition(MessengerListener::class)->setArgument(1, $config['capture_soft_fails']); } + /** + * @param array $config + */ + private function registerTracingConfiguration(ContainerBuilder $container, array $config): void + { + $isConfigEnabled = $this->isConfigEnabled($container, $config['dbal']); + + if ($isConfigEnabled && !class_exists(DoctrineBundle::class)) { + throw new LogicException('DBAL tracing support cannot be enabled as the DoctrineBundle bundle is not installed.'); + } + + $container->setParameter('sentry.tracing.dbal.connections', $isConfigEnabled ? $config['dbal']['connections'] : []); + + if (!$isConfigEnabled) { + $container->removeDefinition(ConnectionConfigurator::class); + $container->removeDefinition(TracingDriverMiddleware::class); + } + } + /** * @param string[] $integrations * @param array $config diff --git a/src/Resources/config/schema/sentry-1.0.xsd b/src/Resources/config/schema/sentry-1.0.xsd index a98b4b36..d74c0dc1 100644 --- a/src/Resources/config/schema/sentry-1.0.xsd +++ b/src/Resources/config/schema/sentry-1.0.xsd @@ -11,6 +11,7 @@ + @@ -79,4 +80,18 @@ + + + + + + + + + + + + + + diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 9d22575b..903aa405 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -65,6 +65,14 @@ + + + + + + + + diff --git a/src/SentryBundle.php b/src/SentryBundle.php index 673baad3..7dc981f6 100644 --- a/src/SentryBundle.php +++ b/src/SentryBundle.php @@ -4,9 +4,18 @@ namespace Sentry\SentryBundle; +use Sentry\SentryBundle\DependencyInjection\Compiler\DbalTracingPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; final class SentryBundle extends Bundle { public const SDK_IDENTIFIER = 'sentry.php.symfony'; + + public function build(ContainerBuilder $container): void + { + parent::build($container); + + $container->addCompilerPass(new DbalTracingPass()); + } } diff --git a/src/Tracing/Doctrine/DBAL/Compatibility/DriverInterface.php b/src/Tracing/Doctrine/DBAL/Compatibility/DriverInterface.php new file mode 100644 index 00000000..8d320f5e --- /dev/null +++ b/src/Tracing/Doctrine/DBAL/Compatibility/DriverInterface.php @@ -0,0 +1,12 @@ +tracingDriverMiddleware = $tracingDriverMiddleware; + } + + /** + * Configures the given connecton by wrapping its driver into an instance + * of the {@see TracingDriver} class. This is done using the reflection, + * and as such should be limited only to the versions of Doctrine DBAL that + * are lower than 3.0. Since 3.0 onwards, the concept of driver middlewares + * has been introduced which allows the same thing we're doing here, but in + * a more appropriate and "legal" way. + * + * @param Connection $connection The connection to configure + */ + public function configure(Connection $connection): void + { + $reflectionProperty = new \ReflectionProperty($connection, '_driver'); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($connection, $this->tracingDriverMiddleware->wrap($reflectionProperty->getValue($connection))); + $reflectionProperty->setAccessible(false); + } +} diff --git a/src/Tracing/Doctrine/DBAL/TracingDriver.php b/src/Tracing/Doctrine/DBAL/TracingDriver.php new file mode 100644 index 00000000..e18b9e6b --- /dev/null +++ b/src/Tracing/Doctrine/DBAL/TracingDriver.php @@ -0,0 +1,142 @@ += 2.10. + * + * @internal + */ +final class TracingDriver implements DriverInterface, VersionAwarePlatformDriverInterface, ExceptionConverterDriverInterface +{ + /** + * @var HubInterface The current hub + */ + private $hub; + + /** + * @var DriverInterface|VersionAwarePlatformDriverInterface|ExceptionConverterDriverInterface The instance of the decorated driver + */ + private $decoratedDriver; + + /** + * Constructor. + * + * @param HubInterface $hub The current hub + * @param DriverInterface $decoratedDriver The instance of the driver to decorate + */ + public function __construct(HubInterface $hub, DriverInterface $decoratedDriver) + { + $this->hub = $hub; + $this->decoratedDriver = $decoratedDriver; + } + + /** + * {@inheritdoc} + */ + public function connect(array $params, $username = null, $password = null, array $driverOptions = []) + { + return new TracingDriverConnection( + $this->hub, + $this->decoratedDriver->connect($params, $username, $password, $driverOptions), + $this->decoratedDriver->getDatabasePlatform()->getName(), + $params + ); + } + + /** + * {@inheritdoc} + */ + public function getDatabasePlatform() + { + return $this->decoratedDriver->getDatabasePlatform(); + } + + /** + * {@inheritdoc} + */ + public function getSchemaManager(Connection $conn, ?AbstractPlatform $platform = null) + { + return $this->decoratedDriver->getSchemaManager($conn, $platform); + } + + /** + * {@inheritdoc} + */ + public function getExceptionConverter(): ExceptionConverter + { + if (method_exists($this->decoratedDriver, 'getExceptionConverter')) { + return $this->decoratedDriver->getExceptionConverter(); + } + + throw new \BadMethodCallException(sprintf('The %s() method is not supported on Doctrine DBAL 2.x.', __METHOD__)); + } + + /** + * {@inheritdoc} + */ + public function getName(): string + { + if (method_exists($this->decoratedDriver, 'getName')) { + return $this->decoratedDriver->getName(); + } + + throw new \BadMethodCallException(sprintf('The %s() method is not supported on Doctrine DBAL 3.0.', __METHOD__)); + } + + /** + * {@inheritdoc} + */ + public function getDatabase(Connection $conn): string + { + if (method_exists($this->decoratedDriver, 'getDatabase')) { + return $this->decoratedDriver->getDatabase($conn); + } + + throw new \BadMethodCallException(sprintf('The %s() method is not supported on Doctrine DBAL 3.0.', __METHOD__)); + } + + /** + * {@inheritdoc} + */ + public function createDatabasePlatformForVersion($version): AbstractPlatform + { + if ($this->decoratedDriver instanceof VersionAwarePlatformDriverInterface) { + return $this->decoratedDriver->createDatabasePlatformForVersion($version); + } + + return $this->getDatabasePlatform(); + } + + /** + * {@inheritdoc} + */ + public function convertException($message, LegacyDriverExceptionInterface $exception): DBALDriverException + { + if (!interface_exists(ResultStatement::class)) { + throw new \BadMethodCallException(sprintf('The %s() method is not supported on Doctrine DBAL 3.0.', __METHOD__)); + } + + if ($this->decoratedDriver instanceof ExceptionConverterDriverInterface) { + return $this->decoratedDriver->convertException($message, $exception); + } + + return new DBALDriverException($message, $exception); + } +} diff --git a/src/Tracing/Doctrine/DBAL/TracingDriverConnection.php b/src/Tracing/Doctrine/DBAL/TracingDriverConnection.php new file mode 100644 index 00000000..5d319d47 --- /dev/null +++ b/src/Tracing/Doctrine/DBAL/TracingDriverConnection.php @@ -0,0 +1,261 @@ += 2.10. + */ +final class TracingDriverConnection implements DriverConnectionInterface +{ + /** + * @internal + */ + public const SPAN_OP_CONN_PREPARE = 'sql.conn.prepare'; + + /** + * @internal + */ + public const SPAN_OP_CONN_QUERY = 'sql.conn.query'; + + /** + * @internal + */ + public const SPAN_OP_CONN_EXEC = 'sql.conn.exec'; + + /** + * @internal + */ + public const SPAN_OP_CONN_BEGIN_TRANSACTION = 'sql.conn.begin_transaction'; + + /** + * @internal + */ + public const SPAN_OP_TRANSACTION_COMMIT = 'sql.transaction.commit'; + + /** + * @internal + */ + public const SPAN_OP_TRANSACTION_ROLLBACK = 'sql.transaction.rollback'; + + /** + * @var HubInterface The current hub + */ + private $hub; + + /** + * @var DriverConnectionInterface The decorated connection + */ + private $decoratedConnection; + + /** + * @var string The name of the database platform + */ + private $databasePlatform; + + /** + * @var array The tags to attach to the span + */ + private $spanTags; + + /** + * Constructor. + * + * @param HubInterface $hub The current hub + * @param DriverConnectionInterface $decoratedConnection The connection to decorate + * @param string $databasePlatform The name of the database platform + * @param array $params The connection params + */ + public function __construct( + HubInterface $hub, + DriverConnectionInterface $decoratedConnection, + string $databasePlatform, + array $params + ) { + $this->hub = $hub; + $this->decoratedConnection = $decoratedConnection; + $this->databasePlatform = $databasePlatform; + $this->spanTags = $this->getSpanTags($params); + } + + /** + * {@inheritdoc} + */ + public function prepare($sql): Statement + { + return $this->traceFunction(self::SPAN_OP_CONN_PREPARE, $sql, function () use ($sql): Statement { + return $this->decoratedConnection->prepare($sql); + }); + } + + /** + * {@inheritdoc} + */ + public function query(?string $sql = null, ...$args): Result + { + return $this->traceFunction(self::SPAN_OP_CONN_QUERY, $sql, function () use ($sql, $args): Result { + return $this->decoratedConnection->query($sql, ...$args); + }); + } + + /** + * {@inheritdoc} + */ + public function quote($value, $type = ParameterType::STRING) + { + return $this->decoratedConnection->quote($value, $type); + } + + /** + * {@inheritdoc} + */ + public function exec($sql): int + { + return $this->traceFunction(self::SPAN_OP_CONN_EXEC, $sql, function () use ($sql): int { + return $this->decoratedConnection->exec($sql); + }); + } + + /** + * {@inheritdoc} + */ + public function lastInsertId($name = null) + { + return $this->decoratedConnection->lastInsertId($name); + } + + /** + * {@inheritdoc} + */ + public function beginTransaction() + { + return $this->traceFunction(self::SPAN_OP_CONN_BEGIN_TRANSACTION, 'BEGIN TRANSACTION', function (): bool { + return $this->decoratedConnection->beginTransaction(); + }); + } + + /** + * {@inheritdoc} + */ + public function commit() + { + return $this->traceFunction(self::SPAN_OP_TRANSACTION_COMMIT, 'COMMIT', function (): bool { + return $this->decoratedConnection->commit(); + }); + } + + /** + * {@inheritdoc} + */ + public function rollBack() + { + return $this->traceFunction(self::SPAN_OP_TRANSACTION_ROLLBACK, 'ROLLBACK', function (): bool { + return $this->decoratedConnection->rollBack(); + }); + } + + /** + * {@inheritdoc} + */ + public function errorCode() + { + if (method_exists($this->decoratedConnection, 'errorInfo')) { + return $this->decoratedConnection->errorCode(); + } + + throw new \BadMethodCallException(sprintf('The %s() method is not supported on Doctrine DBAL 3.0.', __METHOD__)); + } + + /** + * {@inheritdoc} + */ + public function errorInfo() + { + if (method_exists($this->decoratedConnection, 'errorInfo')) { + return $this->decoratedConnection->errorInfo(); + } + + throw new \BadMethodCallException(sprintf('The %s() method is not supported on Doctrine DBAL 3.0.', __METHOD__)); + } + + /** + * @phpstan-template T + * + * @phpstan-param \Closure(): T $callback + * + * @phpstan-return T + */ + private function traceFunction(string $spanOperation, string $spanDescription, \Closure $callback) + { + $span = $this->hub->getSpan(); + + if (null !== $span) { + $spanContext = new SpanContext(); + $spanContext->setOp($spanOperation); + $spanContext->setDescription($spanDescription); + $spanContext->setTags($this->spanTags); + + $span = $span->startChild($spanContext); + } + + try { + return $callback(); + } finally { + if (null !== $span) { + $span->finish(); + } + } + } + + /** + * Gets a map of key-value pairs that will be set as tags of the span. + * + * @param array $params The connection params + * + * @return array + * + * @see https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/database.md + */ + private function getSpanTags(array $params): array + { + $tags = ['db.system' => $this->databasePlatform]; + + if (isset($params['user'])) { + $tags['db.user'] = $params['user']; + } + + if (isset($params['dbname'])) { + $tags['db.name'] = $params['dbname']; + } + + if (isset($params['host']) && !empty($params['host']) && !isset($params['memory'])) { + if (false === filter_var($params['host'], \FILTER_VALIDATE_IP)) { + $tags['net.peer.name'] = $params['host']; + } else { + $tags['net.peer.ip'] = $params['host']; + } + } + + if (isset($params['port'])) { + $tags['net.peer.port'] = (string) $params['port']; + } + + if (isset($params['unix_socket'])) { + $tags['net.transport'] = 'Unix'; + } elseif (isset($params['memory'])) { + $tags['net.transport'] = 'inproc'; + } + + return $tags; + } +} diff --git a/src/Tracing/Doctrine/DBAL/TracingDriverMiddleware.php b/src/Tracing/Doctrine/DBAL/TracingDriverMiddleware.php new file mode 100644 index 00000000..1a77e9d6 --- /dev/null +++ b/src/Tracing/Doctrine/DBAL/TracingDriverMiddleware.php @@ -0,0 +1,39 @@ +hub = $hub; + } + + /** + * {@inheritdoc} + */ + public function wrap(DriverInterface $driver): DriverInterface + { + return new TracingDriver($this->hub, $driver); + } +} diff --git a/src/aliases.php b/src/aliases.php index 796ffda6..4d5a40c0 100644 --- a/src/aliases.php +++ b/src/aliases.php @@ -2,10 +2,22 @@ declare(strict_types=1); +namespace Sentry\SentryBundle; + +use Doctrine\DBAL\Driver as DoctrineDriverInterface; +use Doctrine\DBAL\Driver\DriverException as LegacyDriverExceptionInterface; +use Doctrine\DBAL\Driver\Exception as DriverExceptionInterface; +use Doctrine\DBAL\Driver\ExceptionConverterDriver as LegacyExceptionConverterDriverInterface; +use Doctrine\DBAL\Driver\Middleware as DoctrineMiddlewareInterface; +use Doctrine\DBAL\Driver\Result; +use Doctrine\DBAL\Driver\Statement; use Sentry\SentryBundle\EventListener\ErrorListenerExceptionEvent; use Sentry\SentryBundle\EventListener\RequestListenerControllerEvent; use Sentry\SentryBundle\EventListener\RequestListenerRequestEvent; use Sentry\SentryBundle\EventListener\SubRequestListenerRequestEvent; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\Compatibility\DriverInterface; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\Compatibility\ExceptionConverterDriverInterface; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\Compatibility\MiddlewareInterface; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Event\FilterControllerEvent; @@ -55,3 +67,28 @@ class_alias(FilterControllerEvent::class, RequestListenerControllerEvent::class) class_alias(GetResponseEvent::class, SubRequestListenerRequestEvent::class); } } + +if (interface_exists(Statement::class) && !interface_exists(Result::class)) { + /** @psalm-suppress UndefinedClass */ + class_alias(Statement::class, Result::class); +} + +if (interface_exists(DriverExceptionInterface::class) && !interface_exists(LegacyDriverExceptionInterface::class)) { + /** @psalm-suppress UndefinedClass */ + class_alias(DriverExceptionInterface::class, LegacyDriverExceptionInterface::class); +} + +if (!interface_exists(DoctrineMiddlewareInterface::class)) { + /** @psalm-suppress UndefinedClass */ + class_alias(MiddlewareInterface::class, DoctrineMiddlewareInterface::class); +} + +if (!interface_exists(DoctrineDriverInterface::class)) { + /** @psalm-suppress UndefinedClass */ + class_alias(DriverInterface::class, DoctrineDriverInterface::class); +} + +if (!interface_exists(LegacyExceptionConverterDriverInterface::class)) { + /** @psalm-suppress UndefinedClass */ + class_alias(ExceptionConverterDriverInterface::class, LegacyExceptionConverterDriverInterface::class); +} diff --git a/tests/DependencyInjection/Compiler/DbalTracingPassTest.php b/tests/DependencyInjection/Compiler/DbalTracingPassTest.php new file mode 100644 index 00000000..55d72891 --- /dev/null +++ b/tests/DependencyInjection/Compiler/DbalTracingPassTest.php @@ -0,0 +1,134 @@ +markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be >= 3.0.'); + } + + $container = $this->createContainerBuilder(); + $container->setParameter('doctrine.connections', ['doctrine.dbal.foo_connection', 'doctrine.dbal.bar_connection', 'doctrine.dbal.baz_connection']); + $container->setParameter('sentry.tracing.dbal.connections', ['foo', 'bar', 'baz', 'qux']); + + $container + ->register('foo.service', \stdClass::class) + ->setPublic(true); + + $container + ->register('doctrine.dbal.foo_connection.configuration', Configuration::class) + ->setPublic(true); + + $container + ->register('doctrine.dbal.bar_connection.configuration', Configuration::class) + ->addMethodCall('setMiddlewares', [[]]) + ->setPublic(true); + + $container + ->register('doctrine.dbal.baz_connection.configuration', Configuration::class) + ->addMethodCall('setMiddlewares', [[new Reference('foo.service')]]) + ->setPublic(true); + + $container->compile(); + + $this->assertEquals( + [ + [ + 'setMiddlewares', + [[new Reference(TracingDriverMiddleware::class)]], + ], + ], + $container->getDefinition('doctrine.dbal.foo_connection.configuration')->getMethodCalls() + ); + + $this->assertEquals( + [ + [ + 'setMiddlewares', + [[new Reference(TracingDriverMiddleware::class)]], + ], + ], + $container->getDefinition('doctrine.dbal.bar_connection.configuration')->getMethodCalls() + ); + + $this->assertEquals( + [ + [ + 'setMiddlewares', + [ + [ + new Reference('foo.service'), + new Reference(TracingDriverMiddleware::class), + ], + ], + ], + ], + $container->getDefinition('doctrine.dbal.baz_connection.configuration')->getMethodCalls() + ); + } + + public function testProcessWithDoctrineDBALVersionLowerThan30(): void + { + if (self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be < 3.0.'); + } + + $connection1 = (new Definition(Connection::class))->setPublic(true); + $connection2 = (new Definition(Connection::class))->setPublic(true); + + $container = $this->createContainerBuilder(); + $container->setParameter('doctrine.connections', ['doctrine.dbal.foo_connection', 'doctrine.dbal.bar_connection']); + $container->setParameter('sentry.tracing.dbal.connections', ['foo', 'baz']); + $container->setDefinition('doctrine.dbal.foo_connection', $connection1); + $container->setDefinition('doctrine.dbal.bar_connection', $connection2); + $container->compile(); + + $this->assertEquals([new Reference(ConnectionConfigurator::class), 'configure'], $connection1->getConfigurator()); + $this->assertNull($connection2->getConfigurator()); + } + + public function testProcessDoesNothingIfDoctrineConnectionsParamIsMissing(): void + { + $container = $this->createContainerBuilder(); + $container->setParameter('sentry.tracing.dbal.connections', ['foo']); + + $container + ->register('doctrine.dbal.foo_connection.configuration', Configuration::class) + ->setPublic(true); + + $container->compile(); + + $this->assertEmpty($container->getDefinition('doctrine.dbal.foo_connection.configuration')->getMethodCalls()); + } + + private function createContainerBuilder(): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new DbalTracingPass()); + + $container + ->register(TracingDriverMiddleware::class, TracingDriverMiddleware::class) + ->setPublic(true); + + $container + ->register(ConnectionConfigurator::class, ConnectionConfigurator::class) + ->setPublic(true); + + return $container; + } +} diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 3aa3424e..c377c242 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -4,6 +4,7 @@ namespace Sentry\SentryBundle\Tests\DependencyInjection; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; use PHPUnit\Framework\TestCase; use Sentry\SentryBundle\DependencyInjection\Configuration; @@ -35,6 +36,12 @@ public function testProcessConfigurationWithDefaultConfiguration(): void 'enabled' => interface_exists(MessageBusInterface::class), 'capture_soft_fails' => true, ], + 'tracing' => [ + 'dbal' => [ + 'enabled' => false, + 'connections' => class_exists(DoctrineBundle::class) ? ['%doctrine.default_connection%'] : [], + ], + ], ]; $this->assertSame($expectedBundleDefaultConfig, $this->processConfiguration([])); diff --git a/tests/DependencyInjection/Fixtures/php/dbal_tracing_enabled.php b/tests/DependencyInjection/Fixtures/php/dbal_tracing_enabled.php new file mode 100644 index 00000000..85794aa1 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/php/dbal_tracing_enabled.php @@ -0,0 +1,15 @@ +loadFromExtension('sentry', [ + 'tracing' => [ + 'dbal' => [ + 'enabled' => true, + 'connections' => ['default'], + ], + ], +]); diff --git a/tests/DependencyInjection/Fixtures/php/full.php b/tests/DependencyInjection/Fixtures/php/full.php index 5c4c2207..1e3b848f 100644 --- a/tests/DependencyInjection/Fixtures/php/full.php +++ b/tests/DependencyInjection/Fixtures/php/full.php @@ -42,4 +42,10 @@ 'enabled' => true, 'capture_soft_fails' => false, ], + 'tracing' => [ + 'dbal' => [ + 'enabled' => false, + 'connections' => ['default'], + ], + ], ]); diff --git a/tests/DependencyInjection/Fixtures/xml/dbal_tracing_enabled.xml b/tests/DependencyInjection/Fixtures/xml/dbal_tracing_enabled.xml new file mode 100644 index 00000000..9c4bc03f --- /dev/null +++ b/tests/DependencyInjection/Fixtures/xml/dbal_tracing_enabled.xml @@ -0,0 +1,16 @@ + + + + + + + + default + + + + diff --git a/tests/DependencyInjection/Fixtures/xml/full.xml b/tests/DependencyInjection/Fixtures/xml/full.xml index 486231f1..b1eda4d7 100644 --- a/tests/DependencyInjection/Fixtures/xml/full.xml +++ b/tests/DependencyInjection/Fixtures/xml/full.xml @@ -37,5 +37,10 @@ App\Sentry\Serializer\FooClassSerializer + + + default + + diff --git a/tests/DependencyInjection/Fixtures/yml/dbal_tracing_enabled.yml b/tests/DependencyInjection/Fixtures/yml/dbal_tracing_enabled.yml new file mode 100644 index 00000000..d5708431 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/yml/dbal_tracing_enabled.yml @@ -0,0 +1,6 @@ +sentry: + tracing: + dbal: + enabled: true + connections: + - default diff --git a/tests/DependencyInjection/Fixtures/yml/full.yml b/tests/DependencyInjection/Fixtures/yml/full.yml index baae8e2e..f0ce0a3b 100644 --- a/tests/DependencyInjection/Fixtures/yml/full.yml +++ b/tests/DependencyInjection/Fixtures/yml/full.yml @@ -37,3 +37,8 @@ sentry: messenger: enabled: true capture_soft_fails: false + tracing: + dbal: + enabled: false + connections: + - enabled diff --git a/tests/DependencyInjection/SentryExtensionTest.php b/tests/DependencyInjection/SentryExtensionTest.php index 0824621e..af040d43 100644 --- a/tests/DependencyInjection/SentryExtensionTest.php +++ b/tests/DependencyInjection/SentryExtensionTest.php @@ -4,6 +4,7 @@ namespace Sentry\SentryBundle\Tests\DependencyInjection; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Jean85\PrettyVersions; use PHPUnit\Framework\TestCase; use Sentry\ClientInterface; @@ -16,6 +17,8 @@ use Sentry\SentryBundle\EventListener\RequestListener; use Sentry\SentryBundle\EventListener\SubRequestListener; use Sentry\SentryBundle\SentryBundle; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\ConnectionConfigurator; +use Sentry\SentryBundle\Tracing\Doctrine\DBAL\TracingDriverMiddleware; use Sentry\Serializer\RepresentationSerializer; use Sentry\Serializer\Serializer; use Sentry\Transport\TransportFactoryInterface; @@ -261,12 +264,36 @@ public function testIgnoreErrorsIntegrationIsNotAddedTwiceIfAlreadyConfigured(): $this->assertSame(1, $ignoreErrorsIntegrationsCount); } + public function testTracingDriverMiddlewareIsConfiguredWhenDbalTracingIsEnable(): void + { + if (!class_exists(DoctrineBundle::class)) { + $this->markTestSkipped('This test requires the "doctrine/doctrine-bundle" Composer package to be installed.'); + } + + $container = $this->createContainerFromFixture('dbal_tracing_enabled'); + + $this->assertTrue($container->hasDefinition(TracingDriverMiddleware::class)); + $this->assertTrue($container->hasDefinition(ConnectionConfigurator::class)); + $this->assertNotEmpty($container->getParameter('sentry.tracing.dbal.connections')); + } + + public function testTracingDriverMiddlewareIsRemovedWhenDbalTracingIsDisabled(): void + { + $container = $this->createContainerFromFixture('full'); + + $this->assertFalse($container->hasDefinition(TracingDriverMiddleware::class)); + $this->assertFalse($container->hasDefinition(ConnectionConfigurator::class)); + $this->assertEmpty($container->getParameter('sentry.tracing.dbal.connections')); + } + private function createContainerFromFixture(string $fixtureFile): ContainerBuilder { $container = new ContainerBuilder(new EnvPlaceholderParameterBag([ 'kernel.cache_dir' => __DIR__, 'kernel.build_dir' => __DIR__, 'kernel.project_dir' => __DIR__, + 'doctrine.default_connection' => 'default', + 'doctrine.connections' => ['default'], ])); $container->registerExtension(new SentryExtension()); diff --git a/tests/DoctrineTestCase.php b/tests/DoctrineTestCase.php new file mode 100644 index 00000000..f5d32981 --- /dev/null +++ b/tests/DoctrineTestCase.php @@ -0,0 +1,22 @@ +hub = $this->createMock(HubInterface::class); + $this->decoratedConnection = $this->createMock(DriverConnectionInterface::class); + $this->connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', []); + } + + /** + * @dataProvider tagsDataProvider + * + * @param array $params + * @param array $expectedTags + */ + public function testPrepare(array $params, array $expectedTags): void + { + $statement = $this->createMock(DriverStatementInterface::class); + $connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', $params); + $sql = 'SELECT 1 + 1'; + + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->decoratedConnection->expects($this->once()) + ->method('prepare') + ->with($sql) + ->willReturn($statement); + + $this->assertSame($statement, $connection->prepare($sql)); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame(TracingDriverConnection::SPAN_OP_CONN_PREPARE, $spans[1]->getOp()); + $this->assertSame($sql, $spans[1]->getDescription()); + $this->assertSame($expectedTags, $spans[1]->getTags()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + } + + public function testPrepareDoesNothingIfNoSpanIsSetOnHub(): void + { + $statement = $this->createMock(DriverStatementInterface::class); + $sql = 'SELECT 1 + 1'; + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->decoratedConnection->expects($this->once()) + ->method('prepare') + ->with($sql) + ->willReturn($statement); + + $this->assertSame($statement, $this->connection->prepare($sql)); + } + + /** + * @dataProvider tagsDataProvider + * + * @param array $params + * @param array $expectedTags + */ + public function testQuery(array $params, array $expectedTags): void + { + $result = $this->createMock(DriverResultInterface::class); + $connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', $params); + $sql = 'SELECT 1 + 1'; + + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->decoratedConnection->expects($this->once()) + ->method('query') + ->with($sql) + ->willReturn($result); + + $this->assertSame($result, $connection->query($sql)); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame(TracingDriverConnection::SPAN_OP_CONN_QUERY, $spans[1]->getOp()); + $this->assertSame($sql, $spans[1]->getDescription()); + $this->assertSame($expectedTags, $spans[1]->getTags()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + } + + public function testQueryDoesNothingIfNoSpanIsSetOnHub(): void + { + $result = $this->createMock(DriverResultInterface::class); + $sql = 'SELECT 1 + 1'; + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->decoratedConnection->expects($this->once()) + ->method('query') + ->with($sql) + ->willReturn($result); + + $this->assertSame($result, $this->connection->query($sql)); + } + + public function testQuote(): void + { + $this->decoratedConnection->expects($this->once()) + ->method('quote') + ->with('foo', ParameterType::STRING) + ->willReturn('foo'); + + $this->assertSame('foo', $this->connection->quote('foo')); + } + + /** + * @dataProvider tagsDataProvider + * + * @param array $params + * @param array $expectedTags + */ + public function testExec(array $params, array $expectedTags): void + { + $connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', $params); + $sql = 'SELECT 1 + 1'; + + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->decoratedConnection->expects($this->once()) + ->method('exec') + ->with($sql) + ->willReturn(10); + + $this->assertSame(10, $connection->exec($sql)); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame(TracingDriverConnection::SPAN_OP_CONN_EXEC, $spans[1]->getOp()); + $this->assertSame($sql, $spans[1]->getDescription()); + $this->assertSame($expectedTags, $spans[1]->getTags()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + } + + public function testLastInsertId(): void + { + $this->decoratedConnection->expects($this->once()) + ->method('lastInsertId') + ->with('foo') + ->willReturn('10'); + + $this->assertSame('10', $this->connection->lastInsertId('foo')); + } + + /** + * @dataProvider tagsDataProvider + * + * @param array $params + * @param array $expectedTags + */ + public function testBeginTransaction(array $params, array $expectedTags): void + { + $connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', $params); + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->decoratedConnection->expects($this->once()) + ->method('beginTransaction') + ->willReturn(false); + + $this->assertFalse($connection->beginTransaction()); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame(TracingDriverConnection::SPAN_OP_CONN_BEGIN_TRANSACTION, $spans[1]->getOp()); + $this->assertSame('BEGIN TRANSACTION', $spans[1]->getDescription()); + $this->assertSame($expectedTags, $spans[1]->getTags()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + } + + public function testBeginTransactionDoesNothingIfNoSpanIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->decoratedConnection->expects($this->once()) + ->method('beginTransaction') + ->willReturn(false); + + $this->assertFalse($this->connection->beginTransaction()); + } + + /** + * @dataProvider tagsDataProvider + * + * @param array $params + * @param array $expectedTags + */ + public function testCommit(array $params, array $expectedTags): void + { + $connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', $params); + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->decoratedConnection->expects($this->once()) + ->method('commit') + ->willReturn(false); + + $this->assertFalse($connection->commit()); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame(TracingDriverConnection::SPAN_OP_TRANSACTION_COMMIT, $spans[1]->getOp()); + $this->assertSame('COMMIT', $spans[1]->getDescription()); + $this->assertSame($expectedTags, $spans[1]->getTags()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + } + + public function testCommitDoesNothingIfNoSpanIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->decoratedConnection->expects($this->once()) + ->method('commit') + ->willReturn(false); + + $this->assertFalse($this->connection->commit()); + } + + /** + * @dataProvider tagsDataProvider + * + * @param array $params + * @param array $expectedTags + */ + public function testRollBack(array $params, array $expectedTags): void + { + $connection = new TracingDriverConnection($this->hub, $this->decoratedConnection, 'foo_platform', $params); + $transaction = new Transaction(new TransactionContext(), $this->hub); + $transaction->initSpanRecorder(); + + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn($transaction); + + $this->decoratedConnection->expects($this->once()) + ->method('rollBack') + ->willReturn(false); + + $this->assertFalse($connection->rollBack()); + + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(2, $spans); + $this->assertSame(TracingDriverConnection::SPAN_OP_TRANSACTION_ROLLBACK, $spans[1]->getOp()); + $this->assertSame('ROLLBACK', $spans[1]->getDescription()); + $this->assertSame($expectedTags, $spans[1]->getTags()); + $this->assertNotNull($spans[1]->getEndTimestamp()); + } + + public function testRollBackDoesNothingIfNoSpanIsSetOnHub(): void + { + $this->hub->expects($this->once()) + ->method('getSpan') + ->willReturn(null); + + $this->decoratedConnection->expects($this->once()) + ->method('rollBack') + ->willReturn(false); + + $this->assertFalse($this->connection->rollBack()); + } + + /** + * @return \Generator + */ + public function tagsDataProvider(): \Generator + { + yield [ + [], + ['db.system' => 'foo_platform'], + ]; + + yield [ + [ + 'user' => 'root', + 'dbname' => 'INFORMATION_SCHEMA', + 'port' => 3306, + 'unix_socket' => '/var/run/mysqld/mysqld.sock', + ], + [ + 'db.system' => 'foo_platform', + 'db.user' => 'root', + 'db.name' => 'INFORMATION_SCHEMA', + 'net.peer.port' => '3306', + 'net.transport' => 'Unix', + ], + ]; + + yield [ + [ + 'user' => 'root', + 'dbname' => 'INFORMATION_SCHEMA', + 'port' => 3306, + 'memory' => true, + ], + [ + 'db.system' => 'foo_platform', + 'db.user' => 'root', + 'db.name' => 'INFORMATION_SCHEMA', + 'net.peer.port' => '3306', + 'net.transport' => 'inproc', + ], + ]; + + yield [ + [ + 'host' => 'localhost', + ], + [ + 'db.system' => 'foo_platform', + 'net.peer.name' => 'localhost', + ], + ]; + + yield [ + [ + 'host' => '127.0.0.1', + ], + [ + 'db.system' => 'foo_platform', + 'net.peer.ip' => '127.0.0.1', + ], + ]; + } +} diff --git a/tests/Tracing/Doctrine/DBAL/TracingDriverMiddlewareTest.php b/tests/Tracing/Doctrine/DBAL/TracingDriverMiddlewareTest.php new file mode 100644 index 00000000..69dd5683 --- /dev/null +++ b/tests/Tracing/Doctrine/DBAL/TracingDriverMiddlewareTest.php @@ -0,0 +1,45 @@ +hub = $this->createMock(HubInterface::class); + $this->middleware = new TracingDriverMiddleware($this->hub); + } + + public function testWrap(): void + { + $driver = $this->createMock(DriverInterface::class); + + $this->assertInstanceOf(TracingDriver::class, $this->middleware->wrap($driver)); + } +} diff --git a/tests/Tracing/Doctrine/DBAL/TracingDriverTest.php b/tests/Tracing/Doctrine/DBAL/TracingDriverTest.php new file mode 100644 index 00000000..6fa50e9e --- /dev/null +++ b/tests/Tracing/Doctrine/DBAL/TracingDriverTest.php @@ -0,0 +1,266 @@ +hub = $this->createMock(HubInterface::class); + } + + public static function setUpBeforeClass(): void + { + if (!self::isDoctrineBundlePackageInstalled()) { + self::markTestSkipped(); + } + } + + public function testConnect(): void + { + $databasePlatform = $this->createMock(AbstractPlatform::class); + $databasePlatform->expects($this->once()) + ->method('getName') + ->willReturn('foo'); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('connect') + ->with(['host' => 'localhost'], 'username', 'password', ['foo' => 'bar']) + ->willReturn($this->createMock(DriverConnectionInterface::class)); + + $decoratedDriver->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($databasePlatform); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertInstanceOf(TracingDriverConnection::class, $driver->connect(['host' => 'localhost'], 'username', 'password', ['foo' => 'bar'])); + } + + public function testGetDatabasePlatform(): void + { + $databasePlatform = $this->createMock(AbstractPlatform::class); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($databasePlatform); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame($databasePlatform, $driver->getDatabasePlatform()); + } + + public function testGetSchemaManager(): void + { + $connection = $this->createMock(Connection::class); + $databasePlatform = $this->createMock(AbstractPlatform::class); + $schemaManager = $this->createMock(AbstractSchemaManager::class); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('getSchemaManager') + ->with($connection, $databasePlatform) + ->willReturn($schemaManager); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame($schemaManager, $driver->getSchemaManager($connection, $databasePlatform)); + } + + public function testGetExceptionConverter(): void + { + if (!self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be >= 3.0.'); + } + + $exceptionConverter = $this->createMock(ExceptionConverter::class); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('getExceptionConverter') + ->willReturn($exceptionConverter); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame($exceptionConverter, $driver->getExceptionConverter()); + } + + public function testGetExceptionConverterThrowsIfDoctrineDBALVersionIsLowerThan30(): void + { + if (self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be < 3.0.'); + } + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('The Sentry\\SentryBundle\\Tracing\\Doctrine\\DBAL\\TracingDriver::getExceptionConverter() method is not supported on Doctrine DBAL 2.x.'); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $driver->getExceptionConverter(); + } + + public function testGetNameIfDoctrineDBALVersionIsLowerThan30(): void + { + if (self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be < 3.0.'); + } + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('getName') + ->willReturn('foo'); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame('foo', $driver->getName()); + } + + public function testGetNameThrowsIfDoctrineDBALVersionIsAtLeast30(): void + { + if (!self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be >= 3.0.'); + } + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('The Sentry\\SentryBundle\\Tracing\\Doctrine\\DBAL\\TracingDriver::getName() method is not supported on Doctrine DBAL 3.0.'); + + $driver = new TracingDriver($this->hub, $this->createMock(DriverInterface::class)); + $driver->getName(); + } + + public function testGetDatabaseIfDoctrineDBALVersionIsLowerThan30(): void + { + if (self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be < 3.0.'); + } + + $connection = $this->createMock(Connection::class); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('getDatabase') + ->with($connection) + ->willReturn('foo'); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame('foo', $driver->getDatabase($connection)); + } + + public function testGetDatabaseThrowsIfDoctrineDBALVersionIsAtLeast30(): void + { + if (!self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be >= 3.0.'); + } + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('The Sentry\\SentryBundle\\Tracing\\Doctrine\\DBAL\\TracingDriver::getDatabase() method is not supported on Doctrine DBAL 3.0.'); + + $driver = new TracingDriver($this->hub, $this->createMock(DriverInterface::class)); + $driver->getDatabase($this->createMock(Connection::class)); + } + + public function testCreateDatabasePlatformForVersion(): void + { + $databasePlatform = $this->createMock(AbstractPlatform::class); + + $decoratedDriver = $this->createMock(StubVersionAwarePlatformDriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('createDatabasePlatformForVersion') + ->with('5.7') + ->willReturn($databasePlatform); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame($databasePlatform, $driver->createDatabasePlatformForVersion('5.7')); + } + + public function testCreateDatabasePlatformForVersionWhenDriverDoesNotImplementInterface(): void + { + $databasePlatform = $this->createMock(AbstractPlatform::class); + + $decoratedDriver = $this->createMock(DriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('getDatabasePlatform') + ->willReturn($databasePlatform); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame($databasePlatform, $driver->createDatabasePlatformForVersion('5.7')); + } + + public function testConvertException(): void + { + if (self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be <= 3.0.'); + } + + $exception = $this->createMock(DriverExceptionInterface::class); + $convertedException = new DBALDriverException('foo', $exception); + + $decoratedDriver = $this->createMock(StubExceptionConverterDriverInterface::class); + $decoratedDriver->expects($this->once()) + ->method('convertException') + ->with('foo', $exception) + ->willReturn($convertedException); + + $driver = new TracingDriver($this->hub, $decoratedDriver); + + $this->assertSame($convertedException, $driver->convertException('foo', $exception)); + } + + public function testConvertExceptionThrowsIfDoctrineDBALVersionIsAtLeast30(): void + { + if (!self::isDoctrineDBALVersion3Installed()) { + $this->markTestSkipped('This test requires the version of the "doctrine/dbal" Composer package to be >= 3.0.'); + } + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('The Sentry\\SentryBundle\\Tracing\\Doctrine\\DBAL\\TracingDriver::convertException() method is not supported on Doctrine DBAL 3.0.'); + + $driver = new TracingDriver($this->hub, $this->createMock(StubExceptionConverterDriverInterface::class)); + $driver->convertException('foo', $this->createMock(DriverExceptionInterface::class)); + } +} + +if (interface_exists(VersionAwarePlatformDriverInterface::class)) { + interface StubVersionAwarePlatformDriverInterface extends DriverInterface, VersionAwarePlatformDriverInterface + { + } +} + +if (interface_exists(ExceptionConverterDriverInterface::class)) { + interface StubExceptionConverterDriverInterface extends ExceptionConverterDriverInterface, DriverInterface + { + } +} else { + interface StubExceptionConverterDriverInterface extends DriverInterface + { + } +}