Skip to content

Commit af537c2

Browse files
authored
Add MongoDB profiler (#4)
1 parent 74668e0 commit af537c2

File tree

15 files changed

+838
-16
lines changed

15 files changed

+838
-16
lines changed

composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
"symfony/filesystem": "^6.3 || ^7.0",
2828
"symfony/framework-bundle": "^6.3.5 || ^7.0",
2929
"symfony/phpunit-bridge": "~6.3.10 || ^6.4.1 || ^7.0.1",
30+
"symfony/stopwatch": "^6.3 || ^7.0",
3031
"symfony/yaml": "^6.3 || ^7.0",
32+
"symfony/web-profiler-bundle": "^6.3 || ^7.0",
3133
"zenstruck/browser": "^1.6"
3234
},
3335
"scripts": {

config/services.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
2222

2323
use MongoDB\Bundle\Command\DebugCommand;
24+
use MongoDB\Bundle\DataCollector\MongoDBDataCollector;
2425
use MongoDB\Client;
2526

2627
return static function (ContainerConfigurator $container): void {
@@ -38,9 +39,19 @@
3839
->tag('console.command');
3940

4041
$services
41-
->set('mongodb.prototype.client', Client::class)
42+
->set('mongodb.abstract.client', Client::class)
4243
->arg('$uri', abstract_arg('Should be defined by pass'))
4344
->arg('$uriOptions', abstract_arg('Should be defined by pass'))
4445
->arg('$driverOptions', abstract_arg('Should be defined by pass'))
45-
->tag('mongodb.client');
46+
->abstract();
47+
48+
$services
49+
->set('mongodb.data_collector', MongoDBDataCollector::class)
50+
->arg('$stopwatch', service('debug.stopwatch')->nullOnInvalid())
51+
->arg('$clients', tagged_iterator('mongodb.client', 'name'))
52+
->tag('data_collector', [
53+
'template' => '@MongoDB/Collector/mongodb.html.twig',
54+
'id' => 'mongodb',
55+
'priority' => 250,
56+
]);
4657
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MongoDB\Bundle\DataCollector;
6+
7+
/** @internal */
8+
interface CommandEventCollector
9+
{
10+
public function collectCommandEvent(int $clientId, string $requestId, array $data): void;
11+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright 2023-present MongoDB, Inc.
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* https://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
21+
namespace MongoDB\Bundle\DataCollector;
22+
23+
use MongoDB\Driver\Monitoring\CommandFailedEvent;
24+
use MongoDB\Driver\Monitoring\CommandStartedEvent;
25+
use MongoDB\Driver\Monitoring\CommandSubscriber;
26+
use MongoDB\Driver\Monitoring\CommandSucceededEvent;
27+
use Symfony\Component\Stopwatch\Stopwatch;
28+
use Symfony\Component\Stopwatch\StopwatchEvent;
29+
30+
use function array_shift;
31+
use function debug_backtrace;
32+
33+
use const DEBUG_BACKTRACE_IGNORE_ARGS;
34+
35+
/** @internal */
36+
final class DriverEventSubscriber implements CommandSubscriber
37+
{
38+
/** @var array<string, StopwatchEvent> */
39+
private array $stopwatchEvents = [];
40+
41+
public function __construct(
42+
private readonly int $clientId,
43+
private readonly CommandEventCollector $collector,
44+
private readonly ?Stopwatch $stopwatch = null,
45+
) {
46+
}
47+
48+
public function commandStarted(CommandStartedEvent $event): void
49+
{
50+
$requestId = $event->getRequestId();
51+
52+
$command = (array) $event->getCommand();
53+
unset($command['lsid'], $command['$clusterTime']);
54+
55+
$data = [
56+
'databaseName' => $event->getDatabaseName(),
57+
'commandName' => $event->getCommandName(),
58+
'command' => $command,
59+
'operationId' => $event->getOperationId(),
60+
'serviceId' => $event->getServiceId(),
61+
'backtrace' => $this->filterBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)),
62+
];
63+
64+
if ($event->getCommandName() === 'getMore') {
65+
$data['cursorId'] = $event->getCommand()->getMore;
66+
}
67+
68+
$this->collector->collectCommandEvent($this->clientId, $requestId, $data);
69+
70+
$this->stopwatchEvents[$requestId] = $this->stopwatch?->start(
71+
'mongodb.' . $event->getCommandName(),
72+
'mongodb',
73+
);
74+
}
75+
76+
public function commandSucceeded(CommandSucceededEvent $event): void
77+
{
78+
$requestId = $event->getRequestId();
79+
80+
$this->stopwatchEvents[$requestId]?->stop();
81+
unset($this->stopwatchEvents[$requestId]);
82+
83+
$data = [
84+
'durationMicros' => $event->getDurationMicros(),
85+
];
86+
87+
if (isset($event->getReply()->cursor)) {
88+
$data['cursorId'] = $event->getReply()->cursor->id;
89+
}
90+
91+
$this->collector->collectCommandEvent($this->clientId, $requestId, $data);
92+
}
93+
94+
public function commandFailed(CommandFailedEvent $event): void
95+
{
96+
$requestId = $event->getRequestId();
97+
98+
$this->stopwatchEvents[$requestId]?->stop();
99+
unset($this->stopwatchEvents[$requestId]);
100+
101+
$data = [
102+
'durationMicros' => $event->getDurationMicros(),
103+
'error' => (string) $event->getError(),
104+
];
105+
106+
$this->collector->collectCommandEvent($this->clientId, $requestId, $data);
107+
}
108+
109+
private function filterBacktrace(array $backtrace): array
110+
{
111+
// skip first since it's always the current method
112+
array_shift($backtrace);
113+
114+
return $backtrace;
115+
}
116+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright 2023-present MongoDB, Inc.
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* https://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
21+
namespace MongoDB\Bundle\DataCollector;
22+
23+
use LogicException;
24+
use MongoDB\Client;
25+
use MongoDB\Driver\Command;
26+
use Symfony\Component\HttpFoundation\Request;
27+
use Symfony\Component\HttpFoundation\Response;
28+
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
29+
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
30+
use Symfony\Component\Stopwatch\Stopwatch;
31+
use Throwable;
32+
33+
use function array_diff_key;
34+
use function spl_object_id;
35+
36+
/** @internal */
37+
final class MongoDBDataCollector extends DataCollector implements LateDataCollectorInterface, CommandEventCollector
38+
{
39+
/**
40+
* The list of request by client ID is built with driver event data.
41+
*
42+
* @var array<string, array<string, array{clientName:string,databaseName:string,commandName:string,command:array,operationId:int,serviceId:int,durationMicros?:int,error?:string}>>
43+
*/
44+
private array $requests = [];
45+
46+
public function __construct(
47+
private readonly ?Stopwatch $stopwatch = null,
48+
/** @var iterable<string, Client> */
49+
private readonly iterable $clients = [],
50+
) {
51+
}
52+
53+
public function configureClient(Client $client): void
54+
{
55+
$client->getManager()->addSubscriber(new DriverEventSubscriber(spl_object_id($client), $this, $this->stopwatch));
56+
}
57+
58+
public function collectCommandEvent(int $clientId, string $requestId, array $data): void
59+
{
60+
if (isset($this->requests[$clientId][$requestId])) {
61+
$this->requests[$clientId][$requestId] += $data;
62+
} else {
63+
$this->requests[$clientId][$requestId] = $data;
64+
}
65+
}
66+
67+
public function collect(Request $request, Response $response, ?Throwable $exception = null): void
68+
{
69+
}
70+
71+
public function lateCollect(): void
72+
{
73+
$requestCount = 0;
74+
$errorCount = 0;
75+
$durationMicros = 0;
76+
77+
$clients = [];
78+
$clientIdMap = [];
79+
foreach ($this->clients as $name => $client) {
80+
$clientIdMap[spl_object_id($client)] = $name;
81+
$clients[$name] = [
82+
'serverBuildInfo' => array_diff_key(
83+
(array) $client->getManager()->executeCommand('admin', new Command(['buildInfo' => 1]))->toArray()[0],
84+
['versionArray' => 0, 'ok' => 0],
85+
),
86+
'clientInfo' => array_diff_key($client->__debugInfo(), ['manager' => 0]),
87+
];
88+
}
89+
90+
$requests = [];
91+
foreach ($this->requests as $clientId => $requestsByClientId) {
92+
$clientName = $clientIdMap[$clientId] ?? throw new LogicException('Client not found');
93+
foreach ($requestsByClientId as $requestId => $request) {
94+
$requests[$clientName][$requestId] = $request;
95+
$requestCount++;
96+
$durationMicros += $request['durationMicros'] ?? 0;
97+
$errorCount += isset($request['error']) ? 1 : 0;
98+
}
99+
}
100+
101+
$this->data = [
102+
'clients' => $clients,
103+
'requests' => $requests,
104+
'requestCount' => $requestCount,
105+
'errorCount' => $errorCount,
106+
'durationMicros' => $durationMicros,
107+
];
108+
}
109+
110+
public function getRequestCount(): int
111+
{
112+
return $this->data['requestCount'];
113+
}
114+
115+
public function getErrorCount(): int
116+
{
117+
return $this->data['errorCount'];
118+
}
119+
120+
public function getTime(): int
121+
{
122+
return $this->data['durationMicros'];
123+
}
124+
125+
public function getRequests(): array
126+
{
127+
return $this->data['requests'];
128+
}
129+
130+
public function getClients(): array
131+
{
132+
return $this->data['clients'];
133+
}
134+
135+
public function getName(): string
136+
{
137+
return 'mongodb';
138+
}
139+
140+
public function reset(): void
141+
{
142+
$this->requests = [];
143+
$this->data = [];
144+
}
145+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright 2023-present MongoDB, Inc.
7+
*
8+
* Licensed under the Apache License, Version 2.0 (the "License");
9+
* you may not use this file except in compliance with the License.
10+
* You may obtain a copy of the License at
11+
*
12+
* https://www.apache.org/licenses/LICENSE-2.0
13+
*
14+
* Unless required by applicable law or agreed to in writing, software
15+
* distributed under the License is distributed on an "AS IS" BASIS,
16+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
* See the License for the specific language governing permissions and
18+
* limitations under the License.
19+
*/
20+
21+
namespace MongoDB\Bundle\DependencyInjection\Compiler;
22+
23+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
24+
use Symfony\Component\DependencyInjection\ContainerBuilder;
25+
use Symfony\Component\DependencyInjection\Reference;
26+
27+
/** @internal */
28+
final class DataCollectorPass implements CompilerPassInterface
29+
{
30+
public function process(ContainerBuilder $container): void
31+
{
32+
if (! $container->has('profiler')) {
33+
return;
34+
}
35+
36+
/**
37+
* Add a subscriber to each client to collect driver events.
38+
*
39+
* @see \MongoDB\Bundle\DataCollector\MongoDBDataCollector::configureClient()
40+
*/
41+
foreach ($container->findTaggedServiceIds('mongodb.client', true) as $clientId => $attributes) {
42+
$container->getDefinition($clientId)->setConfigurator([new Reference('mongodb.data_collector'), 'configureClient']);
43+
}
44+
}
45+
}

0 commit comments

Comments
 (0)