Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Instrumentation/PDO/ignore-by-php-version.neon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

$ignoreErrors = [];

if (version_compare(PHP_VERSION, '8.4', '<')) {
$ignoreErrors = [
'#Call to an undefined static method PDO::connect\(\)#',
'#PHPDoc tag @var for variable \$db contains unknown class PDO\\\\Sqlite#',
'#Call to method createFunction\(\) on an unknown class PDO\\\\Sqlite#',
'#Call to method exec\(\) on an unknown class PDO\\\\Sqlite#',
'#Call to method query\(\) on an unknown class PDO\\\\Sqlite#',
];
} elseif (version_compare(PHP_VERSION, '8.4', '>=')) {
$ignoreErrors = [
'#Call to an undefined method Pdo\\\\Sqlite::createFunction\(\)#',
];
}

return [
'parameters' => [
'ignoreErrors' => $ignoreErrors,
],
];
1 change: 1 addition & 0 deletions src/Instrumentation/PDO/phpstan.neon.dist
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
includes:
- vendor/phpstan/phpstan-phpunit/extension.neon
- ignore-by-php-version.neon.php

parameters:
tmpDir: var/cache/phpstan
Expand Down
59 changes: 50 additions & 9 deletions src/Instrumentation/PDO/src/PDOInstrumentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,59 @@ public static function register(): void
);
$pdoTracker = new PDOTracker();

// Hook for the new PDO::connect static method
if (method_exists(PDO::class, 'connect')) {
hook(
PDO::class,
'connect',
pre: static function (
$object,
array $params,
string $class,
string $function,
?string $filename,
?int $lineno,
) use ($instrumentation) {
/** @psalm-suppress ArgumentTypeCoercion */
$builder = self::makeBuilder($instrumentation, 'PDO::connect', $function, $class, $filename, $lineno)
->setSpanKind(SpanKind::KIND_CLIENT);

$parent = Context::getCurrent();
$span = $builder->startSpan();
Context::storage()->attach($span->storeInContext($parent));
},
post: static function (
$object,
array $params,
$result,
?Throwable $exception,
) use ($pdoTracker) {
$scope = Context::storage()->scope();
if (!$scope) {
return;
}
$span = Span::fromContext($scope->context());

$dsn = $params[0] ?? '';

// guard against PDO::connect returning a string
if ($result instanceof PDO) {
$attributes = $pdoTracker->trackPdoAttributes($result, $dsn);
$span->setAttributes($attributes);
}

self::end($exception);
}
);
}

