diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c1b19f45..b631eeda7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -101,6 +101,7 @@ updates: - "/src/ResourceDetectors/DigitalOcean" - "/src/Sampler/RuleBased" - "/src/Shims/OpenTracing" + - "/src/SqlCommenter" - "/src/Symfony" - "/src/Symfony/src/OtelBundle" - "/src/Symfony/src/OtelSdkBundle" diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b4ac69e67..981ef8cb4 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -65,6 +65,7 @@ jobs: 'Sampler/RuleBased', 'Sampler/Xray', 'Shims/OpenTracing', + 'SqlCommenter', 'Symfony', 'Utils/Test' ] diff --git a/.gitsplit.yml b/.gitsplit.yml index eac359c84..b3aebb56d 100644 --- a/.gitsplit.yml +++ b/.gitsplit.yml @@ -94,6 +94,8 @@ splits: target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sampler-aws-xray.git" - prefix: "src/Shims/OpenTracing" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-shim-opentracing.git" + - prefix: "src/SqlCommenter" + target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sqlcommenter.git" - prefix: "src/Symfony" target: "https://${GH_TOKEN}@github.com/opentelemetry-php/contrib-sdk-bundle.git" - prefix: "src/Utils/Test" diff --git a/composer.json b/composer.json index 5591c3d21..7e23455ed 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "OpenTelemetry\\Contrib\\Resource\\Detector\\DigitalOcean\\": "src/ResourceDetectors/DigitalOcean/src", "OpenTelemetry\\Contrib\\Sampler\\RuleBased\\": "src/Sampler/RuleBased/src", "OpenTelemetry\\Contrib\\Shim\\OpenTracing\\": "src/Shims/OpenTracing/src", + "OpenTelemetry\\Contrib\\SqlCommenter\\": "src/SqlCommenter/src", "OpenTelemetry\\Contrib\\Symfony\\": "src/Symfony/src", "OpenTelemetry\\TestUtils\\": "src/Utils/Test/src" }, @@ -125,6 +126,7 @@ "OpenTelemetry\\Tests\\Propagation\\CloudTrace\\": "src/Propagation/CloudTrace/tests", "OpenTelemetry\\Tests\\Resource\\Detector\\Azure\\": "src/ResourceDetectors/Azure/tests", "OpenTelemetry\\Contrib\\Resource\\Detector\\DigitalOcean\\": "src/ResourceDetectors/DigitalOcean/tests", + "OpenTelemetry\\Tests\\Contrib\\SqlCommenter\\": "src/SqlCommenter/tests", "OpenTelemetry\\Tests\\Contrib\\Symfony\\": "src/Symfony/tests", "OpenTelemetry\\TestUtils\\Tests\\": "src/Utils/Test/tests" } @@ -171,6 +173,7 @@ "open-telemetry/opentelemetry-propagation-instana": "self.version", "open-telemetry/opentelemetry-propagation-server-timing": "self.version", "open-telemetry/opentelemetry-propagation-traceresponse": "self.version", + "open-telemetry/opentelemetry-sqlcommenter": "self.version", "open-telemetry/opentracing-shim": "self.version", "open-telemetry/sampler-rule-based": "self.version", "open-telemetry/symfony-sdk-bundle": "self.version", diff --git a/src/Instrumentation/MySqli/README.md b/src/Instrumentation/MySqli/README.md index 01a08d87a..4e3447918 100644 --- a/src/Instrumentation/MySqli/README.md +++ b/src/Instrumentation/MySqli/README.md @@ -54,3 +54,9 @@ The extension can be disabled via [runtime configuration](https://opentelemetry. OTEL_PHP_DISABLED_INSTRUMENTATIONS=mysqli ``` +## Database Context Propagation + +Enable context propagation for database queries by installing the following packages: +```shell +composer require open-telemetry/opentelemetry-sqlcommenter +``` \ No newline at end of file diff --git a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php index 51ec05692..c3e0fe483 100644 --- a/src/Instrumentation/MySqli/src/MySqliInstrumentation.php +++ b/src/Instrumentation/MySqli/src/MySqliInstrumentation.php @@ -20,12 +20,14 @@ /** * @phan-file-suppress PhanParamTooFewUnpack + * @phan-file-suppress PhanUndeclaredClassMethod */ class MySqliInstrumentation { use LogsMessagesTrait; public const NAME = 'mysqli'; + private const UNDEFINED = 'undefined'; private const MYSQLI_CONNECT_ARG_OFFSET = 0; private const MYSQLI_REAL_CONNECT_ARG_OFFSET = 1; // The mysqli_real_connect function in procedural mode requires a mysqli object as its first argument. The remaining arguments are consistent with those used in other connection methods, such as connect or __construct @@ -98,7 +100,7 @@ public static function register(): void null, 'mysqli_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::queryPreHook('mysqli_query', $instrumentation, $tracker, ...$args); + return self::queryPreHook('mysqli_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::queryPostHook($instrumentation, $tracker, ...$args); @@ -108,7 +110,7 @@ public static function register(): void mysqli::class, 'query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::queryPreHook('mysqli::query', $instrumentation, $tracker, ...$args); + return self::queryPreHook('mysqli::query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::queryPostHook($instrumentation, $tracker, ...$args); @@ -119,7 +121,7 @@ public static function register(): void null, 'mysqli_real_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::queryPreHook('mysqli_real_query', $instrumentation, $tracker, ...$args); + return self::queryPreHook('mysqli_real_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::queryPostHook($instrumentation, $tracker, ...$args); @@ -129,7 +131,7 @@ public static function register(): void mysqli::class, 'real_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::queryPreHook('mysqli::real_query', $instrumentation, $tracker, ...$args); + return self::queryPreHook('mysqli::real_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::queryPostHook($instrumentation, $tracker, ...$args); @@ -161,7 +163,7 @@ public static function register(): void null, 'mysqli_multi_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::queryPreHook('mysqli_multi_query', $instrumentation, $tracker, ...$args); + self::multiQueryPreHook('mysqli_multi_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::multiQueryPostHook($instrumentation, $tracker, ...$args); @@ -171,7 +173,7 @@ public static function register(): void mysqli::class, 'multi_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::queryPreHook('mysqli::multi_query', $instrumentation, $tracker, ...$args); + self::multiQueryPreHook('mysqli::multi_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::multiQueryPostHook($instrumentation, $tracker, ...$args); @@ -440,11 +442,46 @@ 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 + private static function queryPreHook(string $spanName, CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, ?string $class, string $function, ?string $filename, ?int $lineno): array { $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); $mysqli = $obj ? $obj : $params[0]; + $query = $obj ? $params[0] : $params[1]; + $query = mb_convert_encoding($query ?? self::UNDEFINED, 'UTF-8'); + if (!is_string($query)) { + $query = self::UNDEFINED; + } + $span->setAttributes([ + TraceAttributes::DB_QUERY_TEXT => $query, + TraceAttributes::DB_OPERATION_NAME => self::extractQueryCommand($query), + ]); + self::addTransactionLink($tracker, $span, $mysqli); + + if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) { + /** + * @psalm-suppress UndefinedClass + */ + $commenter = \OpenTelemetry\Contrib\SqlCommenter\SqlCommenter::getInstance(); + $query = $commenter->inject($query); + if ($commenter->isAttributeEnabled()) { + $span->setAttributes([ + TraceAttributes::DB_QUERY_TEXT => (string) $query, + ]); + } + if ($obj) { + return [ + 0 => $query, + ]; + } + + return [ + 1 => $query, + ]; + + } + + return []; } private static function queryPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) @@ -454,9 +491,6 @@ private static function queryPostHook(CachedInstrumentation $instrumentation, My $attributes = $tracker->getMySqliAttributes($mysqli); - $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($query, 'UTF-8'); - $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($query); - if ($retVal === false || $exception) { $attributes[TraceAttributes::DB_RESPONSE_STATUS_CODE] = $mysqli->errno; } @@ -466,6 +500,19 @@ private static function queryPostHook(CachedInstrumentation $instrumentation, My } + /** + * multi_query can execute multiple queries in one call. We will create a span for the multi_query call, but we will also track the individual queries and their results, creating spans for each query in the multi_query call. + * The individual query spans will be created in the next_result hook, which is called to fetch the results of each query in the multi_query call. + * As QueryPreHook has database span context propagation logic, we need to create this multiQueryPrehook function for multi_query to keep the pre-hook function unchanged. + */ + /** @param non-empty-string $spanName */ + private static function multiQueryPreHook(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 multiQueryPostHook(CachedInstrumentation $instrumentation, MySqliTracker $tracker, $obj, array $params, mixed $retVal, ?\Throwable $exception) { diff --git a/src/Instrumentation/PDO/README.md b/src/Instrumentation/PDO/README.md index 7a56fbda4..0cef16911 100644 --- a/src/Instrumentation/PDO/README.md +++ b/src/Instrumentation/PDO/README.md @@ -33,4 +33,11 @@ otel.instrumentation.pdo.distribute_statement_to_linked_spans = true or environment variable: ```shell OTEL_PHP_INSTRUMENTATION_PDO_DISTRIBUTE_STATEMENT_TO_LINKED_SPANS=true +``` + +## Database Context Propagation + +Enable context propagation for database queries by installing the following packages: +```shell +composer require open-telemetry/opentelemetry-sqlcommenter ``` \ No newline at end of file diff --git a/src/Instrumentation/PDO/src/PDOInstrumentation.php b/src/Instrumentation/PDO/src/PDOInstrumentation.php index a739d9e38..2f5bdaea3 100644 --- a/src/Instrumentation/PDO/src/PDOInstrumentation.php +++ b/src/Instrumentation/PDO/src/PDOInstrumentation.php @@ -19,9 +19,11 @@ use PDOStatement; use Throwable; +/** @phan-file-suppress PhanUndeclaredClassMethod */ class PDOInstrumentation { public const NAME = 'pdo'; + private const UNDEFINED = 'undefined'; public static function register(): void { @@ -112,8 +114,12 @@ public static function register(): void /** @psalm-suppress ArgumentTypeCoercion */ $builder = self::makeBuilder($instrumentation, 'PDO::query', $function, $class, $filename, $lineno) ->setSpanKind(SpanKind::KIND_CLIENT); + $query = mb_convert_encoding($params[0] ?? self::UNDEFINED, 'UTF-8'); + if (!is_string($query)) { + $query = self::UNDEFINED; + } if ($class === PDO::class) { - $builder->setAttribute(DbAttributes::DB_QUERY_TEXT, mb_convert_encoding($params[0] ?? 'undefined', 'UTF-8')); + $builder->setAttribute(DbAttributes::DB_QUERY_TEXT, $query); } $parent = Context::getCurrent(); $span = $builder->startSpan(); @@ -122,6 +128,35 @@ public static function register(): void $span->setAttributes($attributes); Context::storage()->attach($span->storeInContext($parent)); + + if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) { + if (array_key_exists(DbAttributes::DB_SYSTEM_NAME, $attributes)) { + /** @psalm-suppress PossiblyInvalidCast */ + switch ((string) $attributes[DbAttributes::DB_SYSTEM_NAME]) { + case 'postgresql': + case 'mysql': + /** + * @psalm-suppress UndefinedClass + */ + $commenter = \OpenTelemetry\Contrib\SqlCommenter\SqlCommenter::getInstance(); + $query = $commenter->inject($query); + if ($commenter->isAttributeEnabled()) { + $span->setAttributes([ + DbAttributes::DB_QUERY_TEXT => (string) $query, + ]); + } + + return [ + 0 => $query, + ]; + default: + // Do nothing, not a database we want to propagate + break; + } + } + } + + return []; }, post: static function (PDO $pdo, array $params, mixed $statement, ?Throwable $exception) { self::end($exception); @@ -135,8 +170,12 @@ public static function register(): void /** @psalm-suppress ArgumentTypeCoercion */ $builder = self::makeBuilder($instrumentation, 'PDO::exec', $function, $class, $filename, $lineno) ->setSpanKind(SpanKind::KIND_CLIENT); + $query = mb_convert_encoding($params[0] ?? self::UNDEFINED, 'UTF-8'); + if (!is_string($query)) { + $query = self::UNDEFINED; + } if ($class === PDO::class) { - $builder->setAttribute(DbAttributes::DB_QUERY_TEXT, mb_convert_encoding($params[0] ?? 'undefined', 'UTF-8')); + $builder->setAttribute(DbAttributes::DB_QUERY_TEXT, $query); } $parent = Context::getCurrent(); $span = $builder->startSpan(); @@ -145,6 +184,35 @@ public static function register(): void $span->setAttributes($attributes); Context::storage()->attach($span->storeInContext($parent)); + + if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) { + if (array_key_exists(DbAttributes::DB_SYSTEM_NAME, $attributes)) { + /** @psalm-suppress PossiblyInvalidCast */ + switch ((string) $attributes[DbAttributes::DB_SYSTEM_NAME]) { + case 'postgresql': + case 'mysql': + /** + * @psalm-suppress UndefinedClass + */ + $commenter = \OpenTelemetry\Contrib\SqlCommenter\SqlCommenter::getInstance(); + $query = $commenter->inject($query); + if ($commenter->isAttributeEnabled()) { + $span->setAttributes([ + DbAttributes::DB_QUERY_TEXT => (string) $query, + ]); + } + + return [ + 0 => $query, + ]; + default: + // Do nothing, not a database we want to propagate + break; + } + } + } + + return []; }, post: static function (PDO $pdo, array $params, mixed $statement, ?Throwable $exception) { self::end($exception); diff --git a/src/Instrumentation/PostgreSql/README.md b/src/Instrumentation/PostgreSql/README.md index 1bbdd22af..54d8125c7 100644 --- a/src/Instrumentation/PostgreSql/README.md +++ b/src/Instrumentation/PostgreSql/README.md @@ -67,6 +67,13 @@ The extension can be disabled via [runtime configuration](https://opentelemetry. OTEL_PHP_DISABLED_INSTRUMENTATIONS=postgresql ``` +## Database Context Propagation + +Enable context propagation for database queries by installing the following packages: +```shell +composer require open-telemetry/opentelemetry-sqlcommenter +``` + ## Compatibility PHP 8.2 or newer is required diff --git a/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php b/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php index 5fd030681..46fa72c66 100644 --- a/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php +++ b/src/Instrumentation/PostgreSql/src/PostgreSqlInstrumentation.php @@ -20,12 +20,14 @@ /** * @phan-file-suppress PhanParamTooFewUnpack + * @phan-file-suppress PhanUndeclaredClassMethod */ class PostgreSqlInstrumentation { use LogsMessagesTrait; public const NAME = 'postgresql'; + private const UNDEFINED = 'undefined'; public static function register(): void { @@ -128,7 +130,7 @@ public static function register(): void null, 'pg_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::basicPreHook('pg_query', $instrumentation, $tracker, ...$args); + return self::basicPreHookWithContextPropagator('pg_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::queryPostHook($instrumentation, $tracker, ...$args); @@ -171,7 +173,7 @@ public static function register(): void null, 'pg_send_query', pre: static function (...$args) use ($instrumentation, $tracker) { - self::basicPreHook('pg_send_query', $instrumentation, $tracker, ...$args); + return self::basicPreHookWithContextPropagator('pg_send_query', $instrumentation, $tracker, ...$args); }, post: static function (...$args) use ($instrumentation, $tracker) { self::sendQueryPostHook($instrumentation, $tracker, ...$args); @@ -298,6 +300,41 @@ private static function basicPreHook(string $spanName, CachedInstrumentation $in self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); } + /** @param non-empty-string $spanName */ + private static function basicPreHookWithContextPropagator(string $spanName, CachedInstrumentation $instrumentation, PgSqlTracker $tracker, $obj, array $params, ?string $class, ?string $function, ?string $filename, ?int $lineno): array + { + $span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []); + + $query = mb_convert_encoding($params[1] ?? self::UNDEFINED, 'UTF-8'); + if (!is_string($query)) { + $query = self::UNDEFINED; + } + + $span->setAttributes([ + TraceAttributes::DB_QUERY_TEXT => $query, + TraceAttributes::DB_OPERATION_NAME => self::extractQueryCommand($query), + ]); + + if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) { + /** + * @psalm-suppress UndefinedClass + */ + $commenter = \OpenTelemetry\Contrib\SqlCommenter\SqlCommenter::getInstance(); + $query = $commenter->inject($query); + if ($commenter->isAttributeEnabled()) { + $span->setAttributes([ + TraceAttributes::DB_QUERY_TEXT => (string) $query, + ]); + } + + return [ + 1 => $query, + ]; + } + + return []; + } + private static function tableOperationsPostHook(CachedInstrumentation $instrumentation, PgSqlTracker $tracker, bool $dropIfNoError, ?string $operationName, $obj, array $params, mixed $retVal, ?\Throwable $exception) { $connection = $params[0]; @@ -405,9 +442,6 @@ private static function sendQueryPostHook(CachedInstrumentation $instrumentation $tracker->addAsyncLinkForConnection($params[0], Span::getCurrent()->getContext()); } - $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($params[1], 'UTF-8'); - $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($params[1]); - $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; self::endSpan($attributes, $exception, $errorStatus); } @@ -416,9 +450,6 @@ private static function queryPostHook(CachedInstrumentation $instrumentation, Pg { $attributes = $tracker->getConnectionAttributes($params[0]); - $attributes[TraceAttributes::DB_QUERY_TEXT] = mb_convert_encoding($params[1], 'UTF-8'); - $attributes[TraceAttributes::DB_OPERATION_NAME] = self::extractQueryCommand($params[1]); - $errorStatus = $retVal == false ? pg_last_error($params[0]) : null; self::endSpan($attributes, $exception, $errorStatus); } diff --git a/src/SqlCommenter/.gitattributes b/src/SqlCommenter/.gitattributes new file mode 100644 index 000000000..1676cf825 --- /dev/null +++ b/src/SqlCommenter/.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/SqlCommenter/.gitignore b/src/SqlCommenter/.gitignore new file mode 100644 index 000000000..57872d0f1 --- /dev/null +++ b/src/SqlCommenter/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/src/SqlCommenter/.php-cs-fixer.php b/src/SqlCommenter/.php-cs-fixer.php new file mode 100644 index 000000000..e35fa078c --- /dev/null +++ b/src/SqlCommenter/.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/SqlCommenter/README.md b/src/SqlCommenter/README.md new file mode 100644 index 000000000..ffe3723dd --- /dev/null +++ b/src/SqlCommenter/README.md @@ -0,0 +1,82 @@ +[![Releases](https://img.shields.io/badge/releases-purple)](https://github.com/opentelemetry-php/contrib-sqlcommenter/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/SqlCommenter) +[![Mirror](https://img.shields.io/badge/mirror-opentelemetry--php--contrib-blue)](https://github.com/opentelemetry-php/contrib-sqlcommenter) +[![Latest Version](http://poser.pugx.org/open-telemetry/opentelemetry-sqlcommenter/v/unstable)](https://packagist.org/packages/open-telemetry/opentelemetry-sqlcommenter/) +[![Stable](http://poser.pugx.org/open-telemetry/opentelemetry-sqlcommenter/v/stable)](https://packagist.org/packages/open-telemetry/opentelemetry-sqlcommenter/) + +> **Note:** This is a read-only subtree split of [open-telemetry/opentelemetry-php-contrib](https://github.com/open-telemetry/opentelemetry-php-contrib). + +# OpenTelemetry SQL Commenter + +OpenTelemetry SQL Commenter for PHP provides a [SqlCommenter](https://opentelemetry.io/docs/specs/semconv/database/database-spans/#sql-commenter) implementation, enabling you to inject trace and context comments into SQL queries for enhanced observability and distributed tracing. + +## Installation + +Install via Composer: + +```bash +composer require open-telemetry/opentelemetry-sqlcommenter +``` + +## Usage + +Inject comments into your SQL query as follows: + +```php +use OpenTelemetry\SqlCommenter\SqlCommenter; + +$comments = [ + 'traceparent' => '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00', + 'custom' => 'value', +]; +$query = SqlCommenter::inject($query, $comments); +``` + +## Configuration + +- **Context Propagators** + + Set the propagators to use (comma-separated): + + ```shell + OTEL_PHP_SQLCOMMENTER_CONTEXT_PROPAGATORS=tracecontext + ``` + Default: `''` + +- **SQL Commenter Attribute** + + Add SQL comments to `DbAttributes::DB_QUERY_TEXT` in span attributes: + + ```shell + otel.sqlcommenter.attribute = true + ``` + or via environment variable: + ```shell + OTEL_PHP_SQLCOMMENTER_ATTRIBUTE=true + ``` + Default: `false` + +- **Prepend Comments** + + Prepend comments to the query statement using either a configuration directive: + + ```shell + otel.sqlcommenter.prepend = true + ``` + or via environment variable: + + ```shell + OTEL_PHP_SQLCOMMENTER_PREPEND=true + ``` + Default: `false` + +## Development + +Install dependencies and run tests from the `SqlCommenter` subdirectory: + +```bash +composer install +./vendor/bin/phpunit tests +``` + diff --git a/src/SqlCommenter/composer.json b/src/SqlCommenter/composer.json new file mode 100644 index 000000000..22ce35ed2 --- /dev/null +++ b/src/SqlCommenter/composer.json @@ -0,0 +1,41 @@ +{ + "name": "open-telemetry/opentelemetry-sqlcommenter", + "description": "OpenTelemetry sqlcommenter.", + "keywords": ["opentelemetry", "otel", "open-telemetry", "sqlcommenter"], + "type": "library", + "homepage": "https://opentelemetry.io/docs/php", + "readme": "./README.md", + "license": "Apache-2.0", + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": "^8.1", + "open-telemetry/api": "^1.0", + "open-telemetry/context": "^1.0" + }, + "autoload": { + "psr-4": { + "OpenTelemetry\\Contrib\\SqlCommenter\\": "src/" + } + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5.0", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "psalm/plugin-phpunit": "^0.19.2", + "open-telemetry/sdk": "^1.0", + "phpunit/phpunit": "^9.5", + "vimeo/psalm": "6.4.0", + "symfony/http-client": "^5.4|^6.0", + "guzzlehttp/promises": "^2", + "php-http/message-factory": "^1.0", + "nyholm/psr7": "^1.5" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } + } +} diff --git a/src/SqlCommenter/phpstan.neon.dist b/src/SqlCommenter/phpstan.neon.dist new file mode 100644 index 000000000..ed94c13da --- /dev/null +++ b/src/SqlCommenter/phpstan.neon.dist @@ -0,0 +1,9 @@ +includes: + - vendor/phpstan/phpstan-phpunit/extension.neon + +parameters: + tmpDir: var/cache/phpstan + level: 5 + paths: + - src + - tests diff --git a/src/SqlCommenter/phpunit.xml.dist b/src/SqlCommenter/phpunit.xml.dist new file mode 100644 index 000000000..44d976f6f --- /dev/null +++ b/src/SqlCommenter/phpunit.xml.dist @@ -0,0 +1,44 @@ + + + + + + + src + + + + + + + + + + + + + tests/Unit + + + + diff --git a/src/SqlCommenter/psalm.xml.dist b/src/SqlCommenter/psalm.xml.dist new file mode 100644 index 000000000..155711712 --- /dev/null +++ b/src/SqlCommenter/psalm.xml.dist @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/src/SqlCommenter/src/ContextPropagatorFactory.php b/src/SqlCommenter/src/ContextPropagatorFactory.php new file mode 100644 index 000000000..267206dd1 --- /dev/null +++ b/src/SqlCommenter/src/ContextPropagatorFactory.php @@ -0,0 +1,73 @@ +buildPropagator($propagators[0]); + if ($propagator !== null && is_a($propagator, NoopTextMapPropagator::class)) { + return null; + } + + return $propagator; + default: + $props = $this->buildPropagators($propagators); + if ($props) { + return new MultiTextMapPropagator($props); + } + + return null; + } + } + + /** + * @return ?list + */ + private function buildPropagators(array $names): ?array + { + $propagators = []; + foreach ($names as $name) { + $propagator = $this->buildPropagator($name); + if ($propagator !== null && !is_a($propagator, NoopTextMapPropagator::class)) { + $propagators[] = $propagator; + } + } + if (count($propagators) === 0) { + return null; + } + + return $propagators; + } + + private function buildPropagator(string $name): ?TextMapPropagatorInterface + { + try { + return Registry::textMapPropagator($name); + } catch (\RuntimeException $e) { + self::logWarning($e->getMessage()); + } + + return null; + } +} diff --git a/src/SqlCommenter/src/SqlCommenter.php b/src/SqlCommenter/src/SqlCommenter.php new file mode 100644 index 000000000..35316c826 --- /dev/null +++ b/src/SqlCommenter/src/SqlCommenter.php @@ -0,0 +1,62 @@ +create()); + } + + return self::$instance; + } + + public function isAttributeEnabled(): bool + { + if (class_exists('OpenTelemetry\\SDK\\Common\\Configuration\\Configuration') && \OpenTelemetry\SDK\Common\Configuration\Configuration::getBoolean('OTEL_PHP_SQLCOMMENTER_ATTRIBUTE', false)) { + return true; + } + + return filter_var(get_cfg_var('otel.sqlcommenter.attribute'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; + } + + public function isPrepend(): bool + { + if (class_exists('OpenTelemetry\SDK\Common\Configuration\Configuration') && \OpenTelemetry\SDK\Common\Configuration\Configuration::getBoolean('OTEL_PHP_SQLCOMMENTER_PREPEND', false)) { + return true; + } + + return filter_var(get_cfg_var('otel.sqlcommenter.prepend'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; + } + + public function inject(string $query): string + { + $comments = []; + if ($this->contextPropagator !== null) { + $this->contextPropagator->inject($comments); + } else { + Globals::propagator()->inject($comments); + } + $query = trim($query); + if ($this->isPrepend()) { + return Utils::formatComments(array_filter($comments)) . $query; + } + $hasSemicolon = $query !== '' && $query[strlen($query) - 1] === ';'; + $query = rtrim($query, ';'); + + return $query . Utils::formatComments(array_filter($comments)) . ($hasSemicolon ? ';' : ''); + } +} diff --git a/src/SqlCommenter/src/Utils.php b/src/SqlCommenter/src/Utils.php new file mode 100644 index 000000000..278ac18de --- /dev/null +++ b/src/SqlCommenter/src/Utils.php @@ -0,0 +1,36 @@ + Utils::customUrlEncode($key) . "='" . Utils::customUrlEncode($value) . "'", + $comments, + array_keys($comments) + ), + ) . '*/'; + } + + private static function customUrlEncode(string $input): string + { + $encodedString = urlencode($input); + + // Since SQL uses '%' as a keyword, '%' is a by-product of url quoting + // e.g. foo,bar --> foo%2Cbar + // thus in our quoting, we need to escape it too to finally give + // foo,bar --> foo%%2Cbar + + return str_replace('%', '%%', $encodedString); + } +} diff --git a/src/SqlCommenter/tests/Unit/ContextPropagatorFactoryTest.php b/src/SqlCommenter/tests/Unit/ContextPropagatorFactoryTest.php new file mode 100644 index 000000000..ddbb0b48f --- /dev/null +++ b/src/SqlCommenter/tests/Unit/ContextPropagatorFactoryTest.php @@ -0,0 +1,52 @@ +create(); + if ($expected === null) { + $this->assertNull($propagator); + } else { + $this->assertInstanceOf($expected, $propagator); + } + putenv('OTEL_PHP_SQLCOMMENTER_CONTEXT_PROPAGATORS'); + } + + public static function propagatorsProvider(): array + { + return [ + [KnownValues::VALUE_BAGGAGE, BaggagePropagator::class], + [KnownValues::VALUE_TRACECONTEXT, TraceContextPropagator::class], + [KnownValues::VALUE_NONE, null], + [sprintf('%s,%s', KnownValues::VALUE_TRACECONTEXT, KnownValues::VALUE_BAGGAGE), MultiTextMapPropagator::class], + ['', null], + ['invalid', null], + ]; + } +} diff --git a/src/SqlCommenter/tests/Unit/SqlCommenterTest.php b/src/SqlCommenter/tests/Unit/SqlCommenterTest.php new file mode 100644 index 000000000..bf5b00fc5 --- /dev/null +++ b/src/SqlCommenter/tests/Unit/SqlCommenterTest.php @@ -0,0 +1,92 @@ +set($carrier, 'key1', 'value1'); + $setter->set($carrier, 'key2', 'value2'); + $setter->set($carrier, 'key3', 'value3'); + } + + #[\Override] + public function extract($carrier, ?PropagationGetterInterface $getter = null, ?ContextInterface $context = null): ContextInterface + { + return $context ?? Context::getCurrent(); + } + + #[\Override] + public function fields(): array + { + return ['key1', 'key2', 'key3']; + } +} +class SqlCommenterTest extends TestCase +{ + private SqlCommenter $commenter; + #[\Override] + protected function setUp(): void + { + $this->commenter = SqlCommenter::getInstance(); + } + public function testIsPrependReturnsTrue() + { + $_SERVER['OTEL_PHP_SQLCOMMENTER_PREPEND'] = true; + $result = $this->commenter->isPrepend(); + $this->assertTrue($result); + } + + public function testIsPrependReturnsFalse() + { + $_SERVER['OTEL_PHP_SQLCOMMENTER_PREPEND'] = false; + $result = $this->commenter->isPrepend(); + $this->assertFalse($result); + } + + public function testInjectPrepend() + { + $commenter = new SqlCommenter(new SqlCommenterTestPropagator()); + $_SERVER['OTEL_PHP_SQLCOMMENTER_PREPEND'] = true; + $query = 'SELECT 1;'; + $result = $commenter->inject($query); + $this->assertEquals("/*key1='value1',key2='value2',key3='value3'*/SELECT 1;", $result); + } + + public function testInjectAppend() + { + $commenter = new SqlCommenter(new SqlCommenterTestPropagator()); + $_SERVER['OTEL_PHP_SQLCOMMENTER_PREPEND'] = false; + $query = 'SELECT 1;'; + $result = $commenter->inject($query); + $this->assertEquals("SELECT 1/*key1='value1',key2='value2',key3='value3'*/;", $result); + } + + public function testIsAttributeEnabledReturnsTrue() + { + $_SERVER['OTEL_PHP_SQLCOMMENTER_ATTRIBUTE'] = true; + $result = $this->commenter->isAttributeEnabled(); + $this->assertTrue($result); + } + + public function testIsAttributeEnabledReturnsFalse() + { + $_SERVER['OTEL_PHP_SQLCOMMENTER_ATTRIBUTE'] = false; + $result = $this->commenter->isAttributeEnabled(); + $this->assertFalse($result); + } +} diff --git a/src/SqlCommenter/tests/Unit/UtilsTest.php b/src/SqlCommenter/tests/Unit/UtilsTest.php new file mode 100644 index 000000000..08350c909 --- /dev/null +++ b/src/SqlCommenter/tests/Unit/UtilsTest.php @@ -0,0 +1,36 @@ +assertEquals("/*key1='value1',key2='value2'*/", Utils::formatComments(['key1' => 'value1', 'key2' => 'value2'])); + } + + public function testFormatCommentsWithoutKeys(): void + { + $this->assertEquals('', Utils::formatComments([])); + } + + public function testFormatCommentsWithSpecialCharKeys(): void + { + $this->assertEquals("/*key1='value1%%40',key2='value2'*/", Utils::formatComments(['key1' => 'value1@', 'key2' => 'value2'])); + } + + public function testFormatCommentsWithPlaceholder(): void + { + $this->assertEquals("/*key1='value1%%3F',key2='value2'*/", Utils::formatComments(['key1' => 'value1?', 'key2' => 'value2'])); + } + + public function testFormatCommentsWithNamedPlaceholder(): void + { + $this->assertEquals("/*key1='%%3Anamed',key2='value2'*/", Utils::formatComments(['key1' => ':named', 'key2' => 'value2'])); + } +}