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