Skip to content

Commit b48a443

Browse files
PuvaanRaajclaude
andcommitted
fix(mysqli,pdo): preserve binary SQL literals when sqlcommenter is installed
The mysqli and PDO query pre-hooks reassigned the local $query to mb_convert_encoding($query, 'UTF-8') for the db.query.text span attribute. When opentelemetry-sqlcommenter is also installed, that same (now UTF-8-mangled) variable was injected and returned as the substituted statement argument, so any non-UTF-8 byte in the executed SQL was replaced with 0x3F ('?'). This silently corrupted BINARY/VARBINARY/BLOB writes that embed raw bytes in a quoted literal (e.g. sha1() into a BINARY(20) column). Convert to UTF-8 only for the span attribute (a separate $displayQuery), and inject sqlcommenter into the raw $query so the wire statement keeps its exact bytes. This mirrors the already-correct PDO::prepare hook. When the sqlcommenter attribute is enabled, the attribute is set from a UTF-8-safe copy of the injected query so span export stays valid. Adds integration regression tests (real MySQL) proving a binary literal round-trips unmodified through mysqli::query, PDO::exec and PDO::query with sqlcommenter installed; both fail before this change and pass after. Fixes open-telemetry/opentelemetry-php#1960 - Rollback: revert commit; restores prior (corrupting) behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Risk-Level: medium AI-Agent: claude-opus-4-8
1 parent 849e2ef commit b48a443

6 files changed

Lines changed: 239 additions & 19 deletions

File tree

src/Instrumentation/MySqli/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"phpstan/phpstan": "^1.1",
3535
"phpstan/phpstan-phpunit": "^1.0",
3636
"psalm/plugin-phpunit": "^0.19.2",
37+
"open-telemetry/opentelemetry-sqlcommenter": "^0.2",
3738
"open-telemetry/sdk": "^1.0",
3839
"phpunit/phpunit": "^9.5",
3940
"vimeo/psalm": "6.4.0"

