Skip to content

Commit

Permalink
feat: adds Mysqli wrapper class. (#150)
Browse files Browse the repository at this point in the history
* feat: adds Mysqli wrapper class.

* tests: adds integration test.

* chore: ignore lint for Mysqli.

* chore: fix lint.

* chore: fixes lint.

* chore: fix lint.

* chore: fixes static check.

* chore: fix lint.

* chore: fix lint.

* chore: relax typing for being lint compliant with 7.4.

* chore: disables mysqli as requirement.

* chore: explicitly install mysql extension.

* chore: skip test on windows.

* docs: fixes suggestion in composer.json

* chore: readds closer
  • Loading branch information
jcchavezs authored Oct 25, 2021
1 parent 1443eff commit be738e5
Show file tree
Hide file tree
Showing 7 changed files with 357 additions and 0 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ jobs:
with:
php-version: ${{ matrix.php-versions }}
coverage: xdebug #optional
extensions: mysql
- name: Get composer cache directory
id: composer-cache
run: echo "::set-output name=dir::$(composer config cache-files-dir)"
Expand Down
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"psr/log": "^1.0"
},
"require-dev": {
"ext-mysqli": "*",
"jcchavezs/httptest": "~0.2",
"middlewares/fast-route": "^2.0",
"middlewares/request-handler": "^2.0",
Expand Down Expand Up @@ -69,6 +70,7 @@
"static-check": "phpstan analyse src --level 8"
},
"suggest": {
"ext-mysqli": "Allows to use mysqli instrumentation.",
"psr/http-client": "Allows to instrument HTTP clients following PSR18.",
"psr/http-server-middleware": "Allows to instrument HTTP servers via middlewares following PSR15."
},
Expand Down
225 changes: 225 additions & 0 deletions src/Zipkin/Instrumentation/Mysqli/Mysqli.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php

namespace Zipkin\Instrumentation\Mysqli;

use mysqli_result;
use Zipkin\Tracer;
use Zipkin\Span;
use Zipkin\Endpoint;

use InvalidArgumentException;
use const Zipkin\Tags\ERROR;

/**
* Mysqli is an instrumented extension for Mysqli.
* Function signatures come are borrowed from
* https://github.com/php/php-src/blob/master/ext/mysqli/mysqli.stub.php
*/
final class Mysqli extends \Mysqli
{
private const DEFAULT_OPTIONS = [
'tag_query' => false,
'remote_endpoint' => null,
'default_tags' => [],
];

private Tracer $tracer;

private array $options;

public function __construct(
Tracer $tracer,
array $options = [],
string $host = null,
string $user = null,
string $password = null,
string $database = '',
int $port = null,
string $socket = null
) {
self::validateOptions($options);
$this->tracer = $tracer;
$this->options = $options + self::DEFAULT_OPTIONS;
parent::__construct(
$host ?? (ini_get('mysqli.default_host') ?: ''),
$user ?? (ini_get('mysqli.default_user') ?: ''),
$password ?? (ini_get('mysqli.default_pw') ?: ''),
$database,
$port ?? (($defaultPort = ini_get('mysqli.default_port')) ? (int) $defaultPort : 3306),
$socket ?? (ini_get('mysqli.default_socket') ?: '')
);
}

private static function validateOptions(array $opts): void
{
if (array_key_exists('tag_query', $opts) && ($opts['tag_query'] !== (bool) $opts['tag_query'])) {
throw new InvalidArgumentException('Invalid tag_query, bool expected');
}

if (array_key_exists('remote_endpoint', $opts) && !($opts['remote_endpoint'] instanceof Endpoint)) {
throw new InvalidArgumentException(sprintf('Invalid remote_endpoint, %s expected', Endpoint::class));
}

if (array_key_exists('default_tags', $opts) && ($opts['default_tags'] !== (array) $opts['default_tags'])) {
throw new InvalidArgumentException('Invalid default_tags, array expected');
}
}

private function addsTagsAndRemoteEndpoint(Span $span, string $query = null): void
{
if ($query !== null && $this->options['tag_query']) {
$span->tag('sql.query', $query);
}

if ($this->options['remote_endpoint'] !== null) {
$span->setRemoteEndpoint($this->options['remote_endpoint']);
}

foreach ($this->options['default_tags'] as $key => $value) {
$span->tag($key, $value);
}
}

/**
* Performs a query on the database
*
* @return mysqli_result|bool
* @alias mysqli_query
*/
public function query(string $query, int $resultmode = MYSQLI_STORE_RESULT)
{
if ($resultmode === MYSQLI_ASYNC) {
// if $resultmode is async, making the timing on this execution
// does not make much sense. For now we just skip tracing on this.
return parent::query($query, $resultmode);
}

$span = $this->tracer->nextSpan();
$span->setName('sql/query');
$this->addsTagsAndRemoteEndpoint($span, $query);
if ($this->options['tag_query']) {
$span->tag('sql.query', $query);
}

$span->start();
try {
$result = parent::query($query, $resultmode);
if ($result === false) {
$span->tag(ERROR, 'true');
}
return $result;
} finally {
$span->finish();
}
}

/**
* @return bool
* @alias mysqli_real_query
*/
// phpcs:ignore PSR1.Methods.CamelCapsMethodName
public function real_query(string $query)
{
$span = $this->tracer->nextSpan();
$span->setName('sql/query');
$this->addsTagsAndRemoteEndpoint($span, $query);

$span->start();
try {
$result = parent::real_query($query);
if ($result === false) {
$span->tag(ERROR, 'true');
}
return $result;
} finally {
$span->finish();
}
}

/**
* {@inheritdoc}
* @alias mysqli_begin_transaction
*/
// phpcs:ignore PSR1.Methods.CamelCapsMethodName
public function begin_transaction($flags = 0, $name = null)
{
$span = $this->tracer->nextSpan();
$span->setName('sql/begin_transaction');
$this->addsTagsAndRemoteEndpoint($span);
$span->start();
if ($name !== null) {
$span->tag('mysqli.transaction_name', (string) $name);
}
try {
if ($name === null) {
$result = parent::begin_transaction($flags);
} else {
$result = parent::begin_transaction($flags, $name);
}

if ($result === false) {
$span->tag(ERROR, 'true');
}
return $result;
} finally {
$span->finish();
}
}

/**
* @return bool
* @alias mysqli_commit
*/
public function commit(int $flags = -1, ?string $name = null)
{
$span = $this->tracer->nextSpan();
$span->setName('sql/begin_transaction');
$this->addsTagsAndRemoteEndpoint($span);
$span->start();
if ($name !== null) {
$span->tag('mysqli.transaction_name', $name);
}
try {
if ($name === null) {
$result = parent::commit($flags);
} else {
$result = parent::commit($flags, $name);
}

if ($result === false) {
$span->tag(ERROR, 'true');
}
return $result;
} finally {
$span->finish();
}
}

/**
* {@inheritdoc}
* @alias mysqli_rollback
*/
public function rollback($flags = 0, $name = null)
{
$span = $this->tracer->nextSpan();
$span->setName('sql/rollback');
$this->addsTagsAndRemoteEndpoint($span);
$span->start();
if ($name !== null) {
$span->tag('mysqli.transaction_name', (string) $name);
}
try {
if ($name === null) {
$result = parent::commit($flags);
} else {
$result = parent::commit($flags, $name);
}
if ($result === false) {
$span->tag(ERROR, 'true');
}
return $result;
} finally {
$span->finish();
}
}
}
19 changes: 19 additions & 0 deletions src/Zipkin/Instrumentation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Zipkin instrumentation for Mysqli