hook(
PDO::class,
'__construct',
pre: static function (PDO $pdo, array $params, string $class, string $function, ?string $filename, ?int $lineno) use ($instrumentation) {
/** @psalm-suppress ArgumentTypeCoercion */
$builder = self::makeBuilder($instrumentation, 'PDO::__construct', $function, $class, $filename, $lineno)
->setSpanKind(SpanKind::KIND_CLIENT);
if ($class === PDO::class) {
//@todo split params[0] into host + port, replace deprecated trace attribute
$builder
->setAttribute(TraceAttributes::SERVER_ADDRESS, $params[0] ?? 'unknown')
->setAttribute(TraceAttributes::SERVER_PORT, $params[0] ?? null);
}
$parent = Context::getCurrent();
$span = $builder->startSpan();
Context::storage()->attach($span->storeInContext($parent));
Expand Down Expand Up @@ -213,9 +253,10 @@ public static function register(): void
if ($spanContext = $pdoTracker->getSpanForPreparedStatement($statement)) {
$builder->addLink($spanContext);
}
$parent = Context::getCurrent();
$span = $builder->startSpan();

Context::storage()->attach($span->storeInContext(Context::getCurrent()));
Context::storage()->attach($span->storeInContext($parent));
},
post: static function (PDOStatement $statement, array $params, mixed $retval, ?Throwable $exception) {
self::end($exception);
Expand All @@ -240,9 +281,9 @@ public static function register(): void
if ($spanContext = $pdoTracker->getSpanForPreparedStatement($statement)) {
$builder->addLink($spanContext);
}
$parent = Context::getCurrent();
$span = $builder->startSpan();

Context::storage()->attach($span->storeInContext(Context::getCurrent()));
Context::storage()->attach($span->storeInContext($parent));
},
post: static function (PDOStatement $statement, array $params, mixed $retval, ?Throwable $exception) {
self::end($exception);
Expand Down
45 changes: 45 additions & 0 deletions src/Instrumentation/PDO/src/PDOTracker.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,64 @@ private static function extractAttributesFromDSN(string $dsn): array
return $attributes;
}

// SQL Server format handling
if (str_starts_with($dsn, 'sqlsrv:')) {
if (preg_match('/Server=([^,;]+)(?:,([0-9]+))?/', $dsn, $serverMatches)) {
$server = $serverMatches[1];
if ($server !== '') {
$attributes[TraceAttributes::SERVER_ADDRESS] = $server;
}

if (isset($serverMatches[2]) && $serverMatches[2] !== '') {
$attributes[TraceAttributes::SERVER_PORT] = (int) $serverMatches[2];
}
}

if (preg_match('/Database=([^;]*)/', $dsn, $dbMatches)) {
$dbname = $dbMatches[1];
if ($dbname !== '') {
$attributes[TraceAttributes::DB_NAMESPACE] = $dbname;
}
}

return $attributes;
}

//deprecated, no replacement at this time
/*if (preg_match('/user=([^;]*)/', $dsn, $matches)) {
$user = $matches[1];
if ($user !== '') {
$attributes[TraceAttributes::DB_USER] = $user;
}
}*/

// Extract host information
if (preg_match('/host=([^;]*)/', $dsn, $matches)) {
$host = $matches[1];
if ($host !== '') {
$attributes[TraceAttributes::SERVER_ADDRESS] = $host;
}
} elseif (preg_match('/mysql:([^;:]+)/', $dsn, $hostMatches)) {
$host = $hostMatches[1];
if ($host !== '' && $host !== 'dbname') {
$attributes[TraceAttributes::SERVER_ADDRESS] = $host;
}
}

// Extract port information
if (preg_match('/port=([0-9]+)/', $dsn, $portMatches)) {
$port = (int) $portMatches[1];
$attributes[TraceAttributes::SERVER_PORT] = $port;
} elseif (preg_match('/[.0-9]+:([0-9]+)/', $dsn, $portMatches)) {
// This pattern matches IP:PORT format like 127.0.0.1:3308
$port = (int) $portMatches[1];
$attributes[TraceAttributes::SERVER_PORT] = $port;
} elseif (preg_match('/:([0-9]+)/', $dsn, $portMatches)) {
$port = (int) $portMatches[1];
$attributes[TraceAttributes::SERVER_PORT] = $port;
}

// Extract database name
if (preg_match('/dbname=([^;]*)/', $dsn, $matches)) {
$dbname = $matches[1];
if ($dbname !== '') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use ArrayObject;
use OpenTelemetry\API\Instrumentation\Configurator;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\Context\Context;
use OpenTelemetry\Context\ScopeInterface;
use OpenTelemetry\SDK\Trace\ImmutableSpan;
use OpenTelemetry\SDK\Trace\SpanExporter\InMemoryExporter;
Expand All @@ -18,14 +21,24 @@
class PDOInstrumentationTest extends TestCase
{
private ScopeInterface $scope;
/** @var ArrayObject<int, ImmutableSpan> */
/** @var ArrayObject<array-key, mixed> */
private ArrayObject $storage;

private function createDB(): PDO
{
return new PDO('sqlite::memory:');
}

private function createDBWithNewSubclass(): PDO
{
if (!class_exists('Pdo\Sqlite')) {
$this->markTestSkipped('Pdo\Sqlite class is not available in this PHP version');
}

/** @psalm-suppress UndefinedMethod */
return PDO::connect('sqlite::memory:');
}

private function fillDB():string
{
return <<<SQL
Expand Down Expand Up @@ -72,6 +85,51 @@ public function test_pdo_construct(): void
$this->assertEquals('sqlite', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM_NAME));
}

/**
* @psalm-suppress UndefinedClass
* @psalm-suppress InvalidClass
*/
public function test_pdo_sqlite_subclass(): void
{
// skip if php version is less than 8.4
if (version_compare(PHP_VERSION, '8.4', '<')) {
$this->markTestSkipped('Pdo\Sqlite class is not available in this PHP version');
}

$this->assertCount(0, $this->storage);

/**
* Need to suppress because of different casing of the class name
*
* @psalm-suppress UndefinedClass
* @psalm-suppress InvalidClass
* @var Pdo\Sqlite $db
*/
$db = self::createDBWithNewSubclass();
$this->assertCount(1, $this->storage);
$span = $this->storage->offsetGet(0);
$this->assertSame('PDO::connect', $span->getName());
$this->assertEquals('sqlite', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM_NAME));

// Test that the subclass-specific methods work
$db->createFunction('test_function', static fn ($value) => strtoupper($value));

// Test that standard PDO operations still work
$db->exec($this->fillDB());
$span = $this->storage->offsetGet(1);
$this->assertSame('PDO::exec', $span->getName());
$this->assertEquals('sqlite', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM_NAME));
$this->assertCount(2, $this->storage);

// Test that the custom function works
$result = $db->query("SELECT test_function('hello')")->fetchColumn();
$this->assertEquals('HELLO', $result);
$span = $this->storage->offsetGet(2);
$this->assertSame('PDO::query', $span->getName());
$this->assertEquals('sqlite', $span->getAttributes()->get(TraceAttributes::DB_SYSTEM_NAME));
$this->assertCount(3, $this->storage);
}

