Skip to content

Add MongoDB profiler #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Dec 9, 2023
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"symfony/filesystem": "^6.3 || ^7.0",
"symfony/framework-bundle": "^6.3.5 || ^7.0",
"symfony/phpunit-bridge": "~6.3.10 || ^6.4.1 || ^7.0.1",
"symfony/stopwatch": "^6.3 || ^7.0",
"symfony/yaml": "^6.3 || ^7.0",
"symfony/web-profiler-bundle": "^6.3 || ^7.0",
"zenstruck/browser": "^1.6"
},
"scripts": {
Expand Down
15 changes: 13 additions & 2 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

use MongoDB\Bundle\Command\DebugCommand;
use MongoDB\Bundle\DataCollector\MongoDBDataCollector;
use MongoDB\Client;

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

$services
->set('mongodb.prototype.client', Client::class)
->set('mongodb.abstract.client', Client::class)
->arg('$uri', abstract_arg('Should be defined by pass'))
->arg('$uriOptions', abstract_arg('Should be defined by pass'))
->arg('$driverOptions', abstract_arg('Should be defined by pass'))
->tag('mongodb.client');
->abstract();

$services
->set('mongodb.data_collector', MongoDBDataCollector::class)
->arg('$stopwatch', service('debug.stopwatch')->nullOnInvalid())
->arg('$clients', tagged_iterator('mongodb.client', 'name'))
->tag('data_collector', [
'template' => '@MongoDB/Collector/mongodb.html.twig',
'id' => 'mongodb',
'priority' => 250,
]);
};
11 changes: 11 additions & 0 deletions src/DataCollector/CommandEventCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace MongoDB\Bundle\DataCollector;

/** @internal */
interface CommandEventCollector
{
public function collectCommandEvent(int $clientId, string $requestId, array $data): void;
}
116 changes: 116 additions & 0 deletions src/DataCollector/DriverEventSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

declare(strict_types=1);

/**
* Copyright 2023-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace MongoDB\Bundle\DataCollector;

use MongoDB\Driver\Monitoring\CommandFailedEvent;
use MongoDB\Driver\Monitoring\CommandStartedEvent;
use MongoDB\Driver\Monitoring\CommandSubscriber;
use MongoDB\Driver\Monitoring\CommandSucceededEvent;
use Symfony\Component\Stopwatch\Stopwatch;
use Symfony\Component\Stopwatch\StopwatchEvent;

use function array_shift;
use function debug_backtrace;

use const DEBUG_BACKTRACE_IGNORE_ARGS;

/** @internal */
final class DriverEventSubscriber implements CommandSubscriber
{
/** @var array<string, StopwatchEvent> */
private array $stopwatchEvents = [];

public function __construct(
private readonly int $clientId,
private readonly CommandEventCollector $collector,
private readonly ?Stopwatch $stopwatch = null,
) {
}

public function commandStarted(CommandStartedEvent $event): void
{
$requestId = $event->getRequestId();

$command = (array) $event->getCommand();
unset($command['lsid'], $command['$clusterTime']);

$data = [
'databaseName' => $event->getDatabaseName(),
'commandName' => $event->getCommandName(),
'command' => $command,
'operationId' => $event->getOperationId(),
'serviceId' => $event->getServiceId(),
'backtrace' => $this->filterBacktrace(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS)),
];

if ($event->getCommandName() === 'getMore') {
$data['cursorId'] = $event->getCommand()->getMore;
}

$this->collector->collectCommandEvent($this->clientId, $requestId, $data);

$this->stopwatchEvents[$requestId] = $this->stopwatch?->start(
'mongodb.' . $event->getCommandName(),
'mongodb',
);
}

public function commandSucceeded(CommandSucceededEvent $event): void
{
$requestId = $event->getRequestId();

$this->stopwatchEvents[$requestId]?->stop();
unset($this->stopwatchEvents[$requestId]);

$data = [
'durationMicros' => $event->getDurationMicros(),
];

if (isset($event->getReply()->cursor)) {
$data['cursorId'] = $event->getReply()->cursor->id;
}

$this->collector->collectCommandEvent($this->clientId, $requestId, $data);
}

