diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2fe06941..2d8cb27a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -32,6 +32,7 @@ jobs: 'Instrumentation/IO', 'Instrumentation/Laravel', 'Instrumentation/MongoDB', + 'Instrumentation/MySqli', 'Instrumentation/OpenAIPHP', 'Instrumentation/PDO', # Sort PSRs numerically. @@ -92,6 +93,12 @@ jobs: php-version: 8.0 - project: 'Instrumentation/Curl' php-version: 8.1 + - project: 'Instrumentation/MySqli' + php-version: 7.4 + - project: 'Instrumentation/MySqli' + php-version: 8.0 + - project: 'Instrumentation/MySqli' + php-version: 8.1 - project: 'Instrumentation/PDO' php-version: 7.4 - project: 'Instrumentation/PDO' @@ -132,7 +139,7 @@ jobs: with: php-version: ${{ matrix.php-version }} coverage: xdebug - extensions: ast, amqp, grpc, opentelemetry, rdkafka + extensions: ast, amqp, grpc, opentelemetry, rdkafka, mysqli - name: Validate composer.json and composer.lock run: composer validate @@ -192,6 +199,11 @@ jobs: run: | KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:29092,PLAINTEXT_HOST://localhost:9092 docker compose up kafka -d --wait + - name: Start Mysql + if: ${{ matrix.project == 'Instrumentation/MySqli' }} + run: | + docker compose up mysql -d --wait + - name: Run PHPUnit working-directory: src/${{ matrix.project }} run: vendor/bin/phpunit diff --git a/.gitsplit.yml b/.gitsplit.yml index 06edf178..23ee30cc 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -30,6 +30,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-laravel.git" - prefix: "src/Instrumentation/MongoDB" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-mongodb.git" + - prefix: "src/Instrumentation/MySqli" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-mysqli.git" - prefix: "src/Instrumentation/OpenAIPHP" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-auto-openai.git" - prefix: "src/Instrumentation/PDO" diff --git a/composer.json b/composer.json index c60c5fd2..7db6e192 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "OpenTelemetry\\Contrib\\Instrumentation\\HttpAsyncClient\\": "src/Instrumentation/HttpAsyncClient/src", "OpenTelemetry\\Contrib\\Instrumentation\\IO\\": "src/Instrumentation/IO/src", "OpenTelemetry\\Contrib\\Instrumentation\\MongoDB\\": "src/Instrumentation/MongoDB/src", + "OpenTelemetry\\Contrib\\Instrumentation\\MySqli\\": "src/Instrumentation/MySqli/src", "OpenTelemetry\\Contrib\\Instrumentation\\PDO\\": "src/Instrumentation/PDO/src", "OpenTelemetry\\Contrib\\Instrumentation\\Psr3\\": "src/Instrumentation/Psr3/src", "OpenTelemetry\\Contrib\\Instrumentation\\Psr15\\": "src/Instrumentation/Psr15/src", @@ -47,6 +48,7 @@ "src/Instrumentation/IO/_register.php", "src/Instrumentation/Laravel/_register.php", "src/Instrumentation/MongoDB/_register.php", + "src/Instrumentation/MySqli/_register.php", "src/Instrumentation/PDO/_register.php", "src/Instrumentation/Psr3/_register.php", "src/Instrumentation/Psr15/_register.php", @@ -66,6 +68,7 @@ "open-telemetry/opentelemetry-auto-http-async": "self.version", "open-telemetry/opentelemetry-auto-io": "self.version", "open-telemetry/opentelemetry-auto-mongodb": "self.version", + "open-telemetry/opentelemetry-auto-mysqli": "self.version", "open-telemetry/opentelemetry-auto-pdo": "self.version", "open-telemetry/opentelemetry-auto-psr3": "self.version", "open-telemetry/opentelemetry-auto-psr15": "self.version", diff --git a/docker-compose.yaml b/docker-compose.yaml index e9d99f4a..2d3ad109 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -14,6 +14,8 @@ services: PHP_IDE_CONFIG: ${PHP_IDE_CONFIG:-''} RABBIT_HOST: ${RABBIT_HOST:-rabbitmq} KAFKA_HOST: ${KAFKA_HOST:-kafka} + MYSQL_HOST: ${MYSQL_HOST:-mysql} + zipkin: image: openzipkin/zipkin-slim @@ -61,4 +63,20 @@ services: volumes: - ./docker/kafka/update_run.sh:/tmp/update_run.sh - + mysql: + image: mysql:8.0 + hostname: mysql + ports: + - "3306:3306/tcp" + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: otel_db + MYSQL_USER: otel_user + MYSQL_PASSWORD: otel_passwd + healthcheck: + test: mysql -uotel_user -potel_passwd -e "USE otel_db;" + interval: 30s + timeout: 30s + retries: 3 + volumes: + - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql diff --git a/docker/Dockerfile b/docker/Dockerfile index 9a5951e6..ebfc9b25 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,6 +7,7 @@ RUN install-php-extensions \ opentelemetry \ mongodb \ amqp \ - rdkafka + rdkafka \ + mysqli USER php diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql new file mode 100644 index 00000000..e1b5c346 --- /dev/null +++ b/docker/mysql/init.sql @@ -0,0 +1,34 @@ +CREATE DATABASE IF NOT EXISTS otel_db2; +CREATE USER 'otel_user2'@'%' IDENTIFIED BY 'otel_passwd'; + + +GRANT ALL PRIVILEGES ON *.* TO 'otel_user'@'%'; +GRANT ALL PRIVILEGES ON *.* TO 'otel_user2'@'%'; +FLUSH PRIVILEGES; + + +USE otel_db; + +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO users (name, email) VALUES +('John Doe', 'john.doe@example.com'), +('Jane Smith', 'jane.smith@example.com'), +('Bob Johnson', 'bob.johnson@example.com'); + +CREATE TABLE products ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10, 2) NOT NULL, + stock INT NOT NULL DEFAULT 0 +); + +INSERT INTO products (name, price, stock) VALUES +('Laptop', 999.99, 10), +('Smartphone', 499.99, 25), +('Headphones', 49.99, 50); diff --git a/src/Instrumentation/MySqli/.gitattributes b/src/Instrumentation/MySqli/.gitattributes new file mode 100644 index 00000000..1676cf82 --- /dev/null +++ b/src/Instrumentation/MySqli/.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/MySqli/.gitignore b/src/Instrumentation/MySqli/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/src/Instrumentation/MySqli/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/Instrumentation/MySqli/.php-cs-fixer.php b/src/Instrumentation/MySqli/.php-cs-fixer.php new file mode 100644 index 00000000..e35fa078 --- /dev/null +++ b/src/Instrumentation/MySqli/.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/MySqli/README.md b/src/Instrumentation/MySqli/README.md new file mode 100644 index 00000000..01a08d87 --- /dev/null +++ b/src/Instrumentation/MySqli/README.md @@ -0,0 +1,56 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-auto-mysqli/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/MySqli) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-auto-mysqli) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-auto-mysqli/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-mysqli/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-auto-mysqli/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-auto-mysqli/) + +This is a read-only subtree split of https://github.com/open-telemetry/opentelemetry-php-contrib. + +# OpenTelemetry mysqli 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 client kind spans will automatically be created when calling following functions or methods: + +* `mysqli_connect` +* `mysqli::__construct` +* `mysqli::connect` +* `mysqli_real_connect` +* `mysqli::real_connect` + +* `mysqli_query` +* `mysqli::query` +* `mysqli_real_query` +* `mysqli::real_query` +* `mysqli_execute_query` +* `mysqli::execute_query` +* `mysqli_multi_query` +* `mysqli::multi_query` +* `mysqli_next_result` +* `mysqli::next_result` + +* `mysqli_begin_transaction` +* `mysqli::begin_transaction` +* `mysqli_rollback` +* `mysqli::rollback` +* `mysqli_commit` +* `mysqli::commit` +* +* `mysqli_stmt_execute` +* `mysqli_stmt::execute` +* `mysqli_stmt_next_result` +* `mysqli_stmt::next_result` + +## Configuration + +### Disabling mysqli instrumentation + +The extension can be disabled via [runtime configuration](https://opentelemetry.io/docs/instrumentation/php/sdk/#configuration): + +```shell +OTEL_PHP_DISABLED_INSTRUMENTATIONS=mysqli +``` + diff --git a/src/Instrumentation/MySqli/_register.php b/src/Instrumentation/MySqli/_register.php new file mode 100644 index 00000000..10cb9823 --- /dev/null +++ b/src/Instrumentation/MySqli/_register.php @@ -0,0 +1,18 @@ + + + + + + + src + + + + + + + + + + + + + tests/Unit + + + tests/Integration + + + + diff --git a/src/Instrumentation/MySqli/psalm.xml.dist b/src/Instrumentation/MySqli/psalm.xml.dist new file mode 100644 index 00000000..5a04b34d --- /dev/null +++ b/src/Instrumentation/MySqli/psalm.xml.dist @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php new file mode 100644 index 00000000..4ee60abc --- /dev/null +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -0,0 +1,832 @@ +storeMySqliAttributes($mysqliObject, $params[$paramsOffset + 0] ?? null, $params[$paramsOffset + 1] ?? null, $params[$paramsOffset + 3] ?? null, $params[$paramsOffset + 4] ?? null, null); + } + + self::endSpan([], $exception, ($retVal === false && !$exception) ? mysqli_connect_error() : null); + } + + /** @param non-empty-string $spanName */ + private static function queryPreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + $mysqli = $obj ? $obj : $params[0]; + self::addTransactionLink($tracker, $span, $mysqli); + } + + private static function queryPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $mysqli = $obj ? $obj : $params[0]; + $query = $obj ? $params[0] : $params[1]; + + $attributes = $tracker->getMySqliAttributes($mysqli); + + $attributes[TraceAttributes::DB_STATEMENT] = mb_convert_encoding($query, 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($query); + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $mysqli->errno; + } + + $errorStatus = ($retVal === false && !$exception) ? $mysqli->error : null; + self::endSpan($attributes, $exception, $errorStatus); + + } + + private static function multiQueryPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + + $mysqli = $obj ? $obj : $params[0]; + $query = $obj ? $params[0] : $params[1]; + + $attributes = $tracker->getMySqliAttributes($mysqli); + + $tracker->storeMySqliMultiQuery($mysqli, $query); + if ($currentQuery = $tracker->getNextMySqliMultiQuery($mysqli)) { + $attributes[TraceAttributes::DB_STATEMENT] = mb_convert_encoding($currentQuery, 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($currentQuery); + } + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $mysqli->errno; + } else { + $tracker->trackMySqliSpan($mysqli, Span::getCurrent()->getContext()); + } + + $errorStatus = ($retVal === false && !$exception) ? $mysqli->error : null; + self::endSpan($attributes, $exception, $errorStatus); + + } + + /** @param non-empty-string $spanName */ + private static function nextResultPreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + $mysqli = $obj ? $obj : $params[0]; + if ($mysqli instanceof mysqli && ($spanContext = $tracker->getMySqliSpan($mysqli))) { + $span->addLink($spanContext); + } + + self::addTransactionLink($tracker, $span, $mysqli); + } + + private static function nextResultPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + + $mysqli = $obj ? $obj : $params[0]; + + $errorStatus = ($retVal === false && !$exception) ? (strlen($mysqli->error) > 0 ? $mysqli->error : null) : null; + + $attributes = $tracker->getMySqliAttributes($mysqli); + + $currentQuery = $tracker->getNextMySqliMultiQuery($mysqli); + + // it was just a call to check if there is a pending query + if ($currentQuery === null || ($retVal === false && !$errorStatus && !$exception)) { + self::logDebug('nextResultPostHook span dropped', ['exception' => $exception, 'obj' => $obj, 'retVal' => $retVal, 'params' => $params, 'currentQuery' => $currentQuery]); + self::dropSpan(); + + return; + } + + if ($currentQuery) { + $attributes[TraceAttributes::DB_STATEMENT] = mb_convert_encoding($currentQuery, 'UTF-8'); + $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($currentQuery); + } + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $mysqli->errno; + } + + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function changeUserPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + if ($retVal != true) { + return; + } + + $mysqli = $obj ? $obj : $params[0]; + + $tracker->addMySqliAttribute($mysqli, TraceAttributes::DB_USER, $params[$obj ? 0 : 1]); + if (($database = $params[$obj ? 2 : 3] ?? null) !== null) { + $tracker->addMySqliAttribute($mysqli, TraceAttributes::DB_NAMESPACE, $database); + } + + } + + private static function selectDbPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + if ($retVal != true) { + return; + } + $tracker->addMySqliAttribute($obj ? $obj : $params[0], TraceAttributes::DB_NAMESPACE, $params[$obj ? 0 : 1]); + } + + /** @param non-empty-string $spanName */ + private static function preparePreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + $mysqli = $obj ? $obj : $params[0]; + self::addTransactionLink($tracker, $span, $mysqli); + } + + private static function preparePostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $stmtRetVal, ?\Throwable $exception) + { + $mysqli = $obj ? $obj : $params[0]; + $query = $params[$obj ? 0 : 1]; + + $errorStatus = null; + + $query = mb_convert_encoding($query, 'UTF-8'); + $operation = self::extractQueryCommand($query); + + $attributes = $tracker->getMySqliAttributes($mysqli); + $attributes[TraceAttributes::DB_STATEMENT] = $query; + $attributes[TraceAttributes::DB_OPERATION_NAME] = $operation; + + if (!$exception && $stmtRetVal instanceof mysqli_stmt) { + $tracker->trackMySqliFromStatement($mysqli, $stmtRetVal); + $tracker->addStatementAttribute($stmtRetVal, TraceAttributes::DB_STATEMENT, $query); + $tracker->addStatementAttribute($stmtRetVal, TraceAttributes::DB_OPERATION_NAME, $operation); + + } else { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $mysqli->errno; + $errorStatus = !$exception ? $mysqli->error : null; + } + + self::endSpan($attributes, $exception, $errorStatus); + } + + /** @param non-empty-string $spanName */ + private static function beginTransactionPreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + } + + private static function beginTransactionPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $mysqli = $obj ? $obj : $params[0]; + $transactionName = $params[$obj ? 1 : 2] ?? null; + + $attributes = $tracker->getMySqliAttributes($mysqli); + + if ($transactionName) { + $attributes['db.transaction.name'] = $transactionName; + } + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $mysqli->errno; + } else { + $tracker->trackMySqliTransaction($mysqli, Span::getCurrent()->getContext()); + } + + $errorStatus = ($retVal === false && !$exception) ? $mysqli->error : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + /** @param non-empty-string $spanName */ + private static function transactionPreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + $mysqli = $obj ? $obj : $params[0]; + self::addTransactionLink($tracker, $span, $mysqli); + } + + private static function transactionPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $mysqli = $obj ? $obj : $params[0]; + $transactionName = $params[$obj ? 1 : 2] ?? null; + + $attributes = $tracker->getMySqliAttributes($mysqli); + + if ($transactionName) { + $attributes['db.transaction.name'] = $transactionName; + } + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $mysqli->errno; + } + + $tracker->untrackMySqliTransaction($mysqli); + + $errorStatus = ($retVal === false && !$exception) ? $mysqli->error : null; + self::endSpan($attributes, $exception, $errorStatus); + } + + private static function stmtInitPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $mySqliObj, array $params, mixed $retVal, ?\Throwable $exception) + { + if ($retVal !== false) { + $tracker->trackMySqliFromStatement($mySqliObj ? $mySqliObj : $params[0], $retVal); + } + } + + private static function stmtPreparePostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + // There is no need to create a span for prepare. It is a partial operation that is not executed on the database, so we do not need to measure its execution time. + if ($retVal != true) { + self::logDebug('mysqli::prepare failed', ['exception' => $exception, 'obj' => $obj, 'retVal' => $retVal, 'params' => $params]); + + return; + } + + $query = $obj ? $params[0] : $params[1]; + $tracker->addStatementAttribute($obj ? $obj : $params[0], TraceAttributes::DB_STATEMENT, mb_convert_encoding($query, 'UTF-8')); + $tracker->addStatementAttribute($obj ? $obj : $params[0], TraceAttributes::DB_OPERATION_NAME, self::extractQueryCommand($query)); + } + + private static function stmtConstructPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $stmt, array $params, mixed $retVal, ?\Throwable $exception) + { + + if ($exception) { + self::logDebug('stmt::__construct failed', ['exception' => $exception, 'stmt' => $stmt, 'retVal' => $retVal, 'params' => $params]); + + return; + } + + $tracker->trackMySqliFromStatement($params[0], $stmt); + + if ($params[1] ?? null) { + $tracker->addStatementAttribute($stmt, TraceAttributes::DB_STATEMENT, mb_convert_encoding($params[1], 'UTF-8')); + $tracker->addStatementAttribute($stmt, TraceAttributes::DB_OPERATION_NAME, self::extractQueryCommand($params[1])); + } + } + + /** @param non-empty-string $spanName */ + private static function stmtExecutePreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + self::addTransactionLink($tracker, $span, $obj ? $obj : $params[0]); + } + + private static function stmtExecutePostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $stmt = $obj ? $obj : $params[0]; + $attributes = array_merge($tracker->getMySqliAttributesFromStatement($stmt), $tracker->getStatementAttributes($stmt)); + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $stmt->errno; + } + + $errorStatus = ($retVal === false && !$exception) ? $stmt->error : null; + + $tracker->trackStatementSpan($stmt, Span::getCurrent()->getContext()); + + self::endSpan($attributes, $exception, $errorStatus); + + } + + /** @param non-empty-string $spanName */ + private static function stmtNextResultPreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + + $stmt = $obj ? $obj : $params[0]; + if ($spanContext = $tracker->getStatementSpan($stmt)) { + $span->addLink($spanContext); + } + self::addTransactionLink($tracker, $span, $stmt); + } + + private static function stmtNextResultPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) + { + $stmt = $obj ? $obj : $params[0]; + $attributes = array_merge($tracker->getMySqliAttributesFromStatement($stmt), $tracker->getStatementAttributes($stmt)); + + if ($retVal === false && $stmt->errno == 0 && !$exception) { + // it was just a call to check if there is a pending result + self::logDebug('stmtNextResultPostHook span dropped', ['exception' => $exception, 'obj' => $obj, 'retVal' => $retVal, 'params' => $params]); + + self::dropSpan(); + + return; + } + + if ($retVal === false || $exception) { + //TODO use constant from comment after sem-conv update + $attributes[/*TraceAttributes::DB_RESPONSE_STATUS_CODE*/ 'db.response.status_code'] = $stmt->errno; + } + + $errorStatus = ($retVal === false && !$exception) ? $stmt->error : null; + + self::endSpan($attributes, $exception, $errorStatus); + } + + /** @param non-empty-string $spanName */ + private static function startSpan(string $spanName, CachedInstrumentation $instrumentation, ?string $class, ?string $function, ?string $filename, ?int $lineno, iterable $attributes) : SpanInterface + { + $parent = Context::getCurrent(); + $builder = $instrumentation->tracer() + ->spanBuilder($spanName) + ->setParent($parent) + ->setSpanKind(SpanKind::KIND_CLIENT) + ->setAttribute(TraceAttributes::CODE_FUNCTION, $function) + ->setAttribute(TraceAttributes::CODE_NAMESPACE, $class) + ->setAttribute(TraceAttributes::CODE_FILEPATH, $filename) + ->setAttribute(TraceAttributes::CODE_LINENO, $lineno) + ->setAttributes($attributes); + + $span = $builder->startSpan(); + $context = $span->storeInContext($parent); + + Context::storage()->attach($context); + + return $span; + } + + private static function endSpan(iterable $attributes, ?\Throwable $exception, ?string $errorStatus) + { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + $scope->detach(); + $span = Span::fromContext($scope->context()); + + $span->setAttributes($attributes); + + if ($errorStatus !== null) { + $span->setAttribute(TraceAttributes::EXCEPTION_MESSAGE, $errorStatus); + $span->setStatus(StatusCode::STATUS_ERROR, $errorStatus); + } + + if ($exception) { + $span->recordException($exception, [TraceAttributes::EXCEPTION_ESCAPED => true]); + $span->setAttribute(TraceAttributes::EXCEPTION_TYPE, $exception::class); + $span->setStatus(StatusCode::STATUS_ERROR, $exception->getMessage()); + } + + $span->end(); + } + + private static function dropSpan() + { + $scope = Context::storage()->scope(); + if (!$scope) { + return; + } + $scope->detach(); + } + + private static function addTransactionLink(MySqliTracker $tracker, SpanInterface $span, $mysqliOrStatement) + { + $mysqli = $mysqliOrStatement; + + if ($mysqli instanceof mysqli_stmt) { + $mysqli = $tracker->getMySqliFromStatement($mysqli); + } + + if ($mysqli instanceof mysqli && ($spanContext = $tracker->getMySqliTransaction($mysqli))) { + $span->addLink($spanContext); + } + } + + private static function extractQueryCommand($query) : ?string + { + $query = preg_replace("/\r\n|\n\r|\r/", "\n", $query); + /** @psalm-suppress PossiblyInvalidArgument */ + if (preg_match('/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*([a-zA-Z_][a-zA-Z0-9_]*)/i', $query, $matches)) { + return strtoupper($matches[1]); + } + + return null; + } + +} diff --git a/src/Instrumentation/MySqli/src/MySqliTracker.php b/src/Instrumentation/MySqli/src/MySqliTracker.php new file mode 100644 index 00000000..568a766a --- /dev/null +++ b/src/Instrumentation/MySqli/src/MySqliTracker.php @@ -0,0 +1,214 @@ +mySqliToAttributes = new WeakMap(); + $this->mySqliToMultiQueries = new WeakMap(); + $this->statementToMySqli = new WeakMap(); + $this->statementAttributes = new WeakMap(); + $this->statementSpan = new WeakMap(); + $this->mySqliSpan = new WeakMap(); + $this->mySqliTransaction = new WeakMap(); + } + + public function storeMySqliMultiQuery(mysqli $mysqli, string $query) + { + $this->mySqliToMultiQueries[$mysqli] = $this->splitQueries($query); + } + + public function getNextMySqliMultiQuery(mysqli $mysqli) : ?string + { + if (!$this->mySqliToMultiQueries->offsetExists($mysqli)) { + return null; + } + + return array_shift($this->mySqliToMultiQueries[$mysqli]); + } + + public function storeMySqliAttributes(mysqli $mysqli, ?string $hostname = null, ?string $username = null, ?string $database = null, ?int $port = null, ?string $socket = null) + { + $attributes = []; + $attributes[TraceAttributes::DB_SYSTEM] = 'mysql'; + $attributes[TraceAttributes::SERVER_ADDRESS] = $hostname ?? get_cfg_var('mysqli.default_host'); + $attributes[TraceAttributes::SERVER_PORT] = $port ?? get_cfg_var('mysqli.default_port'); + $attributes[TraceAttributes::DB_USER] = $username ?? get_cfg_var('mysqli.default_user'); + if ($database) { + $attributes[TraceAttributes::DB_NAMESPACE] = $database; + } + $this->mySqliToAttributes[$mysqli] = $attributes; + } + + public function addMySqliAttribute($mysqli, string $attribute, bool|int|float|string|array|null $value) + { + if (!$this->mySqliToAttributes->offsetExists($mysqli)) { + $this->mySqliToAttributes[$mysqli] = []; + } + $this->mySqliToAttributes[$mysqli][$attribute] = $value; + } + + public function getMySqliAttributes(mysqli $mysqli) : array + { + return $this->mySqliToAttributes[$mysqli] ?? []; + } + + public function trackMySqliFromStatement(mysqli $mysqli, mysqli_stmt $mysqli_stmt) + { + $this->statementToMySqli[$mysqli_stmt] = WeakReference::create($mysqli); + } + + public function getMySqliFromStatement(mysqli_stmt $mysqli_stmt) : ?mysqli + { + return ($this->statementToMySqli[$mysqli_stmt] ?? null)?->get(); + ; + } + + public function getMySqliAttributesFromStatement(mysqli_stmt $stmt) : array + { + $mysqli = ($this->statementToMySqli[$stmt] ?? null)?->get(); + if (!$mysqli) { + return []; + } + + return $this->getMySqliAttributes($mysqli); + } + + public function addStatementAttribute(mysqli_stmt $stmt, string $attribute, bool|int|float|string|array|null $value) + { + if (!$this->statementAttributes->offsetExists($stmt)) { + $this->statementAttributes[$stmt] = []; + } + $this->statementAttributes[$stmt][$attribute] = $value; + } + + public function getStatementAttributes(mysqli_stmt $stmt) : array + { + if (!$this->statementAttributes->offsetExists($stmt)) { + return []; + } + + return $this->statementAttributes[$stmt]; + } + + public function trackStatementSpan(mysqli_stmt $stmt, SpanContextInterface $spanContext) + { + $this->statementSpan[$stmt] = WeakReference::create($spanContext); + } + + public function getStatementSpan(mysqli_stmt $stmt) : ?SpanContextInterface + { + if (!$this->statementSpan->offsetExists($stmt)) { + return null; + } + + return $this->statementSpan[$stmt]->get(); + } + + public function trackMysqliSpan(mysqli $mysqli, SpanContextInterface $spanContext) + { + $this->mySqliSpan[$mysqli] = WeakReference::create($spanContext); + } + + public function getMySqliSpan(mysqli $mysqli) : ?SpanContextInterface + { + if (!$this->mySqliSpan->offsetExists($mysqli)) { + return null; + } + + return $this->mySqliSpan[$mysqli]->get(); + } + + public function trackMySqliTransaction(mysqli $mysqli, SpanContextInterface $spanContext) + { + $this->mySqliTransaction[$mysqli] = WeakReference::create($spanContext); + } + + public function getMySqliTransaction(mysqli $mysqli) : ?SpanContextInterface + { + if (!$this->mySqliTransaction->offsetExists($mysqli)) { + return null; + } + + return $this->mySqliTransaction[$mysqli]->get(); + } + + public function untrackMySqliTransaction(mysqli $mysqli) + { + if ($this->mySqliTransaction->offsetExists($mysqli)) { + unset($this->mySqliTransaction[$mysqli]); + } + } + + private function splitQueries(string $sql) + { + // Normalize line endings to \n + $sql = preg_replace("/\r\n|\n\r|\r/", "\n", $sql); + + $queries = []; + $buffer = ''; + $blockDepth = 0; + $tokens = preg_split('/(;)/', $sql, -1, PREG_SPLIT_DELIM_CAPTURE); // Keep semicolons as separate tokens + + foreach ($tokens as $token) { + if ($token === '') { + continue; + } + + if ($blockDepth === 0) { + $token = trim($token); + } + + $buffer .= $token; + + // Detect BEGIN with optional label + if (preg_match('/(^|\s|[)])\bBEGIN\b/i', $token)) { + $blockDepth++; + } + + // Detect END with optional label + if (preg_match('/\bEND\b(\s+[a-zA-Z0-9_]+)?\s*$/i', $token)) { + $blockDepth--; + } + + // If we are outside a block and encounter a semicolon, split the query + if ($blockDepth === 0 && $token === ';') { + $trimmedQuery = trim($buffer); + if ($trimmedQuery !== ';') { // Ignore empty queries + $queries[] = $trimmedQuery; + //substr($trimmedQuery, 0, -1); // Remove the trailing semicolon + } + $buffer = ''; + } + } + + // Add any remaining buffer as a query + if (!empty(trim($buffer))) { + $queries[] = trim($buffer); + } + + return $queries; + } + +} diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php new file mode 100644 index 00000000..35a8d66b --- /dev/null +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -0,0 +1,1424 @@ + */ + private ArrayObject $storage; + + private string $mysqlHost; + + private string $user; + private string $passwd; + private string $database; + + public function setUp(): void + { + $this->storage = new ArrayObject(); + $tracerProvider = new TracerProvider( + new SimpleSpanProcessor( + new InMemoryExporter($this->storage) + ) + ); + + $this->scope = Configurator::create() + ->withTracerProvider($tracerProvider) + ->withPropagator(TraceContextPropagator::getInstance()) + ->activate(); + + $this->mysqlHost = getenv('MYSQL_HOST') ?: '127.0.0.1'; + + $this->user = 'otel_user'; + $this->passwd = 'otel_passwd'; + $this->database = 'otel_db'; + } + + public function tearDown(): void + { + $this->scope->detach(); + } + + private function assertDatabaseAttributes(int $offset) + { + $span = $this->storage->offsetGet($offset); + $this->assertEquals($this->mysqlHost, $span->getAttributes()->get(TraceAttributes::SERVER_ADDRESS)); + $this->assertEquals($this->user, $span->getAttributes()->get(TraceAttributes::DB_USER)); + $this->assertEquals($this->database, $span->getAttributes()->get(TraceAttributes::DB_NAMESPACE)); + $this->assertEquals('mysql', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM)); + } + + private function assertDatabaseAttributesForAllSpans(int $offsets) + { + for ($offset = 0; $offset < $offsets; $offset++) { + $this->assertDatabaseAttributes($offset); + } + } + + private function assertAttributes(int $offset, iterable $attributes) + { + foreach ($attributes as $attribute => $expected) { + $this->assertSame($expected, $this->storage->offsetGet($offset)->getAttributes()->get($attribute)); + } + } + + public function test_mysqli_connect(): void + { + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + $mysqli->connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + mysqli_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $mysqli->real_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + mysqli_real_connect($mysqli, $this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset++)->getName()); + $this->assertSame('mysqli::connect', $this->storage->offsetGet($offset++)->getName()); + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset++)->getName()); + $this->assertSame('mysqli::real_connect', $this->storage->offsetGet($offset++)->getName()); + $this->assertSame('mysqli_real_connect', $this->storage->offsetGet($offset++)->getName()); + + $this->assertCount($offset, $this->storage); + + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_query_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR| MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $offset++; + $res = $mysqli->query('SELECT * FROM otel_db.users'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + if ($mysqli->real_query('SELECT * FROM otel_db.users')) { + $mysqli->store_result(); + } + $this->assertSame('mysqli::real_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + + try { + $mysqli->query('SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + $offset++; + + try { + $mysqli->real_query('SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli::real_query', $this->storage->offsetGet($offset)->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $this->storage->offsetGet($offset)->getStatus()->getCode()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + // disabling exceptions - test error capturing + mysqli_report(MYSQLI_REPORT_ERROR); + $offset++; + + try { + + $mysqli->query('SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + + try { + $mysqli->real_query('SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli::real_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_query_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR| MYSQLI_REPORT_STRICT); + + $mysqli = mysqli_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $this->assertTrue($mysqli instanceof mysqli); + + $offset = 0; + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset)->getName()); + + $offset++; + $res = mysqli_query($mysqli, 'SELECT * FROM otel_db.users'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $this->assertSame('mysqli_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + if (mysqli_real_query($mysqli, 'SELECT * FROM otel_db.users')) { + $mysqli->store_result(); + } + $this->assertSame('mysqli_real_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + + try { + mysqli_query($mysqli, 'SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + $offset++; + + try { + mysqli_real_query($mysqli, 'SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli_real_query', $this->storage->offsetGet($offset)->getName()); + $this->assertSame(StatusCode::STATUS_ERROR, $this->storage->offsetGet($offset)->getStatus()->getCode()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + // disabling exceptions - test error capturing + mysqli_report(MYSQLI_REPORT_ERROR); + + $offset++; + + try { + mysqli_query($mysqli, 'SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + + try { + mysqli_real_query($mysqli, 'SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli_real_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + + } + + public function test_mysqli_execute_query_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $offset++; + $result = $mysqli->execute_query('SELECT * FROM otel_db.users'); + if ($result instanceof mysqli_result) { + $this->assertCount(3, $result->fetch_all(), 'Result should contain 3 elements'); + } + + $this->assertSame('mysqli::execute_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + + try { + $result = $mysqli->execute_query('SELECT * FROM unknown_db.users'); + } catch (mysqli_sql_exception) { + } + + $this->assertSame('mysqli::execute_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + mysqli_report(MYSQLI_REPORT_ERROR); + + $offset++; + + try { + $result = $mysqli->execute_query('SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli::execute_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_execute_query_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = mysqli_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset)->getName()); + + $offset++; + $result = mysqli_execute_query($mysqli, 'SELECT * FROM otel_db.users'); + if ($result instanceof mysqli_result) { + $this->assertCount(3, $result->fetch_all(), 'Result should contain 3 elements'); + } + + $this->assertSame('mysqli_execute_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + + try { + $result = mysqli_execute_query($mysqli, 'SELECT * FROM unknown_db.users'); + } catch (mysqli_sql_exception) { + } + + $this->assertSame('mysqli_execute_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + mysqli_report(MYSQLI_REPORT_ERROR); + + $offset++; + + try { + $result = mysqli_execute_query($mysqli, 'SELECT * FROM unknown_db.users'); + } catch (Throwable) { + } + + $this->assertSame('mysqli_execute_query', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString('Unknown database', $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_multi_query_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $query = 'SELECT CURRENT_USER();'; + $query .= 'SELECT email FROM users ORDER BY id;'; + $query .= 'SELECT name FROM products ORDER BY stock;'; + $query .= 'SELECT test FROM unknown ORDER BY nothing;'; + + $result = $mysqli->multi_query($query); + do { + try { + if ($result = $mysqli->store_result()) { + $result->free_result(); + } + + if (!$mysqli->next_result()) { + break; + } + } catch (Throwable) { + break; + } + } while (true); + + $offset++; + $this->assertSame('mysqli::multi_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT email FROM users ORDER BY id;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT name FROM products ORDER BY stock;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString("Table 'otel_db.unknown' doesn't exist", $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT test FROM unknown ORDER BY nothing;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + mysqli_report(MYSQLI_REPORT_ERROR); + + $result = $mysqli->multi_query($query); + do { + try { + if ($result = $mysqli->store_result()) { + $result->free_result(); + } + + if (!$mysqli->next_result()) { + break; + } + } catch (Throwable) { + break; + } + } while (true); + + $offset++; + $this->assertSame('mysqli::multi_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT email FROM users ORDER BY id;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT name FROM products ORDER BY stock;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString("Table 'otel_db.unknown' doesn't exist", $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT test FROM unknown ORDER BY nothing;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_multi_query_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = mysqli_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset)->getName()); + + $query = 'SELECT CURRENT_USER();'; + $query .= 'SELECT email FROM users ORDER BY id;'; + $query .= 'SELECT name FROM products ORDER BY stock;'; + $query .= 'SELECT test FROM unknown ORDER BY nothing;'; + + $result = mysqli_multi_query($mysqli, $query); + do { + try { + if ($result = mysqli_store_result($mysqli)) { + mysqli_free_result($result); + } + + if (!mysqli_next_result($mysqli)) { + break; + } + } catch (Throwable) { + break; + } + } while (true); + + $offset++; + $this->assertSame('mysqli_multi_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT email FROM users ORDER BY id;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT name FROM products ORDER BY stock;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString("Table 'otel_db.unknown' doesn't exist", $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT test FROM unknown ORDER BY nothing;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + mysqli_report(MYSQLI_REPORT_ERROR); + + $result = mysqli_multi_query($mysqli, $query); + do { + try { + if ($result = mysqli_store_result($mysqli)) { + mysqli_free_result($result); + } + + if (!mysqli_next_result($mysqli)) { + break; + } + } catch (Throwable) { + break; + } + } while (true); + + $offset++; + $this->assertSame('mysqli_multi_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT email FROM users ORDER BY id;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT name FROM products ORDER BY stock;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertSame('mysqli_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertStringContainsString("Table 'otel_db.unknown' doesn't exist", $this->storage->offsetGet($offset)->getStatus()->getDescription()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT test FROM unknown ORDER BY nothing;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_prepare_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + try { + $stmt = $mysqli->prepare('SELECT * FROM otel_db.users'); + + $offset++; + $this->assertSame('mysqli::prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $stmt->execute(); + $offset++; + + $this->assertSame('mysqli_stmt::execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $stmt->fetch(); + $stmt->close(); + + } catch (mysqli_sql_exception $exception) { + $this->fail('Unexpected exception was thrown: ' . $exception->getMessage()); + } + + try { + $stmt = $mysqli->prepare('SELECT * FROM unknown_db.users'); + + $this->fail('Should never reach this point'); + } catch (mysqli_sql_exception $exception) { + $offset++; + + $this->assertSame('mysqli::prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + } + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_prepare_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + try { + $stmt = mysqli_prepare($mysqli, 'SELECT * FROM otel_db.users'); + + $offset++; + $this->assertSame('mysqli_prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + mysqli_stmt_execute($stmt); + $offset++; + + $this->assertSame('mysqli_stmt_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM otel_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + mysqli_stmt_fetch($stmt); + mysqli_stmt_close($stmt); + } catch (mysqli_sql_exception $exception) { + $this->fail('Unexpected exception was thrown: ' . $exception->getMessage()); + } + + try { + $stmt = mysqli_prepare($mysqli, 'SELECT * FROM unknown_db.users'); + + $this->fail('Should never reach this point'); + } catch (\Throwable $exception) { + $offset++; + + $this->assertSame('mysqli_prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM unknown_db.users', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::EXCEPTION_TYPE => \PHPUnit\Framework\Error\Warning::class, + ]); + } + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_transaction_rollback_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $mysqli->query('DROP TABLE IF EXISTS language;'); + $offset++; + + $mysqli->query('CREATE TABLE IF NOT EXISTS language ( Code text NOT NULL, Speakers int(11) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;'); + $offset++; + + $mysqli->begin_transaction(name: 'supertransaction'); + $offset++; + $this->assertSame('mysqli::begin_transaction', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + + try { + // Insert some values + $mysqli->query("INSERT INTO language(Code, Speakers) VALUES ('DE', 42000123)"); + + $offset++; + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "INSERT INTO language(Code, Speakers) VALUES ('DE', 42000123)", + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + // Try to insert invalid values + $language_code = 'FR'; + $native_speakers = 'Unknown'; + $stmt = $mysqli->prepare('INSERT INTO language(Code, Speakers) VALUES (?,?)'); + $offset++; + $this->assertSame('mysqli::prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + $stmt->bind_param('ss', $language_code, $native_speakers); + $stmt->execute(); // THROWS HERE + + $this->fail('Should never reach this point'); + } catch (mysqli_sql_exception $exception) { + $offset++; + $this->assertSame('mysqli_stmt::execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + $mysqli->rollback(name: 'supertransaction'); + $offset++; + $this->assertSame('mysqli::rollback', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + + } + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_transaction_rollback_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR); + + $mysqli = mysqli_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset)->getName()); + + mysqli_query($mysqli, 'DROP TABLE IF EXISTS language;'); + $offset++; + + mysqli_query($mysqli, 'CREATE TABLE IF NOT EXISTS language ( Code text NOT NULL, Speakers int(11) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;'); + $offset++; + + mysqli_begin_transaction($mysqli, name: 'supertransaction'); + $offset++; + $this->assertSame('mysqli_begin_transaction', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + + try { + // Insert some values + mysqli_query($mysqli, "INSERT INTO language(Code, Speakers) VALUES ('DE', 76000001)"); + + $offset++; + $this->assertSame('mysqli_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "INSERT INTO language(Code, Speakers) VALUES ('DE', 76000001)", + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + // Try to insert invalid values + $language_code = 'FR'; + $native_speakers = 'Unknown'; + $stmt = mysqli_prepare($mysqli, 'INSERT INTO language(Code, Speakers) VALUES (?,?)'); + $offset++; + $this->assertSame('mysqli_prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + mysqli_stmt_bind_param($stmt, 'ss', $language_code, $native_speakers); + + try { + mysqli_stmt_execute($stmt); + } catch (\PHPUnit\Framework\Error\Warning $e) { + $offset++; + $this->assertSame('mysqli_stmt_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + mysqli_rollback($mysqli, name: 'supertransaction'); + $offset++; + $this->assertSame('mysqli_rollback', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + } + } catch (mysqli_sql_exception $exception) { + $this->fail('Should never reach this point'); + } + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_transaction_commit_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $mysqli->query('DROP TABLE IF EXISTS language;'); + $offset++; + + $mysqli->query('CREATE TABLE IF NOT EXISTS language ( Code text NOT NULL, Speakers int(11) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;'); + $offset++; + + $mysqli->begin_transaction(name: 'supertransaction'); + $offset++; + $this->assertSame('mysqli::begin_transaction', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + + try { + // Insert some values + $mysqli->query("INSERT INTO language(Code, Speakers) VALUES ('DE', 76000001)"); + + $offset++; + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "INSERT INTO language(Code, Speakers) VALUES ('DE', 76000001)", + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + // Try to insert invalid values + $language_code = 'FR'; + $native_speakers = 66000002; + $stmt = $mysqli->prepare('INSERT INTO language(Code, Speakers) VALUES (?,?)'); + + $offset++; + $this->assertSame('mysqli::prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + $stmt->bind_param('ss', $language_code, $native_speakers); + $stmt->execute(); + + $offset++; + $this->assertSame('mysqli_stmt::execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + $mysqli->commit(name: 'supertransaction'); + + $offset++; + $this->assertSame('mysqli::commit', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + } catch (mysqli_sql_exception $exception) { + $this->fail('Unexpected exception was thrown: ' . $exception->getMessage()); + } + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_transaction_commit_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR); + + $mysqli = mysqli_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset)->getName()); + + mysqli_query($mysqli, 'DROP TABLE IF EXISTS language;'); + $offset++; + + mysqli_query($mysqli, 'CREATE TABLE IF NOT EXISTS language ( Code text NOT NULL, Speakers int(11) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;'); + $offset++; + + mysqli_begin_transaction($mysqli, name: 'supertransaction'); + $offset++; + $this->assertSame('mysqli_begin_transaction', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + + try { + // Insert some values + mysqli_query($mysqli, "INSERT INTO language(Code, Speakers) VALUES ('DE', 76000001)"); + + $offset++; + $this->assertSame('mysqli_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "INSERT INTO language(Code, Speakers) VALUES ('DE', 76000001)", + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + // Try to insert invalid values + $language_code = 'FR'; + $native_speakers = 66000002; + $stmt = mysqli_prepare($mysqli, 'INSERT INTO language(Code, Speakers) VALUES (?,?)'); + $offset++; + $this->assertSame('mysqli_prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + mysqli_stmt_bind_param($stmt, 'ss', $language_code, $native_speakers); + mysqli_stmt_execute($stmt); + + $offset++; + $this->assertSame('mysqli_stmt_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'INSERT INTO language(Code, Speakers) VALUES (?,?)', + TraceAttributes::DB_OPERATION_NAME => 'INSERT', + ]); + + mysqli_commit($mysqli, name: 'supertransaction'); + + $offset++; + $this->assertSame('mysqli_commit', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + 'db.transaction.name' => 'supertransaction', + ]); + } catch (mysqli_sql_exception $exception) { + $this->fail('Unexpected exception was thrown: ' . $exception->getMessage()); + } + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_stmt_execute_objective(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $stmt = new mysqli_stmt($mysqli, "SELECT email FROM users WHERE name='John Doe'"); + $stmt->execute(); + $stmt->fetch(); + $stmt->close(); + + $offset++; + $this->assertSame('mysqli_stmt::execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "SELECT email FROM users WHERE name='John Doe'", + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $stmt = $mysqli->stmt_init(); + $stmt->prepare("SELECT email FROM users WHERE name='John Doe'"); + + $stmt->execute(); + $stmt->fetch(); + $stmt->close(); + + $offset++; + $this->assertSame('mysqli_stmt::execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "SELECT email FROM users WHERE name='John Doe'", + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_stmt_execute_procedural(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $stmt = mysqli_stmt_init($mysqli); + mysqli_stmt_prepare($stmt, "SELECT email FROM users WHERE name='John Doe'"); + mysqli_stmt_execute($stmt); + mysqli_stmt_fetch($stmt); + mysqli_stmt_close($stmt); + + $offset++; + $this->assertSame('mysqli_stmt_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => "SELECT email FROM users WHERE name='John Doe'", + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_multiquery_with_calls(): void + { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $createProcedureSQL = " + DROP PROCEDURE IF EXISTS get_message; + CREATE PROCEDURE get_message() + BEGIN + -- first result + SELECT 'Result 1' AS message; + -- second result + SELECT 'Result 2' AS message; + END; + "; + + $mysqli->multi_query($createProcedureSQL); + + $offset++; + $this->assertSame('mysqli::multi_query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'DROP PROCEDURE IF EXISTS get_message;', + TraceAttributes::DB_OPERATION_NAME => 'DROP', + ]); + + while ($mysqli->next_result()) { + if ($result = $mysqli->store_result()) { + $result->free(); + } + } + + $offset++; + $this->assertSame('mysqli::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_OPERATION_NAME => 'CREATE', + ]); + $span = $this->storage->offsetGet($offset); + $this->assertStringStartsWith('CREATE PROCEDURE', $span->getAttributes()->get(TraceAttributes::DB_STATEMENT)); + $this->assertStringEndsWith('END;', $span->getAttributes()->get(TraceAttributes::DB_STATEMENT)); + + $stmt = $mysqli->prepare('CALL get_message();'); + $offset++; + $this->assertSame('mysqli::prepare', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + $stmt->execute(); + + $offset++; + $this->assertSame('mysqli_stmt::execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + do { + $result = $stmt->get_result(); + if ($result) { + while ($row = $result->fetch_assoc()) { + // echo 'Result: ' . str_replace(PHP_EOL, '', print_r($row, true)) . PHP_EOL; + } + $result->free(); + } + } while ($stmt->next_result()); + + $offset++; + $this->assertSame('mysqli_stmt::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + $offset++; + $this->assertSame('mysqli_stmt::next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + // the same but procedural + + mysqli_stmt_execute($stmt); + + $offset++; + $this->assertSame('mysqli_stmt_execute', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + do { + $result = mysqli_stmt_get_result($stmt); + if ($result) { + while ($row = mysqli_fetch_assoc($result)) { + // echo 'Result: ' . str_replace(PHP_EOL, '', print_r($row, true)) . PHP_EOL; + } + mysqli_free_result($result); + } + } while (mysqli_stmt_next_result($stmt)); + + $offset++; + $this->assertSame('mysqli_stmt_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + $offset++; + $this->assertSame('mysqli_stmt_next_result', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'CALL get_message();', + TraceAttributes::DB_OPERATION_NAME => 'CALL', + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + $this->assertDatabaseAttributesForAllSpans($offset); + } + + public function test_mysqli_change_user(): void + { + mysqli_report(MYSQLI_REPORT_ERROR| MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $offset++; + $res = $mysqli->query('SELECT CURRENT_USER();'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => $this->database, + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + $mysqli->change_user('otel_user2', $this->passwd, 'otel_db2'); + + $offset++; + $res = $mysqli->query('SELECT CURRENT_USER();'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => 'otel_user2', + TraceAttributes::DB_NAMESPACE => 'otel_db2', + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + mysqli_change_user($mysqli, $this->user, $this->passwd, $this->database); + + $offset++; + $res = $mysqli->query('SELECT CURRENT_USER();'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => $this->database, + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + try { + mysqli_change_user($mysqli, 'blahh', $this->passwd, 'unknowndb'); + } catch (Throwable) { + } + + $offset++; + + try { + $res = $mysqli->query('SELECT CURRENT_USER();'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + } catch (Throwable) { + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => $this->database, + TraceAttributes::DB_SYSTEM => 'mysql', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + } + + public function test_mysqli_select_db(): void + { + mysqli_report(MYSQLI_REPORT_ERROR| MYSQLI_REPORT_STRICT); + + $mysqli = new mysqli($this->mysqlHost, $this->user, $this->passwd, $this->database); + + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); + + $offset++; + $res = $mysqli->query('SELECT * FROM users;'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => $this->database, + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + $mysqli->select_db('otel_db2'); + + try { + $res = $mysqli->query('SELECT * FROM users;'); + $this->fail('Should never reach this point'); + } catch (\Throwable $e) { + $offset++; + + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => 'otel_db2', + TraceAttributes::DB_SYSTEM => 'mysql', + TraceAttributes::EXCEPTION_TYPE => mysqli_sql_exception::class, + ]); + } + + + mysqli_select_db($mysqli, $this->database); + + $res = $mysqli->query('SELECT * FROM users;'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $offset++; + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => $this->database, + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + try { + mysqli_select_db($mysqli, 'unknown'); + } catch (Throwable) { + + } + + $res = $mysqli->query('SELECT * FROM users;'); + if ($res instanceof mysqli_result) { + while ($res->fetch_object()) { + } + } + + $offset++; + $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); + $this->assertAttributes($offset, [ + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', + TraceAttributes::DB_OPERATION_NAME => 'SELECT', + TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, + TraceAttributes::DB_USER => $this->user, + TraceAttributes::DB_NAMESPACE => $this->database, + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + } + +} diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliTrackerTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliTrackerTest.php new file mode 100644 index 00000000..3d0587ca --- /dev/null +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliTrackerTest.php @@ -0,0 +1,163 @@ +tracker = new MySqliTracker(); + + $this->splitQueries = new ReflectionMethod(MySqliTracker::class, 'splitQueries'); + $this->splitQueries->setAccessible(true); + } + + public function tearDown(): void + { + unset($this->tracker, $this->splitQueries); + } + + public function test_split_queries(): void + { + $query = "SELECT * FROM users; INSERT INTO logs (message) VALUES ('test');"; + + $result = $this->splitQueries->invoke($this->tracker, $query); + + $expected = [ + 'SELECT * FROM users;', + "INSERT INTO logs (message) VALUES ('test');", + ]; + + $this->assertEquals($expected, $result); + } + + public function test_split_queries_whitespaces(): void + { + $query = " SELECT * FROM users;\n\t INSERT INTO logs (message) VALUES ('test');SELECT * from test\n\n"; + + $result = $this->splitQueries->invoke($this->tracker, $query); + + $expected = [ + 'SELECT * FROM users;', + "INSERT INTO logs (message) VALUES ('test');", + 'SELECT * from test', + ]; + + $this->assertEquals($expected, $result); + } + + public function test_split_queries_with_begin_end(): void + { + $query = " + DROP PROCEDURE IF EXISTS get_data_with_delay; + CREATE PROCEDURE get_data_with_delay() + BEGIN + -- first result + SELECT SLEEP(1); + -- second result + SELECT 'Result 1' AS message; + -- third result + SELECT SLEEP(1); + -- fourth result + SELECT 'Result 2' AS message; + END; + + SELECT * FROM users + "; + + $result = $this->splitQueries->invoke($this->tracker, $query); + + $expected = [ + 'DROP PROCEDURE IF EXISTS get_data_with_delay;', + "CREATE PROCEDURE get_data_with_delay() + BEGIN + -- first result + SELECT SLEEP(1); + -- second result + SELECT 'Result 1' AS message; + -- third result + SELECT SLEEP(1); + -- fourth result + SELECT 'Result 2' AS message; + END;", + 'SELECT * FROM users', + ]; + + $this->assertEquals($expected, $result); + } + + public function test_split_queries_with_labeled_begin_end(): void + { + $query = " + DROP PROCEDURE IF EXISTS get_data_with_delay; + CREATE PROCEDURE get_data_with_delay() + BEGIN label; + -- first result + SELECT SLEEP(1); + -- second result + SELECT 'Result 1' AS message; + -- third result + SELECT SLEEP(1); + -- fourth result + SELECT 'Result 2' AS message; + END label; + + SELECT * FROM users + "; + + $result = $this->splitQueries->invoke($this->tracker, $query); + + $expected = [ + 'DROP PROCEDURE IF EXISTS get_data_with_delay;', + "CREATE PROCEDURE get_data_with_delay() + BEGIN label; + -- first result + SELECT SLEEP(1); + -- second result + SELECT 'Result 1' AS message; + -- third result + SELECT SLEEP(1); + -- fourth result + SELECT 'Result 2' AS message; + END label;", + 'SELECT * FROM users', + ]; + + $this->assertEquals($expected, $result); + } + + public function test_split_queries_with_transaction(): void + { + $query = " + SELECT * FROM users; + BEGIN TRANSACTION; + INSERT INTO users (name) VALUES ('Alice'); + INSERT INTO users (name) VALUES ('Bob'); + END TRANSACTION; + SELECT * FROM users2; + "; + + $result = $this->splitQueries->invoke($this->tracker, $query); + + $expected = [ + 'SELECT * FROM users;', + "BEGIN TRANSACTION; + INSERT INTO users (name) VALUES ('Alice'); + INSERT INTO users (name) VALUES ('Bob'); + END TRANSACTION;", + 'SELECT * FROM users2;', + ]; + + $this->assertEquals($expected, $result); + } + +} diff --git a/src/Instrumentation/MySqli/tests/Unit/.gitkeep b/src/Instrumentation/MySqli/tests/Unit/.gitkeep new file mode 100644 index 00000000..e69de29b