src/Instrumentation/MySqli/src/MySqliInstrumentation.php

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -447,26 +447,32 @@ private static function queryPreHook(string $spanName, CachedInstrumentation $in
447447
$span = self::startSpan($spanName, $instrumentation, $class, $function, $filename, $lineno, []);
448448
$mysqli = $obj ? $obj : $params[0];
449449
$query = $obj ? $params[0] : $params[1];
450-
$query = mb_convert_encoding($query ?? self::UNDEFINED, 'UTF-8');
451-
if (!is_string($query)) {
452-
$query = self::UNDEFINED;
450+
451+
// The UTF-8 conversion is only for the (protobuf-safe) span attribute. It must not
452+
// be assigned back to $query, which may be returned as the executed statement when
453+
// sqlcommenter is present: converting would replace non-UTF-8 bytes with `?` and
454+
// corrupt binary literals (BINARY/VARBINARY/BLOB) on the wire. See #1960.
455+
$displayQuery = is_string($query) ? mb_convert_encoding($query, 'UTF-8') : self::UNDEFINED;
456+
if (!is_string($displayQuery)) {
457+
$displayQuery = self::UNDEFINED;
453458
}
454459
$span->setAttributes([
455-
TraceAttributes::DB_QUERY_TEXT => $query,
456-
TraceAttributes::DB_OPERATION_NAME => self::extractQueryCommand($query),
460+
TraceAttributes::DB_QUERY_TEXT => $displayQuery,
461+
TraceAttributes::DB_OPERATION_NAME => self::extractQueryCommand($displayQuery),
457462
]);
458463

459464
self::addTransactionLink($tracker, $span, $mysqli);
460465

461-
if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) {
466+
if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && is_string($query)) {
462467
/**
463468
* @psalm-suppress UndefinedClass
464469
*/
465470
$commenter = \OpenTelemetry\Contrib\SqlCommenter\SqlCommenter::getInstance();
471+
// Inject into the raw query so binary literals are preserved on the wire.
466472
$query = $commenter->inject($query);
467473
if ($commenter->isAttributeEnabled()) {
468474
$span->setAttributes([
469-
TraceAttributes::DB_QUERY_TEXT => (string) $query,
475+
TraceAttributes::DB_QUERY_TEXT => mb_convert_encoding($query, 'UTF-8'),
470476
]);
471477
}
472478
if ($obj) {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Tests\Instrumentation\MySqli\Integration;
6+
7+
use ArrayObject;
8+
use mysqli;
9+
use mysqli_result;
10+
use OpenTelemetry\API\Instrumentation\Configurator;
11+
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
12+
use OpenTelemetry\Context\ScopeInterface;
13+
use OpenTelemetry\Contrib\SqlCommenter\SqlCommenter;
14+
use OpenTelemetry\SDK\Trace\ImmutableSpan;
15+
use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter;
16+
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
17+
use OpenTelemetry\SDK\Trace\TracerProvider;
18+
use PHPUnit\Framework\TestCase;
19+
20+
/**
21+
* Regression test for #1960.
22+
*
23+
* When sqlcommenter is installed, the mysqli query pre-hook substitutes its (possibly
24+
* modified) query back as the executed statement. It must inject into the raw query so
25+
* binary literals survive on the wire, rather than UTF-8-converting the executed
26+
* statement and replacing non-UTF-8 bytes with `?`.
27+
*/
28+
class MySqliSqlCommenterBinaryTest extends TestCase
29+
{
30+
private ScopeInterface $scope;
31+
/** @var ArrayObject<int, ImmutableSpan> */
32+
private ArrayObject $storage;
33+
34+
public function setUp(): void
35+
{
36+
$this->storage = new ArrayObject();
37+
$tracerProvider = new TracerProvider(
38+
new SimpleSpanProcessor(
39+
new InMemoryExporter($this->storage)
40+
)
41+
);
42+
43+
$this->scope = Configurator::create()
44+
->withTracerProvider($tracerProvider)
45+
->withPropagator(TraceContextPropagator::getInstance())
46+
->activate();
47+
}
48+
49+
public function tearDown(): void
50+
{
51+
$this->scope->detach();
52+
}
53+
54+
public function test_binary_literal_survives_sqlcommenter_injection(): void
55+
{
56+
// The corrupting code path is the sqlcommenter branch of the pre-hook, so it must
57+
// actually be reachable for this test to be meaningful.
58+
$this->assertTrue(class_exists(SqlCommenter::class), 'sqlcommenter must be installed to exercise this path');
59+
60+
$host = getenv('MYSQL_HOST') ?: '127.0.0.1';
61+
$mysqli = new mysqli($host, 'otel_user', 'otel_passwd', 'otel_db');
62+
$mysqli->set_charset('utf8mb4');
63+
64+
$mysqli->query('DROP TABLE IF EXISTS otel_binary_1960');
65+
$mysqli->query('CREATE TABLE otel_binary_1960 (h BINARY(20) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
66+
67+
// sha1("hello world") — 20 bytes containing several high (non-UTF-8) bytes.
68+
$bin = hex2bin('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed');
69+
$this->assertIsString($bin);
70+
71+
$escaped = "'" . $mysqli->real_escape_string($bin) . "'";
72+
$mysqli->query("INSERT INTO otel_binary_1960 (h) VALUES ($escaped)");
73+
74+
$result = $mysqli->query('SELECT h FROM otel_binary_1960');
75+
$this->assertInstanceOf(mysqli_result::class, $result);
76+
$row = $result->fetch_row();
77+
$this->assertIsArray($row);
78+
$this->assertIsString($row[0]);
79+
80+
$mysqli->query('DROP TABLE IF EXISTS otel_binary_1960');
81+
$mysqli->close();
82+
83+
$this->assertSame(
84+
bin2hex($bin),
85+
bin2hex($row[0]),
86+
'binary column bytes must be stored unmodified when sqlcommenter is installed',
87+
);
88+
}
89+
}

src/Instrumentation/PDO/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"phpstan/phpstan": "^1.1",
2727
"phpstan/phpstan-phpunit": "^1.0",
2828
"psalm/plugin-phpunit": "^0.19.2",
29+
"open-telemetry/opentelemetry-sqlcommenter": "^0.2",
2930
"open-telemetry/sdk": "^1.0",
3031
"open-telemetry/test-utils": "^0.2.0",
3132
"phpunit/phpunit": "^9.5",

src/Instrumentation/PDO/src/PDOInstrumentation.php

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,17 @@ public static function register(): void
114114
/** @psalm-suppress ArgumentTypeCoercion */
115115
$builder = self::makeBuilder($instrumentation, 'PDO::query', $function, $class, $filename, $lineno)
116116
->setSpanKind(SpanKind::KIND_CLIENT);
117-
$query = mb_convert_encoding($params[0] ?? self::UNDEFINED, 'UTF-8');
118-
if (!is_string($query)) {
119-
$query = self::UNDEFINED;
117+
$query = $params[0] ?? null;
118+
// The UTF-8 conversion is only for the (protobuf-safe) span attribute. It must
119+
// not be assigned back to $query, which may be returned as the executed statement
120+
// when sqlcommenter is present: converting would replace non-UTF-8 bytes with `?`
121+
// and corrupt binary literals (BINARY/VARBINARY/BLOB) on the wire. See #1960.
122+
$displayQuery = is_string($query) ? mb_convert_encoding($query, 'UTF-8') : self::UNDEFINED;
123+
if (!is_string($displayQuery)) {
124+
$displayQuery = self::UNDEFINED;
120125
}
121126
if ($class === PDO::class) {
122-
$builder->setAttribute(DbAttributes::DB_QUERY_TEXT, $query);
127+
$builder->setAttribute(DbAttributes::DB_QUERY_TEXT, $displayQuery);
123128
}
124129
$parent = Context::getCurrent();
125130
$span = $builder->startSpan();
@@ -129,7 +134,7 @@ public static function register(): void
129134

130135
Context::storage()->attach($span->storeInContext($parent));
131136

132-
if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) {
137+
if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && is_string($query)) {
133138
if (array_key_exists(DbAttributes::DB_SYSTEM_NAME, $attributes)) {
134139
/** @psalm-suppress PossiblyInvalidCast */
135140
switch ((string) $attributes[DbAttributes::DB_SYSTEM_NAME]) {
@@ -142,7 +147,7 @@ public static function register(): void
142147
$query = $commenter->inject($query);
143148
if ($commenter->isAttributeEnabled()) {
144149
$span->setAttributes([
145-
DbAttributes::DB_QUERY_TEXT => (string) $query,
150+
DbAttributes::DB_QUERY_TEXT => mb_convert_encoding($query, 'UTF-8'),
146151
]);
147152
}
148153

@@ -170,12 +175,17 @@ public static function register(): void
170175
/** @psalm-suppress ArgumentTypeCoercion */
171176
$builder = self::makeBuilder($instrumentation, 'PDO::exec', $function, $class, $filename, $lineno)
172177
->setSpanKind(SpanKind::KIND_CLIENT);
173-
$query = mb_convert_encoding($params[0] ?? self::UNDEFINED, 'UTF-8');
174-
if (!is_string($query)) {
175-
$query = self::UNDEFINED;
178+
$query = $params[0] ?? null;
179+
// The UTF-8 conversion is only for the (protobuf-safe) span attribute. It must
180+
// not be assigned back to $query, which may be returned as the executed statement
181+
// when sqlcommenter is present: converting would replace non-UTF-8 bytes with `?`
182+
// and corrupt binary literals (BINARY/VARBINARY/BLOB) on the wire. See #1960.
183+
$displayQuery = is_string($query) ? mb_convert_encoding($query, 'UTF-8') : self::UNDEFINED;
184+
if (!is_string($displayQuery)) {
185+
$displayQuery = self::UNDEFINED;
176186
}
177187
if ($class === PDO::class) {
178-
$builder->setAttribute(DbAttributes::DB_QUERY_TEXT, $query);
188+
$builder->setAttribute(DbAttributes::DB_QUERY_TEXT, $displayQuery);
179189
}
180190
$parent = Context::getCurrent();
181191
$span = $builder->startSpan();
@@ -185,7 +195,7 @@ public static function register(): void
185195

186196
Context::storage()->attach($span->storeInContext($parent));
187197

188-
if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && $query !== self::UNDEFINED) {
198+
if (class_exists('OpenTelemetry\Contrib\SqlCommenter\SqlCommenter') && is_string($query)) {
189199
if (array_key_exists(DbAttributes::DB_SYSTEM_NAME, $attributes)) {
190200
/** @psalm-suppress PossiblyInvalidCast */
191201
switch ((string) $attributes[DbAttributes::DB_SYSTEM_NAME]) {
@@ -198,7 +208,7 @@ public static function register(): void
198208
$query = $commenter->inject($query);
199209
if ($commenter->isAttributeEnabled()) {
200210
$span->setAttributes([
201-
DbAttributes::DB_QUERY_TEXT => (string) $query,
211+
DbAttributes::DB_QUERY_TEXT => mb_convert_encoding($query, 'UTF-8'),
202212
]);
203213
}
204214

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenTelemetry\Tests\Instrumentation\PDO\Integration;
6+
7+
use ArrayObject;
8+
use OpenTelemetry\API\Instrumentation\Configurator;
9+
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
10+
use OpenTelemetry\Context\ScopeInterface;
11+
use OpenTelemetry\Contrib\SqlCommenter\SqlCommenter;
12+
use OpenTelemetry\SDK\Trace\ImmutableSpan;
13+
use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter;
14+
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
15+
use OpenTelemetry\SDK\Trace\TracerProvider;
16+
use PDO;
17+
use PHPUnit\Framework\TestCase;
18+
19+
/**
20+
* Regression test for #1960.
21+
*
22+
* When sqlcommenter is installed and the driver is mysql/postgresql, the PDO::query and
23+
* PDO::exec pre-hooks substitute their query back as the executed statement. They must
24+
* inject into the raw query so binary literals survive on the wire, rather than
25+
* UTF-8-converting the executed statement and replacing non-UTF-8 bytes with `?`.
26+
*/
27+
class PdoSqlCommenterBinaryTest extends TestCase
28+
{
29+
private ScopeInterface $scope;
30+
/** @var ArrayObject<int, ImmutableSpan> */
31+
private ArrayObject $storage;
32+
33+
public function setUp(): void
34+
{
35+
$this->storage = new ArrayObject();
36+
$tracerProvider = new TracerProvider(
37+
new SimpleSpanProcessor(
38+
new InMemoryExporter($this->storage)
39+
)
40+
);
41+
42+
$this->scope = Configurator::create()
43+
->withTracerProvider($tracerProvider)
44+
->withPropagator(TraceContextPropagator::getInstance())
45+
->activate();
46+
}
47+
48+
public function tearDown(): void
49+
{
50+
$this->scope->detach();
51+
}
52+
53+
public function test_binary_literal_survives_sqlcommenter_injection_via_exec(): void
54+
{
55+
$pdo = $this->connect();
56+
57+
// sha1("hello world") — 20 bytes containing several high (non-UTF-8) bytes.
58+
$bin = hex2bin('2aae6c35c94fcfb415dbe95f408b9ce91ee846ed');
59+
$this->assertIsString($bin);
60+
61+
$pdo->exec('INSERT INTO otel_binary_1960 (h) VALUES (' . $pdo->quote($bin) . ')');
62+
$stored = $this->fetchStored($pdo);
63+
64+
$pdo->exec('DROP TABLE IF EXISTS otel_binary_1960');
65+
66+
$this->assertSame(bin2hex($bin), bin2hex($stored), 'PDO::exec must not corrupt binary literals when sqlcommenter is installed');
67+
}
68+
69+
public function test_binary_literal_survives_sqlcommenter_injection_via_query(): void
70+
{
71+
$pdo = $this->connect();
72+
73+
// sha1("") — 20 bytes.
74+
$bin = hex2bin('da39a3ee5e6b4b0d3255bfef95601890afd80709');
75+
$this->assertIsString($bin);
76+
77+
$pdo->query('INSERT INTO otel_binary_1960 (h) VALUES (' . $pdo->quote($bin) . ')');
78+
$stored = $this->fetchStored($pdo);
79+
80+
$pdo->exec('DROP TABLE IF EXISTS otel_binary_1960');
81+
82+
$this->assertSame(bin2hex($bin), bin2hex($stored), 'PDO::query must not corrupt binary literals when sqlcommenter is installed');
83+
}
84+
85+
private function connect(): PDO
86+
{
87+
if (!in_array('mysql', PDO::getAvailableDrivers(), true)) {
88+
$this->markTestSkipped('pdo_mysql driver not available');
89+
}
90+
// The corrupting code path is the sqlcommenter branch of the pre-hook, so it must
91+
// actually be reachable for this test to be meaningful.
92+
$this->assertTrue(class_exists(SqlCommenter::class), 'sqlcommenter must be installed to exercise this path');
93+
94+
$host = getenv('MYSQL_HOST') ?: '127.0.0.1';
95+
$pdo = new PDO("mysql:host={$host};dbname=otel_db;charset=utf8mb4", 'otel_user', 'otel_passwd', [
96+
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
97+
]);
98+
$pdo->exec('DROP TABLE IF EXISTS otel_binary_1960');
99+
$pdo->exec('CREATE TABLE otel_binary_1960 (h BINARY(20) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
100+
101+
return $pdo;
102+
}
103+
104+
private function fetchStored(PDO $pdo): string
105+
{
106+
$result = $pdo->query('SELECT h FROM otel_binary_1960');
107+
$this->assertNotFalse($result);
108+
$stored = $result->fetchColumn();
109+
$this->assertIsString($stored);
110+
111+
return $stored;
112+
}
113+
}

0 commit comments

Comments
 (0)