Skip to content

Commit be738e5

Browse files
authored
feat: adds Mysqli wrapper class. (#150)
* 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
1 parent 1443eff commit be738e5

File tree

7 files changed

+357
-0
lines changed

7 files changed

+357
-0
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ jobs:
2424
with:
2525
php-version: ${{ matrix.php-versions }}
2626
coverage: xdebug #optional
27+
extensions: mysql
2728
- name: Get composer cache directory
2829
id: composer-cache
2930
run: echo "::set-output name=dir::$(composer config cache-files-dir)"

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"psr/log": "^1.0"
2727
},
2828
"require-dev": {
29+
"ext-mysqli": "*",
2930
"jcchavezs/httptest": "~0.2",
3031
"middlewares/fast-route": "^2.0",
3132
"middlewares/request-handler": "^2.0",
@@ -69,6 +70,7 @@
6970
"static-check": "phpstan analyse src --level 8"
7071
},
7172
"suggest": {
73+
"ext-mysqli": "Allows to use mysqli instrumentation.",
7274
"psr/http-client": "Allows to instrument HTTP clients following PSR18.",
7375
"psr/http-server-middleware": "Allows to instrument HTTP servers via middlewares following PSR15."
7476
},
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<?php
2+
3+
namespace Zipkin\Instrumentation\Mysqli;
4+
5+
use mysqli_result;
6+
use Zipkin\Tracer;
7+
use Zipkin\Span;
8+
use Zipkin\Endpoint;
9+
10+
use InvalidArgumentException;
11+
use const Zipkin\Tags\ERROR;
12+
13+
/**
14+
* Mysqli is an instrumented extension for Mysqli.
15+
* Function signatures come are borrowed from
16+
* https://github.com/php/php-src/blob/master/ext/mysqli/mysqli.stub.php
17+
*/
18+
final class Mysqli extends \Mysqli
19+
{
20+
private const DEFAULT_OPTIONS = [
21+
'tag_query' => false,
22+
'remote_endpoint' => null,
23+
'default_tags' => [],
24+
];
25+
26+
private Tracer $tracer;
27+
28+
private array $options;
29+
30+
public function __construct(
31+
Tracer $tracer,
32+
array $options = [],
33+
string $host = null,
34+
string $user = null,
35+
string $password = null,
36+
string $database = '',
37+
int $port = null,
38+
string $socket = null
39+
) {
40+
self::validateOptions($options);
41+
$this->tracer = $tracer;
42+
$this->options = $options + self::DEFAULT_OPTIONS;
43+
parent::__construct(
44+
$host ?? (ini_get('mysqli.default_host') ?: ''),
45+
$user ?? (ini_get('mysqli.default_user') ?: ''),
46+
$password ?? (ini_get('mysqli.default_pw') ?: ''),
47+
$database,
48+
$port ?? (($defaultPort = ini_get('mysqli.default_port')) ? (int) $defaultPort : 3306),
49+
$socket ?? (ini_get('mysqli.default_socket') ?: '')
50+
);
51+
}
52+
53+
private static function validateOptions(array $opts): void
54+
{
55+
if (array_key_exists('tag_query', $opts) && ($opts['tag_query'] !== (bool) $opts['tag_query'])) {
56+
throw new InvalidArgumentException('Invalid tag_query, bool expected');
57+
}
58+
59+
if (array_key_exists('remote_endpoint', $opts) && !($opts['remote_endpoint'] instanceof Endpoint)) {
60+
throw new InvalidArgumentException(sprintf('Invalid remote_endpoint, %s expected', Endpoint::class));
61+
}
62+
63+
if (array_key_exists('default_tags', $opts) && ($opts['default_tags'] !== (array) $opts['default_tags'])) {
64+
throw new InvalidArgumentException('Invalid default_tags, array expected');
65+
}
66+
}
67+
68+
private function addsTagsAndRemoteEndpoint(Span $span, string $query = null): void
69+
{
70+
if ($query !== null && $this->options['tag_query']) {
71+
$span->tag('sql.query', $query);
72+
}
73+
74+
if ($this->options['remote_endpoint'] !== null) {
75+
$span->setRemoteEndpoint($this->options['remote_endpoint']);
76+
}
77+
78+
foreach ($this->options['default_tags'] as $key => $value) {
79+
$span->tag($key, $value);
80+
}
81+
}
82+
83+
/**
84+
* Performs a query on the database
85+
*
86+
* @return mysqli_result|bool
87+
* @alias mysqli_query
88+
*/
89+
public function query(string $query, int $resultmode = MYSQLI_STORE_RESULT)
90+
{
91+
if ($resultmode === MYSQLI_ASYNC) {
92+
// if $resultmode is async, making the timing on this execution
93+
// does not make much sense. For now we just skip tracing on this.
94+
return parent::query($query, $resultmode);
95+
}
96+
97+
$span = $this->tracer->nextSpan();
98+
$span->setName('sql/query');
99+
$this->addsTagsAndRemoteEndpoint($span, $query);
100+
if ($this->options['tag_query']) {
101+
$span->tag('sql.query', $query);
102+
}
103+
104+
$span->start();
105+
try {
106+
$result = parent::query($query, $resultmode);
107+
if ($result === false) {
108+
$span->tag(ERROR, 'true');
109+
}
110+
return $result;
111+
} finally {
112+
$span->finish();
113+
}
114+
}
115+
116+
/**
117+
* @return bool
118+
* @alias mysqli_real_query
119+
*/
120+
// phpcs:ignore PSR1.Methods.CamelCapsMethodName
121+
public function real_query(string $query)
122+
{
123+
$span = $this->tracer->nextSpan();
124+
$span->setName('sql/query');
125+
$this->addsTagsAndRemoteEndpoint($span, $query);
126+
127+
$span->start();
128+
try {
129+
$result = parent::real_query($query);
130+
if ($result === false) {
131+
$span->tag(ERROR, 'true');
132+
}
133+
return $result;
134+
} finally {
135+
$span->finish();
136+
}
137+
}
138+
139+
/**
140+
* {@inheritdoc}
141+
* @alias mysqli_begin_transaction
142+
*/
143+
// phpcs:ignore PSR1.Methods.CamelCapsMethodName
144+
public function begin_transaction($flags = 0, $name = null)
145+
{
146+
$span = $this->tracer->nextSpan();
147+
$span->setName('sql/begin_transaction');
148+
$this->addsTagsAndRemoteEndpoint($span);
149+
$span->start();
150+
if ($name !== null) {
151+
$span->tag('mysqli.transaction_name', (string) $name);
152+
}
153+
try {
154+
if ($name === null) {
155+
$result = parent::begin_transaction($flags);
156+
} else {
157+
$result = parent::begin_transaction($flags, $name);
158+
}
159+
160+
if ($result === false) {
161+
$span->tag(ERROR, 'true');
162+
}
163+
return $result;
164+
} finally {
165+
$span->finish();
166+
}
167+
}
168+
169+
/**
170+
* @return bool
171+
* @alias mysqli_commit
172+
*/
173+
public function commit(int $flags = -1, ?string $name = null)
174+
{
175+
$span = $this->tracer->nextSpan();
176+
$span->setName('sql/begin_transaction');
177+
$this->addsTagsAndRemoteEndpoint($span);
178+
$span->start();
179+
if ($name !== null) {
180+
$span->tag('mysqli.transaction_name', $name);
181+
}
182+
try {
183+
if ($name === null) {
184+
$result = parent::commit($flags);
185+
} else {
186+
$result = parent::commit($flags, $name);
187+
}
188+
189+
if ($result === false) {
190+
$span->tag(ERROR, 'true');
191+
}
192+
return $result;
193+
} finally {
194+
$span->finish();
195+
}
196+
}
197+
198+
/**
199+
* {@inheritdoc}
200+
* @alias mysqli_rollback
201+
*/
202+
public function rollback($flags = 0, $name = null)
203+
{
204+
$span = $this->tracer->nextSpan();
205+
$span->setName('sql/rollback');
206+
$this->addsTagsAndRemoteEndpoint($span);
207+
$span->start();
208+
if ($name !== null) {
209+
$span->tag('mysqli.transaction_name', (string) $name);
210+
}
211+
try {
212+
if ($name === null) {
213+
$result = parent::commit($flags);
214+
} else {
215+
$result = parent::commit($flags, $name);
216+
}
217+
if ($result === false) {
218+
$span->tag(ERROR, 'true');
219+
}
220+
return $result;
221+
} finally {
222+
$span->finish();
223+
}
224+
}
225+
}