public function test_constructor_exception(): void
{
$this->expectException(\PDOException::class);
Expand Down Expand Up @@ -214,4 +272,74 @@ public function test_encode_db_statement_as_utf8(): void
$this->assertTrue(mb_check_encoding($span_db_exec->getAttributes()->get(TraceAttributes::DB_QUERY_TEXT), 'UTF-8'));
$this->assertCount(5, $this->storage);
}

public function test_span_hierarchy_with_pdo_operations(): void
{
$this->assertCount(0, $this->storage);

// Create a server span
$tracerProvider = new TracerProvider(
new SimpleSpanProcessor(
new InMemoryExporter($this->storage)
)
);
$tracer = $tracerProvider->getTracer('test');
/** @var SpanInterface $serverSpan */
$serverSpan = $tracer->spanBuilder('HTTP GET /api/users')
->setSpanKind(SpanKind::KIND_SERVER)
->startSpan();

// Create scope for server span
$serverScope = Context::storage()->attach($serverSpan->storeInContext(Context::getCurrent()));

// Create an internal span (simulating business logic)
/** @var SpanInterface $internalSpan */
$internalSpan = $tracer->spanBuilder('processUserData')
->setSpanKind(SpanKind::KIND_INTERNAL)
->startSpan();

// Create scope for internal span
$internalScope = Context::storage()->attach($internalSpan->storeInContext(Context::getCurrent()));

// Perform PDO operations within the internal span context
$db = self::createDB();
$this->assertCount(1, $this->storage); // PDO constructor span

// Create and populate test table
$db->exec($this->fillDB());
$this->assertCount(2, $this->storage); // PDO exec span

// Query data
$stmt = $db->prepare('SELECT * FROM technology WHERE name = ?');
$this->assertCount(3, $this->storage); // PDO prepare span

$stmt->execute(['PHP']);
$this->assertCount(4, $this->storage); // PDOStatement execute span

$result = $stmt->fetchAll();
$this->assertCount(5, $this->storage); // PDOStatement fetchAll span

// Verify span hierarchy
/** @var ImmutableSpan $pdoSpan */
$pdoSpan = $this->storage->offsetGet(0);
/** @var ImmutableSpan $execSpan */
$execSpan = $this->storage->offsetGet(1);
/** @var ImmutableSpan $prepareSpan */
$prepareSpan = $this->storage->offsetGet(2);
/** @var ImmutableSpan $executeSpan */
$executeSpan = $this->storage->offsetGet(3);
/** @var ImmutableSpan $fetchAllSpan */
$fetchAllSpan = $this->storage->offsetGet(4);

// All PDO spans should be children of the internal span
$this->assertEquals($internalSpan->getContext()->getSpanId(), $pdoSpan->getParentSpanId());
$this->assertEquals($internalSpan->getContext()->getSpanId(), $execSpan->getParentSpanId());
$this->assertEquals($internalSpan->getContext()->getSpanId(), $prepareSpan->getParentSpanId());
$this->assertEquals($internalSpan->getContext()->getSpanId(), $executeSpan->getParentSpanId());
$this->assertEquals($internalSpan->getContext()->getSpanId(), $fetchAllSpan->getParentSpanId());

// Detach scopes
$internalScope->detach();
$serverScope->detach();
}
}
Loading