```php
use Zipkin\Instrumentation\Mysqli\Mysqli;

$mysqli = new Mysqli($tracer, [], "127.0.0.1", "my_user", "my_password", "sakila");

if ($mysqli->connect_errno) {
printf("Connect failed: %s\n", $mysqli->connect_error);
exit();
}

$mysqli->begin_transaction(MYSQLI_TRANS_START_READ_ONLY);

$mysqli->query("SELECT first_name, last_name FROM actor");
$mysqli->commit();

$mysqli->close();
```
88 changes: 88 additions & 0 deletions tests/Integration/Instrumentation/Mysqli/MysqliTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace ZipkinTests\Integration\Instrumentation\Http\Server;

use Zipkin\Tracer;
use Zipkin\Samplers\BinarySampler;
use Zipkin\Reporters\InMemory;
use Zipkin\Propagation\CurrentTraceContext;
use Zipkin\Instrumentation\Mysqli\Mysqli;
use Zipkin\Endpoint;
use Prophecy\PhpUnit\ProphecyTrait;
use PHPUnit\Framework\TestCase;

final class MysqliTest extends TestCase
{
use ProphecyTrait;

private static function launchMySQL(): array
{
shell_exec('docker rm -f zipkin_php_mysql_test');
shell_exec(sprintf('cd %s; docker-compose up -d', __DIR__));
echo "Waiting for mysql container to be up.";
while (true) {
$res = shell_exec('docker ps --filter "name=zipkin_php_mysql_test" --format "{{.Status}}"');
if (strpos($res, "healthy") >= 0) {
break;
}
}

$host = '127.0.0.1';
$user = 'root';
$pass = 'root';
$db = 'test';
$port = 3306;

return [[$host, $user, $pass, $db, $port], function () {
shell_exec(sprintf('cd %s; docker-compose stop', __DIR__));
}];
}

public function testConnect()
{
if (PHP_OS_FAMILY === 'Windows') {
$this->markTestSkipped("Running the test on windows might be problematic");
}

if (!extension_loaded("mysqli")) {
$this->markTestSkipped("mysqli isn't loaded");
}

list($params, $closer) = self::launchMySQL();

$reporter = new InMemory();

$tracer = new Tracer(
Endpoint::createAsEmpty(),
$reporter,
BinarySampler::createAsAlwaysSample(),
false, // usesTraceId128bits
new CurrentTraceContext(),
false // isNoop
);

try {
$mysqli = new Mysqli($tracer, [], ...$params);

if ($mysqli->connect_errno) {
$this->fail(
sprintf('Failed to connect to MySQL: %s %s', $mysqli->connect_errno, $mysqli->connect_error)
);
}

$res = $mysqli->query('SELECT 1');
$this->assertEquals(1, $res->num_rows);

$tracer->flush();
$spans = $reporter->flush();
$this->assertEquals(1, count($spans));

$span = $spans[0];
$this->assertEquals('sql/query', $span->getName());
} finally {
$closer();
}
}
}
4 changes: 4 additions & 0 deletions tests/Integration/Instrumentation/Mysqli/access.cnf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

[mysqld]
# makes it possible to connect to the server using `mysql` as host
bind-address = 0.0.0.0
18 changes: 18 additions & 0 deletions tests/Integration/Instrumentation/Mysqli/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
version: "2.4"

services:
mysql:
image: mysql:latest
container_name: zipkin_php_mysql_test
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=test
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
volumes:
- ./access.cnf:/etc/mysql/conf.d/access.cnf
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10

0 comments on commit be738e5

Please sign in to comment.