From e971b0f09df5a751d9e92f30c05286c7bac4a762 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 2 Dec 2024 16:03:16 +0100 Subject: [PATCH 01/10] Auto instrumentation for mysqli --- src/Instrumentation/MySqli/.gitattributes | 12 + src/Instrumentation/MySqli/.gitignore | 1 + src/Instrumentation/MySqli/.php-cs-fixer.php | 43 ++ src/Instrumentation/MySqli/README.md | 53 ++ src/Instrumentation/MySqli/_register.php | 18 + src/Instrumentation/MySqli/composer.json | 59 ++ src/Instrumentation/MySqli/phpstan.neon.dist | 9 + src/Instrumentation/MySqli/phpunit.xml.dist | 47 ++ src/Instrumentation/MySqli/psalm.xml.dist | 17 + .../MySqli/src/MySqliInstrumentation.php | 671 ++++++++++++++++++ .../MySqli/src/MySqliTracker.php | 184 +++++ .../Integration/MySqliInstrumentationTest.php | 48 ++ .../MySqli/tests/Unit/.gitkeep | 0 13 files changed, 1162 insertions(+) create mode 100644 src/Instrumentation/MySqli/.gitattributes create mode 100644 src/Instrumentation/MySqli/.gitignore create mode 100644 src/Instrumentation/MySqli/.php-cs-fixer.php create mode 100644 src/Instrumentation/MySqli/README.md create mode 100644 src/Instrumentation/MySqli/_register.php create mode 100644 src/Instrumentation/MySqli/composer.json create mode 100644 src/Instrumentation/MySqli/phpstan.neon.dist create mode 100644 src/Instrumentation/MySqli/phpunit.xml.dist create mode 100644 src/Instrumentation/MySqli/psalm.xml.dist create mode 100644 src/Instrumentation/MySqli/src/MySqliInstrumentation.php create mode 100644 src/Instrumentation/MySqli/src/MySqliTracker.php create mode 100644 src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php create mode 100644 src/Instrumentation/MySqli/tests/Unit/.gitkeep 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..8d6c2a4b --- /dev/null +++ b/src/Instrumentation/MySqli/README.md @@ -0,0 +1,53 @@ +[![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_stmt::execute` +* `mysqli_stmt_execute` +* `mysqli_stmt::next_result` +* `mysqli_stmt_next_result` + +## Limitations + +Transactions are not fully supported yet + +## 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..9c1582f2 --- /dev/null +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -0,0 +1,671 @@ +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); + + } + + private static function queryPreHook(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 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); + + } + + 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); + } + + } + + 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; //TODO create error span? + } + + $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; //TODO create error span? + } + $tracker->addMySqliAttribute($obj ? $obj : $params[0], TraceAttributes::DB_NAMESPACE, $params[$obj ? 0 : 1]); + } + + private static function preparePostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $stmtRetVal, ?\Throwable $exception) + { + + if ($exception || !$stmtRetVal instanceof mysqli_stmt) { + self::logDebug('mysqli::prepare failed', ['exception' => $exception, 'obj' => $obj, 'retVal' => $stmtRetVal, 'params' => $params]); + + return; + } + + $mysqli = $obj ? $obj : $params[0]; + $query = $params[$obj ? 0 : 1]; + + $tracker->trackMySqliFromStatement($mysqli, $stmtRetVal); + + $tracker->addStatementAttribute($stmtRetVal, TraceAttributes::DB_STATEMENT, mb_convert_encoding($query, 'UTF-8')); + $tracker->addStatementAttribute($stmtRetVal, TraceAttributes::DB_OPERATION_NAME, self::extractQueryCommand($query)); + + } + + 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])); + } + } + + private static function stmtExecutePreHook(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 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); + + } + + 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); + } + + } + + 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); + } + + 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(array $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 extractQueryCommand($query) : ?string + { + $query = preg_replace("/\r\n|\n\r|\r/", "\n", $query); + 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..5a9f4ce3 --- /dev/null +++ b/src/Instrumentation/MySqli/src/MySqliTracker.php @@ -0,0 +1,184 @@ +mySqliToAttributes = new WeakMap(); + $this->mySqliToMultiQueries = new WeakMap(); + $this->statementToMySqli = new WeakMap(); + $this->statementAttributes = new WeakMap(); + $this->statementSpan = new WeakMap(); + $this->mySqliSpan = 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[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) : iterable + { + return $this->mySqliToAttributes[$mysqli] ?? []; + } + + public function trackMySqliFromStatement(mysqli $mysqli, mysqli_stmt $mysqli_stmt) + { + $this->statementToMySqli[$mysqli_stmt] = WeakReference::create($mysqli); + } + + public function getMySqliAttributesFromStatement(mysqli_stmt $stmt) : iterable + { + $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) : iterable + { + 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(); + } + + 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..88dda9e6 --- /dev/null +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -0,0 +1,48 @@ + */ + private ArrayObject $storage; + + 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(); + } + + public function tearDown(): void + { + $this->scope->detach(); + } + + public function test_mysqli_connect(): void + { + } + + // to be continued +} diff --git a/src/Instrumentation/MySqli/tests/Unit/.gitkeep b/src/Instrumentation/MySqli/tests/Unit/.gitkeep new file mode 100644 index 00000000..e69de29b From 487c695ca62823d2eaee89df6c0fe85d25ff9039 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 4 Dec 2024 15:13:51 +0100 Subject: [PATCH 02/10] mysqli added to workflows --- .github/workflows/php.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2fe06941..ff83bbfa 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' From f223b7fccf4d5f9c999046024a9e9ce5dc9b6167 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Wed, 4 Dec 2024 15:42:05 +0100 Subject: [PATCH 03/10] Added to composer and gitsplit --- .gitsplit.yml | 2 ++ composer.json | 3 +++ 2 files changed, 5 insertions(+) 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", From ca8d1dca29d8448ddac9496826ae70a77ca6766e Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Thu, 5 Dec 2024 09:05:34 +0100 Subject: [PATCH 04/10] Added first test, fixed style and static analysis bugs --- .github/workflows/php.yml | 7 ++- docker-compose.yaml | 20 ++++++++- docker/Dockerfile | 3 +- .../MySqli/src/MySqliInstrumentation.php | 10 ++++- .../MySqli/src/MySqliTracker.php | 7 +-- .../Integration/MySqliInstrumentationTest.php | 45 +++++++++++++++++++ 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index ff83bbfa..2d8cb27a 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -139,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 @@ -199,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/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/src/Instrumentation/MySqli/src/MySqliInstrumentation.php b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php index 9c1582f2..2f76831a 100644 --- a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -338,8 +338,10 @@ public static function register(): void //TODO test to https://www.php.net/manual/en/mysqli.begin-transaction.php } + /** @param non-empty-string $spanName */ private static function constructPreHook(string $spanName, int $paramsOffset, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): void { + $attributes = []; $attributes[TraceAttributes::SERVER_ADDRESS] = $params[$paramsOffset + 0] ?? get_cfg_var('mysqli.default_host'); $attributes[TraceAttributes::SERVER_PORT] = $params[$paramsOffset + 4] ?? get_cfg_var('mysqli.default_port'); $attributes[TraceAttributes::DB_USER] = $params[$paramsOffset + 1] ?? get_cfg_var('mysqli.default_user'); @@ -370,6 +372,7 @@ private static function constructPostHook(int $paramsOffset, CachedInstrumentati } + /** @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 { self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); @@ -421,6 +424,7 @@ private static function multiQueryPostHook(CachedInstrumentation $instrumentatio } + /** @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, []); @@ -545,6 +549,7 @@ private static function stmtConstructPostHook(CachedInstrumentation $instrumenta } } + /** @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 { self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); @@ -568,6 +573,7 @@ private static function stmtExecutePostHook(CachedInstrumentation $instrumentati } + /** @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, []); @@ -603,6 +609,7 @@ private static function stmtNextResultPostHook(CachedInstrumentation $instrument 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(); @@ -624,7 +631,7 @@ private static function startSpan(string $spanName, CachedInstrumentation $instr return $span; } - private static function endSpan(array $attributes, ?\Throwable $exception, ?string $errorStatus) + private static function endSpan(iterable $attributes, ?\Throwable $exception, ?string $errorStatus) { $scope = Context::storage()->scope(); if (!$scope) { @@ -661,6 +668,7 @@ private static function dropSpan() 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]); } diff --git a/src/Instrumentation/MySqli/src/MySqliTracker.php b/src/Instrumentation/MySqli/src/MySqliTracker.php index 5a9f4ce3..49b97a11 100644 --- a/src/Instrumentation/MySqli/src/MySqliTracker.php +++ b/src/Instrumentation/MySqli/src/MySqliTracker.php @@ -48,6 +48,7 @@ public function getNextMySqliMultiQuery(mysqli $mysqli) : ?string 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'); @@ -66,7 +67,7 @@ public function addMySqliAttribute($mysqli, string $attribute, bool|int|float|st $this->mySqliToAttributes[$mysqli][$attribute] = $value; } - public function getMySqliAttributes(mysqli $mysqli) : iterable + public function getMySqliAttributes(mysqli $mysqli) : array { return $this->mySqliToAttributes[$mysqli] ?? []; } @@ -76,7 +77,7 @@ public function trackMySqliFromStatement(mysqli $mysqli, mysqli_stmt $mysqli_stm $this->statementToMySqli[$mysqli_stmt] = WeakReference::create($mysqli); } - public function getMySqliAttributesFromStatement(mysqli_stmt $stmt) : iterable + public function getMySqliAttributesFromStatement(mysqli_stmt $stmt) : array { $mysqli = ($this->statementToMySqli[$stmt] ?? null)?->get(); if (!$mysqli) { @@ -94,7 +95,7 @@ public function addStatementAttribute(mysqli_stmt $stmt, string $attribute, bool $this->statementAttributes[$stmt][$attribute] = $value; } - public function getStatementAttributes(mysqli_stmt $stmt) : iterable + public function getStatementAttributes(mysqli_stmt $stmt) : array { if (!$this->statementAttributes->offsetExists($stmt)) { return []; diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php index 88dda9e6..8a1b5942 100644 --- a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -5,6 +5,7 @@ namespace OpenTelemetry\Tests\Instrumentation\MySqli\Integration; use ArrayObject; +use mysqli; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\Context\ScopeInterface; @@ -12,6 +13,7 @@ use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor; use OpenTelemetry\SDK\Trace\TracerProvider; +use OpenTelemetry\SemConv\TraceAttributes; use PHPUnit\Framework\TestCase; class MySqliInstrumentationTest extends TestCase @@ -20,6 +22,12 @@ class MySqliInstrumentationTest extends TestCase /** @var ArrayObject */ private ArrayObject $storage; + private string $mysqlHost; + + private string $user; + private string $passwd; + private string $database; + public function setUp(): void { $this->storage = new ArrayObject(); @@ -33,6 +41,12 @@ public function setUp(): void ->withTracerProvider($tracerProvider) ->withPropagator(TraceContextPropagator::getInstance()) ->activate(); + + $this->mysqlHost = getenv('MYSQL_HOST') ?: 'localhost'; + + $this->user = 'otel_user'; + $this->passwd = 'otel_passwd'; + $this->database = 'otel_db'; } public function tearDown(): void @@ -42,6 +56,37 @@ public function tearDown(): void 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); + + $this->assertCount(5, $this->storage); + + $span = $this->storage->offsetGet(0); + $this->assertSame('mysqli::__construct', $span->getName()); + + $span = $this->storage->offsetGet(1); + $this->assertSame('mysqli::connect', $span->getName()); + + $span = $this->storage->offsetGet(2); + $this->assertSame('mysqli_connect', $span->getName()); + + $span = $this->storage->offsetGet(3); + $this->assertSame('mysqli::real_connect', $span->getName()); + + $span = $this->storage->offsetGet(4); + $this->assertSame('mysqli_real_connect', $span->getName()); + + for ($i = 0; $i < 5; $i++) { + $span = $this->storage->offsetGet($i); + $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)); + } } // to be continued From 678872a4e7acad0facfa80917b73334f241444df Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Thu, 5 Dec 2024 10:19:55 +0100 Subject: [PATCH 05/10] Mysql db init script --- docker/mysql/init.sql | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 docker/mysql/init.sql diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql new file mode 100644 index 00000000..56117fe5 --- /dev/null +++ b/docker/mysql/init.sql @@ -0,0 +1,30 @@ +CREATE DATABASE IF NOT EXISTS otel_db; +CREATE USER IF NOT EXISTS 'otel_user'@'%' IDENTIFIED BY 'otel_passwd'; +GRANT ALL PRIVILEGES ON otel_db.* TO 'otel_user'@'%'; +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); From f9910cdcaff56171ce4b6df30dd5751354ba1b6a Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Thu, 5 Dec 2024 14:49:51 +0100 Subject: [PATCH 06/10] More tests --- docker/mysql/init.sql | 9 +- .../MySqli/src/MySqliInstrumentation.php | 6 +- .../Integration/MySqliInstrumentationTest.php | 371 +++++++++++++++++- 3 files changed, 361 insertions(+), 25 deletions(-) diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql index 56117fe5..72304a89 100644 --- a/docker/mysql/init.sql +++ b/docker/mysql/init.sql @@ -1,8 +1,11 @@ -CREATE DATABASE IF NOT EXISTS otel_db; -CREATE USER IF NOT EXISTS 'otel_user'@'%' IDENTIFIED BY 'otel_passwd'; -GRANT ALL PRIVILEGES ON otel_db.* TO 'otel_user'@'%'; +-- CREATE DATABASE IF NOT EXISTS otel_db; +-- DROP USER IF EXISTS 'otel_user'@'%'; +-- CREATE USER 'otel_user'@'%' IDENTIFIED BY 'otel_passwd'; + +GRANT ALL PRIVILEGES ON *.* TO 'otel_user'@'%'; FLUSH PRIVILEGES; + USE otel_db; CREATE TABLE users ( diff --git a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php index 2f76831a..5bf380e2 100644 --- a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -353,13 +353,12 @@ private static function constructPreHook(string $spanName, int $paramsOffset, Ca private static function constructPostHook(int $paramsOffset, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) { - $mysqliObject = null; if ($obj && $retVal !== false) { // even if constructor fails, we will get and temporary object which will be assigned (or not) alter in user code $mysqliObject = $obj; } elseif ($retVal instanceof mysqli) { // procedural mode - $mySqliObject = $retVal; + $mysqliObject = $retVal; } elseif ($paramsOffset == self::MYSQLI_REAL_CONNECT_ARG_OFFSET && $retVal !== false && $params[0] instanceof mysqli) { // real_connect procedural mode $mysqliObject = $params[0]; } @@ -369,7 +368,6 @@ private static function constructPostHook(int $paramsOffset, CachedInstrumentati } self::endSpan([], $exception, ($retVal === false && !$exception) ? mysqli_connect_error() : null); - } /** @param non-empty-string $spanName */ @@ -380,11 +378,11 @@ private static function queryPreHook(string $spanName, CachedInstrumentation $in 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); diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php index 8a1b5942..a61bd505 100644 --- a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -6,8 +6,11 @@ use ArrayObject; use mysqli; +use mysqli_result; +use mysqli_sql_exception; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; +use OpenTelemetry\API\Trace\StatusCode; use OpenTelemetry\Context\ScopeInterface; use OpenTelemetry\SDK\Trace\ImmutableSpan; use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter; @@ -15,6 +18,7 @@ use OpenTelemetry\SDK\Trace\TracerProvider; use OpenTelemetry\SemConv\TraceAttributes; use PHPUnit\Framework\TestCase; +use Throwable; class MySqliInstrumentationTest extends TestCase { @@ -54,6 +58,29 @@ 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); @@ -63,31 +90,339 @@ public function test_mysqli_connect(): void $mysqli->real_connect($this->mysqlHost, $this->user, $this->passwd, $this->database); mysqli_real_connect($mysqli, $this->mysqlHost, $this->user, $this->passwd, $this->database); - $this->assertCount(5, $this->storage); + $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', + ]); - $span = $this->storage->offsetGet(0); - $this->assertSame('mysqli::__construct', $span->getName()); + $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); - $span = $this->storage->offsetGet(1); - $this->assertSame('mysqli::connect', $span->getName()); + $offset = 0; + $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); - $span = $this->storage->offsetGet(2); - $this->assertSame('mysqli_connect', $span->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); + } - $span = $this->storage->offsetGet(3); - $this->assertSame('mysqli::real_connect', $span->getName()); + 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); - $span = $this->storage->offsetGet(4); - $this->assertSame('mysqli_real_connect', $span->getName()); + $offset = 0; + $this->assertSame('mysqli_connect', $this->storage->offsetGet($offset)->getName()); - for ($i = 0; $i < 5; $i++) { - $span = $this->storage->offsetGet($i); - $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)); + $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); } - // to be continued } From 495921faffb2e3105190280bf5ad750652683470 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Thu, 5 Dec 2024 16:24:03 +0100 Subject: [PATCH 07/10] Fixed default mysql host --- .../MySqli/tests/Integration/MySqliInstrumentationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php index a61bd505..0dcd5d43 100644 --- a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -46,7 +46,7 @@ public function setUp(): void ->withPropagator(TraceContextPropagator::getInstance()) ->activate(); - $this->mysqlHost = getenv('MYSQL_HOST') ?: 'localhost'; + $this->mysqlHost = getenv('MYSQL_HOST') ?: '127.0.0.1'; $this->user = 'otel_user'; $this->passwd = 'otel_passwd'; From 1ba6c4d631b43c47e008da7b23b00431671274e0 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Fri, 6 Dec 2024 15:13:35 +0100 Subject: [PATCH 08/10] Transaction support and tests --- docker/mysql/init.sql | 7 +- src/Instrumentation/MySqli/README.md | 17 +- .../MySqli/src/MySqliInstrumentation.php | 144 ++++- .../MySqli/src/MySqliTracker.php | 29 + .../Integration/MySqliInstrumentationTest.php | 586 ++++++++++++++++++ 5 files changed, 770 insertions(+), 13 deletions(-) diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql index 72304a89..e1b5c346 100644 --- a/docker/mysql/init.sql +++ b/docker/mysql/init.sql @@ -1,8 +1,9 @@ --- CREATE DATABASE IF NOT EXISTS otel_db; --- DROP USER IF EXISTS 'otel_user'@'%'; --- CREATE USER 'otel_user'@'%' IDENTIFIED BY 'otel_passwd'; +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; diff --git a/src/Instrumentation/MySqli/README.md b/src/Instrumentation/MySqli/README.md index 8d6c2a4b..01a08d87 100644 --- a/src/Instrumentation/MySqli/README.md +++ b/src/Instrumentation/MySqli/README.md @@ -18,8 +18,8 @@ Auto-instrumentation hooks are registered via composer, and client kind spans wi * `mysqli_connect` * `mysqli::__construct` * `mysqli::connect` -* `mysqli::real_connect` * `mysqli_real_connect` +* `mysqli::real_connect` * `mysqli_query` * `mysqli::query` @@ -32,14 +32,17 @@ Auto-instrumentation hooks are registered via composer, and client kind spans wi * `mysqli_next_result` * `mysqli::next_result` -* `mysqli_stmt::execute` +* `mysqli_begin_transaction` +* `mysqli::begin_transaction` +* `mysqli_rollback` +* `mysqli::rollback` +* `mysqli_commit` +* `mysqli::commit` +* * `mysqli_stmt_execute` -* `mysqli_stmt::next_result` +* `mysqli_stmt::execute` * `mysqli_stmt_next_result` - -## Limitations - -Transactions are not fully supported yet +* `mysqli_stmt::next_result` ## Configuration diff --git a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php index 5bf380e2..55e9a892 100644 --- a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -248,6 +248,69 @@ public static function register(): void } ); + hook( + null, + 'mysqli_begin_transaction', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::beginTransactionPreHook('mysqli_begin_transaction', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::beginTransactionPostHook($instrumentation, $tracker, ...$args); + } + ); + hook( + mysqli::class, + 'begin_transaction', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::beginTransactionPreHook('mysqli::begin_transaction', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::beginTransactionPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'mysqli_rollback', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPreHook('mysqli_rollback', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPostHook($instrumentation, $tracker, ...$args); + } + ); + hook( + mysqli::class, + 'rollback', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPreHook('mysqli::rollback', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPostHook($instrumentation, $tracker, ...$args); + } + ); + + hook( + null, + 'mysqli_commit', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPreHook('mysqli_commit', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPostHook($instrumentation, $tracker, ...$args); + } + ); + hook( + mysqli::class, + 'commit', + pre: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPreHook('mysqli::commit', $instrumentation, $tracker, ...$args); + }, + post: static function (...$args) use ($instrumentation, $tracker) { + self::transactionPostHook($instrumentation, $tracker, ...$args); + } + ); + // Statement hooks hook( @@ -373,7 +436,9 @@ private static function constructPostHook(int $paramsOffset, CachedInstrumentati /** @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 { - self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + $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) @@ -431,6 +496,7 @@ private static function nextResultPreHook(string $spanName, CachedInstrumentatio $span->addLink($spanContext); } + self::addTransactionLink($tracker, $span, $mysqli); } private static function nextResultPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) @@ -509,6 +575,64 @@ private static function preparePostHook(CachedInstrumentation $instrumentation, } + /** @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) { @@ -550,7 +674,8 @@ private static function stmtConstructPostHook(CachedInstrumentation $instrumenta /** @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 { - self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + $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) @@ -580,7 +705,7 @@ private static function stmtNextResultPreHook(string $spanName, CachedInstrument 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) @@ -663,6 +788,19 @@ private static function dropSpan() $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); diff --git a/src/Instrumentation/MySqli/src/MySqliTracker.php b/src/Instrumentation/MySqli/src/MySqliTracker.php index 49b97a11..568a766a 100644 --- a/src/Instrumentation/MySqli/src/MySqliTracker.php +++ b/src/Instrumentation/MySqli/src/MySqliTracker.php @@ -20,6 +20,7 @@ final class MySqliTracker private WeakMap $statementAttributes; private WeakMap $statementSpan; private WeakMap $mySqliSpan; + private WeakMap $mySqliTransaction; public function __construct() { @@ -30,6 +31,7 @@ public function __construct() $this->statementAttributes = new WeakMap(); $this->statementSpan = new WeakMap(); $this->mySqliSpan = new WeakMap(); + $this->mySqliTransaction = new WeakMap(); } public function storeMySqliMultiQuery(mysqli $mysqli, string $query) @@ -77,6 +79,12 @@ public function trackMySqliFromStatement(mysqli $mysqli, mysqli_stmt $mysqli_stm $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(); @@ -132,6 +140,27 @@ public function getMySqliSpan(mysqli $mysqli) : ?SpanContextInterface 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 diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php index 0dcd5d43..4919f3ab 100644 --- a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -8,6 +8,7 @@ use mysqli; use mysqli_result; use mysqli_sql_exception; +use mysqli_stmt; use OpenTelemetry\API\Instrumentation\Configurator; use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator; use OpenTelemetry\API\Trace\StatusCode; @@ -425,4 +426,589 @@ public function test_mysqli_execute_query_procedural(): void $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_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();'); + $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 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->select_db('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 => $this->user, + TraceAttributes::DB_NAMESPACE => 'otel_db2', + TraceAttributes::DB_SYSTEM => 'mysql', + ]); + + mysqli_select_db($mysqli, $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_select_db($mysqli, 'unknown'); + } 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', + ]); + + $offset++; + $this->assertCount($offset, $this->storage); + } + } From d70b53f2b5a89aacce159c99b422b73180a9e615 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Mon, 9 Dec 2024 12:38:09 +0100 Subject: [PATCH 09/10] Tests for transaction instrumentation and splitQuery --- .../Integration/MySqliInstrumentationTest.php | 258 ++++++++++++++++++ .../tests/Integration/MySqliTrackerTest.php | 163 +++++++++++ 2 files changed, 421 insertions(+) create mode 100644 src/Instrumentation/MySqli/tests/Integration/MySqliTrackerTest.php diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php index 4919f3ab..ec114ecc 100644 --- a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -648,6 +648,264 @@ public function test_mysqli_multi_query_procedural(): void $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 (?,?)'); + + $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 (?,?)'); + + 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 (?,?)'); + + $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 (?,?)'); + + 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); 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); + } + +} From 7679c87c9ddbfbbc72948a6d88423583866ae9a5 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Tue, 10 Dec 2024 20:15:31 +0100 Subject: [PATCH 10/10] Instrumented mysli::prepare --- .../MySqli/src/MySqliInstrumentation.php | 51 +++-- .../Integration/MySqliInstrumentationTest.php | 192 ++++++++++++++++-- 2 files changed, 206 insertions(+), 37 deletions(-) diff --git a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php index 55e9a892..4ee60abc 100644 --- a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -233,7 +233,9 @@ public static function register(): void hook( null, 'mysqli_prepare', - pre: null, + pre: static function (...$args) use ($instrumentation, $tracker) { + self::preparePreHook('mysqli_prepare', $instrumentation, $tracker, ...$args); + }, post: static function (...$args) use ($instrumentation, $tracker) { self::preparePostHook($instrumentation, $tracker, ...$args); } @@ -242,7 +244,9 @@ public static function register(): void hook( mysqli::class, 'prepare', - pre: null, + pre: static function (...$args) use ($instrumentation, $tracker) { + self::preparePreHook('mysqli::prepare', $instrumentation, $tracker, ...$args); + }, post: static function (...$args) use ($instrumentation, $tracker) { self::preparePostHook($instrumentation, $tracker, ...$args); } @@ -397,8 +401,6 @@ public static function register(): void self::stmtNextResultPostHook($instrumentation, $tracker, ...$args); } ); - - //TODO test to https://www.php.net/manual/en/mysqli.begin-transaction.php } /** @param non-empty-string $spanName */ @@ -533,9 +535,8 @@ private static function nextResultPostHook(CachedInstrumentation $instrumentatio private static function changeUserPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) { - if ($retVal != true) { - return; //TODO create error span? + return; } $mysqli = $obj ? $obj : $params[0]; @@ -549,30 +550,46 @@ private static function changeUserPostHook(CachedInstrumentation $instrumentatio private static function selectDbPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) { - if ($retVal != true) { - return; //TODO create error span? + 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]; - if ($exception || !$stmtRetVal instanceof mysqli_stmt) { - self::logDebug('mysqli::prepare failed', ['exception' => $exception, 'obj' => $obj, 'retVal' => $stmtRetVal, 'params' => $params]); + $errorStatus = null; - return; - } + $query = mb_convert_encoding($query, 'UTF-8'); + $operation = self::extractQueryCommand($query); - $mysqli = $obj ? $obj : $params[0]; - $query = $params[$obj ? 0 : 1]; + $attributes = $tracker->getMySqliAttributes($mysqli); + $attributes[TraceAttributes::DB_STATEMENT] = $query; + $attributes[TraceAttributes::DB_OPERATION_NAME] = $operation; - $tracker->trackMySqliFromStatement($mysqli, $stmtRetVal); + 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); - $tracker->addStatementAttribute($stmtRetVal, TraceAttributes::DB_STATEMENT, mb_convert_encoding($query, 'UTF-8')); - $tracker->addStatementAttribute($stmtRetVal, TraceAttributes::DB_OPERATION_NAME, self::extractQueryCommand($query)); + } 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 */ diff --git a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php index ec114ecc..35a8d66b 100644 --- a/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php +++ b/src/Instrumentation/MySqli/tests/Integration/MySqliInstrumentationTest.php @@ -648,6 +648,116 @@ public function test_mysqli_multi_query_procedural(): void $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); @@ -685,6 +795,12 @@ public function test_mysqli_transaction_rollback_objective(): void $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 @@ -749,6 +865,12 @@ public function test_mysqli_transaction_rollback_procedural(): void $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); @@ -816,6 +938,13 @@ public function test_mysqli_transaction_commit_objective(): void $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(); @@ -879,6 +1008,12 @@ public function test_mysqli_transaction_commit_procedural(): void $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); @@ -929,6 +1064,7 @@ public function test_mysqli_stmt_execute_objective(): void $stmt = $mysqli->stmt_init(); $stmt->prepare("SELECT email FROM users WHERE name='John Doe'"); + $stmt->execute(); $stmt->fetch(); $stmt->close(); @@ -1017,6 +1153,13 @@ public function test_mysqli_multiquery_with_calls(): void $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++; @@ -1195,7 +1338,7 @@ public function test_mysqli_select_db(): void $this->assertSame('mysqli::__construct', $this->storage->offsetGet($offset)->getName()); $offset++; - $res = $mysqli->query('SELECT CURRENT_USER();'); + $res = $mysqli->query('SELECT * FROM users;'); if ($res instanceof mysqli_result) { while ($res->fetch_object()) { } @@ -1203,7 +1346,7 @@ public function test_mysqli_select_db(): void $this->assertSame('mysqli::query', $this->storage->offsetGet($offset)->getName()); $this->assertAttributes($offset, [ - TraceAttributes::DB_STATEMENT => 'SELECT CURRENT_USER();', + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', TraceAttributes::DB_OPERATION_NAME => 'SELECT', TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, TraceAttributes::DB_USER => $this->user, @@ -1213,35 +1356,37 @@ public function test_mysqli_select_db(): void $mysqli->select_db('otel_db2'); - $offset++; - $res = $mysqli->query('SELECT CURRENT_USER();'); - if ($res instanceof mysqli_result) { - while ($res->fetch_object()) { - } + 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, + ]); } - $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 => 'otel_db2', - TraceAttributes::DB_SYSTEM => 'mysql', - ]); mysqli_select_db($mysqli, $this->database); - $offset++; - $res = $mysqli->query('SELECT CURRENT_USER();'); + $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 CURRENT_USER();', + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', TraceAttributes::DB_OPERATION_NAME => 'SELECT', TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, TraceAttributes::DB_USER => $this->user, @@ -1255,9 +1400,16 @@ public function test_mysqli_select_db(): void } + $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 CURRENT_USER();', + TraceAttributes::DB_STATEMENT => 'SELECT * FROM users;', TraceAttributes::DB_OPERATION_NAME => 'SELECT', TraceAttributes::SERVER_ADDRESS => $this->mysqlHost, TraceAttributes::DB_USER => $this->user,