src/Zipkin/Instrumentation/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Zipkin instrumentation for Mysqli
2+
3+
```php
4+
use Zipkin\Instrumentation\Mysqli\Mysqli;
5+
6+
$mysqli = new Mysqli($tracer, [], "127.0.0.1", "my_user", "my_password", "sakila");
7+
8+
if ($mysqli->connect_errno) {
9+
printf("Connect failed: %s\n", $mysqli->connect_error);
10+
exit();
11+
}
12+
13+
$mysqli->begin_transaction(MYSQLI_TRANS_START_READ_ONLY);
14+
15+
$mysqli->query("SELECT first_name, last_name FROM actor");
16+
$mysqli->commit();
17+
18+
$mysqli->close();
19+
```
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ZipkinTests\Integration\Instrumentation\Http\Server;
6+
7+
use Zipkin\Tracer;
8+
use Zipkin\Samplers\BinarySampler;
9+
use Zipkin\Reporters\InMemory;
10+
use Zipkin\Propagation\CurrentTraceContext;
11+
use Zipkin\Instrumentation\Mysqli\Mysqli;
12+
use Zipkin\Endpoint;
13+
use Prophecy\PhpUnit\ProphecyTrait;
14+
use PHPUnit\Framework\TestCase;
15+
16+
final class MysqliTest extends TestCase
17+
{
18+
use ProphecyTrait;
19+
20+
private static function launchMySQL(): array
21+
{
22+
shell_exec('docker rm -f zipkin_php_mysql_test');
23+
shell_exec(sprintf('cd %s; docker-compose up -d', __DIR__));
24+
echo "Waiting for mysql container to be up.";
25+
while (true) {
26+
$res = shell_exec('docker ps --filter "name=zipkin_php_mysql_test" --format "{{.Status}}"');
27+
if (strpos($res, "healthy") >= 0) {
28+
break;
29+
}
30+
}
31+
32+
$host = '127.0.0.1';
33+
$user = 'root';
34+
$pass = 'root';
35+
$db = 'test';
36+
$port = 3306;
37+
38+
return [[$host, $user, $pass, $db, $port], function () {
39+
shell_exec(sprintf('cd %s; docker-compose stop', __DIR__));
40+
}];
41+
}
42+
43+
public function testConnect()
44+
{
45+
if (PHP_OS_FAMILY === 'Windows') {
46+
$this->markTestSkipped("Running the test on windows might be problematic");
47+
}
48+
49+
if (!extension_loaded("mysqli")) {
50+
$this->markTestSkipped("mysqli isn't loaded");
51+
}
52+
53+
list($params, $closer) = self::launchMySQL();
54+
55+
$reporter = new InMemory();
56+
57+
$tracer = new Tracer(
58+
Endpoint::createAsEmpty(),
59+
$reporter,
60+
BinarySampler::createAsAlwaysSample(),
61+
false, // usesTraceId128bits
62+
new CurrentTraceContext(),
63+
false // isNoop
64+
);
65+
66+
try {
67+
$mysqli = new Mysqli($tracer, [], ...$params);
68+
69+
if ($mysqli->connect_errno) {
70+
$this->fail(
71+
sprintf('Failed to connect to MySQL: %s %s', $mysqli->connect_errno, $mysqli->connect_error)
72+
);
73+
}
74+
75+
$res = $mysqli->query('SELECT 1');
76+
$this->assertEquals(1, $res->num_rows);
77+
78+
$tracer->flush();
79+
$spans = $reporter->flush();
80+
$this->assertEquals(1, count($spans));
81+
82+
$span = $spans[0];
83+
$this->assertEquals('sql/query', $span->getName());
84+
} finally {
85+
$closer();
86+
}
87+
}
88+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
[mysqld]
3+
# makes it possible to connect to the server using `mysql` as host
4+
bind-address = 0.0.0.0
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
version: "2.4"
2+
3+
services:
4+
mysql:
5+
image: mysql:latest
6+
container_name: zipkin_php_mysql_test
7+
environment:
8+
- MYSQL_ROOT_PASSWORD=root
9+
- MYSQL_DATABASE=test
10+
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
11+
volumes:
12+
- ./access.cnf:/etc/mysql/conf.d/access.cnf
13+
ports:
14+
- "3306:3306"
15+
healthcheck:
16+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
17+
timeout: 20s
18+
retries: 10

0 commit comments

Comments
 (0)