Skip to content
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