public function commandFailed(CommandFailedEvent $event): void
{
$requestId = $event->getRequestId();

$this->stopwatchEvents[$requestId]?->stop();
unset($this->stopwatchEvents[$requestId]);

$data = [
'durationMicros' => $event->getDurationMicros(),
'error' => (string) $event->getError(),
];

$this->collector->collectCommandEvent($this->clientId, $requestId, $data);
}

private function filterBacktrace(array $backtrace): array
{
// skip first since it's always the current method
array_shift($backtrace);

return $backtrace;
}
}
145 changes: 145 additions & 0 deletions src/DataCollector/MongoDBDataCollector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php

declare(strict_types=1);

/**
* Copyright 2023-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace MongoDB\Bundle\DataCollector;

use LogicException;
use MongoDB\Client;
use MongoDB\Driver\Command;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\Stopwatch\Stopwatch;
use Throwable;

use function array_diff_key;
use function spl_object_id;

/** @internal */
final class MongoDBDataCollector extends DataCollector implements LateDataCollectorInterface, CommandEventCollector
{
/**
* The list of request by client ID is built with driver event data.
*
* @var array<string, array<string, array{clientName:string,databaseName:string,commandName:string,command:array,operationId:int,serviceId:int,durationMicros?:int,error?:string}>>
*/
private array $requests = [];

public function __construct(
private readonly ?Stopwatch $stopwatch = null,
/** @var iterable<string, Client> */
private readonly iterable $clients = [],
) {
}

public function configureClient(Client $client): void
{
$client->getManager()->addSubscriber(new DriverEventSubscriber(spl_object_id($client), $this, $this->stopwatch));
}

public function collectCommandEvent(int $clientId, string $requestId, array $data): void
{
if (isset($this->requests[$clientId][$requestId])) {
$this->requests[$clientId][$requestId] += $data;
} else {
$this->requests[$clientId][$requestId] = $data;
}
}

public function collect(Request $request, Response $response, ?Throwable $exception = null): void
{
}

public function lateCollect(): void
{
$requestCount = 0;
$errorCount = 0;
$durationMicros = 0;

$clients = [];
$clientIdMap = [];
foreach ($this->clients as $name => $client) {
$clientIdMap[spl_object_id($client)] = $name;
$clients[$name] = [
'serverBuildInfo' => array_diff_key(
(array) $client->getManager()->executeCommand('admin', new Command(['buildInfo' => 1]))->toArray()[0],
['versionArray' => 0, 'ok' => 0],
),
'clientInfo' => array_diff_key($client->__debugInfo(), ['manager' => 0]),
];
}

$requests = [];
foreach ($this->requests as $clientId => $requestsByClientId) {
$clientName = $clientIdMap[$clientId] ?? throw new LogicException('Client not found');
foreach ($requestsByClientId as $requestId => $request) {
$requests[$clientName][$requestId] = $request;
$requestCount++;
$durationMicros += $request['durationMicros'] ?? 0;
$errorCount += isset($request['error']) ? 1 : 0;
}
}

$this->data = [
'clients' => $clients,
'requests' => $requests,
'requestCount' => $requestCount,
'errorCount' => $errorCount,
'durationMicros' => $durationMicros,
];
}

public function getRequestCount(): int
{
return $this->data['requestCount'];
}

public function getErrorCount(): int
{
return $this->data['errorCount'];
}

public function getTime(): int
{
return $this->data['durationMicros'];
}

public function getRequests(): array
{
return $this->data['requests'];
}

public function getClients(): array
{
return $this->data['clients'];
}

public function getName(): string
{
return 'mongodb';
}

public function reset(): void
{
$this->requests = [];
$this->data = [];
}
}
45 changes: 45 additions & 0 deletions src/DependencyInjection/Compiler/DataCollectorPass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

/**
* Copyright 2023-present MongoDB, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace MongoDB\Bundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/** @internal */
final class DataCollectorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (! $container->has('profiler')) {
return;
}

/**
* Add a subscriber to each client to collect driver events.
*
* @see \MongoDB\Bundle\DataCollector\MongoDBDataCollector::configureClient()
*/
foreach ($container->findTaggedServiceIds('mongodb.client', true) as $clientId => $attributes) {
$container->getDefinition($clientId)->setConfigurator([new Reference('mongodb.data_collector'), 'configureClient']);
}
}
}
Loading