feature #27202 [Messenger] Improve the profiler panel (ogizanagi)

This PR was squashed before being merged into the 4.1 branch (closes #27202).

Discussion
----------

[Messenger] Improve the profiler panel

| Q             | A
| ------------- | ---
| Branch?       | 4.1 <!-- see below -->
| Bug fix?      | no
| New feature?  | yes <!-- don't forget to update src/**/CHANGELOG.md files -->
| BC breaks?    | no     <!-- see https://symfony.com/bc -->
| Deprecations? | no <!-- don't forget to update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tests pass?   | yes <!-- please add some, will be required by reviewers -->
| Fixed tickets | N/A   <!-- #26597  issue number(s), if any -->
| License       | MIT
| Doc PR        | N/A

This is an attempt to enhance the profiler panel a bit.

**with the following messages dispatched:**

```php
$queryBus->dispatch(Envelope::wrap(new GetGreetingsQuery('Hello you'))
    ->with(new JustAFriendOfMine())
    ->with(new AndHisPlusO̶n̶e̶Eleven())
);
$commandBus->dispatch(new SendGiftCommand());
$queryBus->dispatch(new GetGreetingsQuery('Exterminate!'));
```

## Before

<img width="1084" alt="screenshot 2018-05-12 a 13 57 57" src="https://user-images.githubusercontent.com/2211145/39957055-8a0f009e-55ec-11e8-9d8e-bf79aad4b420.PNG">

🐛calls order are wrong here, fixed in this PR

## After

### collapsed

<!-- <img width="1083" alt="screenshot 2018-05-10 a 23 51 07" src="https://user-images.githubusercontent.com/2211145/39896093-19a8c7ee-54ad-11e8-8dcb-4e165ffd2eae.PNG">-->

<img width="1085" alt="screenshot 2018-05-12 a 13 18 35" src="https://user-images.githubusercontent.com/2211145/39956802-9d4c38a2-55e7-11e8-8425-ad090c0871b6.PNG">
<img width="1085" alt="screenshot 2018-05-12 a 13 26 44" src="https://user-images.githubusercontent.com/2211145/39956827-25d9e426-55e8-11e8-9116-160603649f33.PNG">

📝 _When loading the page, all messages details are collapsed by default but the first one per tab._

### expanded

<!-- <img width="1083" alt="screenshot 2018-05-10 a 23 13 39" src="https://user-images.githubusercontent.com/2211145/39894779-42c9cc9a-54a8-11e8-9529-6292481536d4.PNG"> -->

<img width="1086" alt="screenshot 2018-05-12 a 13 49 42" src="https://user-images.githubusercontent.com/2211145/39956981-639fc3d6-55eb-11e8-9224-a48f591db3da.PNG">

### live

<!-- ![mai-10-2018 23-16-17](https://user-images.githubusercontent.com/2211145/39894789-4b8fa138-54a8-11e8-986c-fccf6cd0234f.gif) -->

![mai-12-2018 13-55-40](https://user-images.githubusercontent.com/2211145/39957041-37f17b34-55ec-11e8-8569-a733a104bf82.gif)

### toolbar (with exceptions)

<img width="284" alt="screenshot 2018-05-10 a 23 18 32" src="https://user-images.githubusercontent.com/2211145/39895011-0467f2a0-54a9-11e8-9d78-25461cf71c41.PNG">

## Notes

- Table headers are clickable, so you can jump directly to the message class in the code
- Reversing headers/rows allows to have a wider space for dumps and allows to add more entries in the future. This is an issue we already have with the Validator panel (when there is both an invalid value as object and a constraint violation dumped) which I'd like to revamp soon.
- ~~I wonder if we should keep the dispatched messages in call order, or if we can segregate by bus (using tabs?).~~
- ~~we could add a left container listing messages classes only, allowing to show details of a single message dispatched on a right container (similar to what the Form panel does). I'll probably suggest the same for the Validator panel.~~

Commits
-------

3d19578297 [Messenger] Improve the profiler panel
This commit is contained in:
Fabien Potencier 2018-05-13 08:14:37 +02:00
commit 8335537446
8 changed files with 310 additions and 102 deletions

View File

@ -4,17 +4,33 @@
{% block toolbar %}
{% if collector.messages|length > 0 %}
{% set status_color = collector.exceptionsCount ? 'red' %}
{% set icon %}
{{ include('@WebProfiler/Icon/messenger.svg') }}
<span class="sf-toolbar-value">{{ collector.messages|length }}</span>
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: 'messenger' }) }}
{% set text %}
{% for bus in collector.buses %}
{% set exceptionsCount = collector.exceptionsCount(bus) %}
<div class="sf-toolbar-info-piece">
<b>{{ bus }}</b>
<span
title="{{ exceptionsCount }} message(s) with exceptions"
class="sf-toolbar-status sf-toolbar-status-{{ exceptionsCount ? 'red' }}"
>
{{ collector.messages(bus)|length }}
</span>
</div>
{% endfor %}
{% endset %}
{{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: 'messenger', status: status_color }) }}
{% endif %}
{% endblock %}
{% block menu %}
<span class="label">
<span class="label {{ collector.exceptionsCount ? 'label-status-error' }}">
<span class="icon">{{ include('@WebProfiler/Icon/messenger.svg') }}</span>
<strong>Messages</strong>
@ -24,7 +40,28 @@
</span>
{% endblock %}
{% block head %}
{{ parent() }}
<style>
.message-item thead th { position: relative; cursor: pointer; user-select: none; padding-right: 35px; }
.message-item tbody tr td:first-child { width: 115px; }
.message-item .label { float: right; padding: 1px 5px; opacity: .75; margin-left: 5px; }
.message-item .toggle-button { position: absolute; right: 6px; top: 6px; opacity: .5; pointer-events: none }
.message-item .icon svg { height: 24px; width: 24px; }
.message-item .sf-toggle-off .icon-close, .sf-toggle-on .icon-open { display: none; }
.message-item .sf-toggle-off .icon-open, .sf-toggle-on .icon-close { display: block; }
.message-bus .badge.status-some-errors { line-height: 16px; border-bottom: 2px solid #B0413E; }
.message-item .sf-toggle-content.sf-toggle-visible { display: table-row-group; }
</style>
{% endblock %}
{% block panel %}
{% import _self as helper %}
<h2>Messages</h2>
{% if collector.messages is empty %}
@ -32,41 +69,99 @@
<p>No messages have been collected.</p>
</div>
{% else %}
<table>
<thead>
<tr>
<th>Bus</th>
<th>Message</th>
<th>Result</th>
</tr>
</thead>
<tbody>
{% for message in collector.messages %}
<tr>
<td>{{ message.bus }}</td>
<td>
{% if message.result.object is defined %}
{{ profiler_dump(message.message.object, maxDepth=2) }}
{% else %}
{{ message.message.type }}
{% endif %}
</td>
<td>
{% if message.result.object is defined %}
{{ profiler_dump(message.result.object, maxDepth=2) }}
{% elseif message.result.type is defined %}
{{ message.result.type }}
{% if message.result.value is defined %}
{{ message.result.value }}
{% endif %}
{% endif %}
{% if message.exception.type is defined %}
{{ message.exception.type }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="sf-tabs message-bus">
<div class="tab">
{% set messages = collector.messages %}
{% set exceptionsCount = collector.exceptionsCount %}
<h3 class="tab-title">All<span class="badge {{ exceptionsCount ? exceptionsCount == messages|length ? 'status-error' : 'status-some-errors' }}">{{ messages|length }}</span></h3>
<div class="tab-content">
<p class="text-muted">Ordered list of dispatched messages across all your buses</p>
{{ helper.render_bus_messages(messages, true) }}
</div>
</div>
{% for bus in collector.buses %}
<div class="tab message-bus">
{% set messages = collector.messages(bus) %}
{% set exceptionsCount = collector.exceptionsCount(bus) %}
<h3 class="tab-title">{{ bus }}<span class="badge {{ exceptionsCount ? exceptionsCount == messages|length ? 'status-error' : 'status-some-errors' }}">{{ messages|length }}</span></h3>
<div class="tab-content">
<p class="text-muted">Ordered list of messages dispatched on the <code>{{ bus }}</code> bus</p>
{{ helper.render_bus_messages(messages) }}
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
{% macro render_bus_messages(messages, showBus = false) %}
{% set discr = random() %}
{% for i, dispatchCall in messages %}
<table class="message-item">
<thead>
<tr>
<th colspan="2" class="sf-toggle"
data-toggle-selector="#message-item-{{ discr }}-{{ i }}-details"
data-toggle-initial="{{ loop.first ? 'display' }}"
>
<span class="dump-inline">{{ profiler_dump(dispatchCall.message.type) }}</span>
{% if showBus %}
<span class="label">{{ dispatchCall.bus }}</span>
{% endif %}
{% if dispatchCall.exception is defined %}
<span class="label status-error">exception</span>
{% endif %}
<a class="toggle-button">
<span class="icon icon-close">{{ include('@Twig/images/icon-minus-square.svg') }}</span>
<span class="icon icon-open">{{ include('@Twig/images/icon-plus-square.svg') }}</span>
</a>
</th>
</tr>
</thead>
<tbody id="message-item-{{ discr }}-{{ i }}-details" class="sf-toggle-content">
{% if showBus %}
<tr>
<td class="text-bold">Bus</td>
<td>{{ dispatchCall.bus }}</td>
</tr>
{% endif %}
<tr>
<td class="text-bold">Message</td>
<td>{{ profiler_dump(dispatchCall.message.value, maxDepth=2) }}</td>
</tr>
<tr>
<td class="text-bold">Envelope items</td>
<td>
{% for item in dispatchCall.envelopeItems %}
{{ profiler_dump(item) }}
{% else %}
<span class="text-muted">No items</span>
{% endfor %}
</td>
</tr>
<tr>
<td class="text-bold">Result</td>
<td>
{% if dispatchCall.result is defined %}
{{ profiler_dump(dispatchCall.result.seek('value'), maxDepth=2) }}
{% elseif dispatchCall.exception is defined %}
<span class="text-danger">No result as an exception occurred</span>
{% endif %}
</td>
</tr>
{% if dispatchCall.exception is defined %}
<tr>
<td class="text-bold">Exception</td>
<td>
{{ profiler_dump(dispatchCall.exception.value, maxDepth=1) }}
</td>
</tr>
{% endif %}
</tbody>
</table>
{% endfor %}
{% endmacro %}

View File

@ -215,6 +215,9 @@ table tbody ul {
.text-muted {
color: #999;
}
.text-danger {
color: {{ colors.error|raw }};
}
.text-bold {
font-weight: bold;
}

View File

@ -16,6 +16,8 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface;
use Symfony\Component\Messenger\TraceableMessageBus;
use Symfony\Component\VarDumper\Caster\ClassStub;
use Symfony\Component\VarDumper\Cloner\Data;
/**
* @author Samuel Roze <samuel.roze@gmail.com>
@ -44,13 +46,25 @@ class MessengerDataCollector extends DataCollector implements LateDataCollectorI
*/
public function lateCollect()
{
$this->data = array('messages' => array());
$this->data = array('messages' => array(), 'buses' => array_keys($this->traceableBuses));
$messages = array();
foreach ($this->traceableBuses as $busName => $bus) {
foreach ($bus->getDispatchedMessages() as $message) {
$this->data['messages'][] = $this->collectMessage($busName, $message);
$debugRepresentation = $this->cloneVar($this->collectMessage($busName, $message));
$messages[] = array($debugRepresentation, $message['callTime']);
}
}
// Order by call time
usort($messages, function (array $a, array $b): int {
return $a[1] > $b[1] ? 1 : -1;
});
// Keep the messages clones only
$this->data['messages'] = array_map(function (array $item): Data {
return $item[0];
}, $messages);
}
/**
@ -78,31 +92,19 @@ class MessengerDataCollector extends DataCollector implements LateDataCollectorI
$debugRepresentation = array(
'bus' => $busName,
'envelopeItems' => $tracedMessage['envelopeItems'] ?? null,
'message' => array(
'type' => \get_class($message),
'object' => $this->cloneVar($message),
'type' => new ClassStub(\get_class($message)),
'value' => $message,
),
);
if (array_key_exists('result', $tracedMessage)) {
$result = $tracedMessage['result'];
if (\is_object($result)) {
$debugRepresentation['result'] = array(
'type' => \get_class($result),
'object' => $this->cloneVar($result),
);
} elseif (\is_array($result)) {
$debugRepresentation['result'] = array(
'type' => 'array',
'object' => $this->cloneVar($result),
);
} else {
$debugRepresentation['result'] = array(
'type' => \gettype($result),
'value' => $result,
);
}
$debugRepresentation['result'] = array(
'type' => \is_object($result) ? \get_class($result) : gettype($result),
'value' => $result,
);
}
if (isset($tracedMessage['exception'])) {
@ -110,15 +112,31 @@ class MessengerDataCollector extends DataCollector implements LateDataCollectorI
$debugRepresentation['exception'] = array(
'type' => \get_class($exception),
'message' => $exception->getMessage(),
'value' => $exception,
);
}
return $debugRepresentation;
}
public function getMessages(): array
public function getExceptionsCount(string $bus = null): int
{
return $this->data['messages'] ?? array();
return array_reduce($this->getMessages($bus), function (int $carry, Data $message) {
return $carry += isset($message['exception']) ? 1 : 0;
}, 0);
}
public function getMessages(string $bus = null): array
{
$messages = $this->data['messages'] ?? array();
return $bus ? array_filter($messages, function (Data $message) use ($bus): bool {
return $bus === $message['bus'];
}) : $messages;
}
public function getBuses(): array
{
return $this->data['buses'];
}
}

View File

@ -16,14 +16,22 @@ use Symfony\Component\Messenger\DataCollector\MessengerDataCollector;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
use Symfony\Component\Messenger\TraceableMessageBus;
use Symfony\Component\VarDumper\Test\VarDumperTestTrait;
use Symfony\Component\VarDumper\Cloner\Data;
use Symfony\Component\VarDumper\Dumper\CliDumper;
/**
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
class MessengerDataCollectorTest extends TestCase
{
use VarDumperTestTrait;
/** @var CliDumper */
private $dumper;
protected function setUp()
{
$this->dumper = new CliDumper();
$this->dumper->setColors(false);
}
/**
* @dataProvider getHandleTestData
@ -46,17 +54,18 @@ class MessengerDataCollectorTest extends TestCase
$messages = $collector->getMessages();
$this->assertCount(1, $messages);
$this->assertDumpMatchesFormat($expected, $messages[0]);
$this->assertStringMatchesFormat($expected, $this->getDataAsString($messages[0]));
}
public function getHandleTestData()
{
$messageDump = <<<DUMP
"bus" => "default"
"envelopeItems" => null
"message" => array:2 [
"type" => "Symfony\Component\Messenger\Tests\Fixtures\DummyMessage"
"object" => Symfony\Component\VarDumper\Cloner\Data {%A
%A+class: "Symfony\Component\Messenger\Tests\Fixtures\DummyMessage"%A
"value" => Symfony\Component\Messenger\Tests\Fixtures\DummyMessage %A
-message: "dummy message"
}
]
DUMP;
@ -64,7 +73,7 @@ DUMP;
yield 'no returned value' => array(
null,
<<<DUMP
array:3 [
array:4 [
$messageDump
"result" => array:2 [
"type" => "NULL"
@ -77,7 +86,7 @@ DUMP
yield 'scalar returned value' => array(
'returned value',
<<<DUMP
array:3 [
array:4 [
$messageDump
"result" => array:2 [
"type" => "string"
@ -90,11 +99,13 @@ DUMP
yield 'array returned value' => array(
array('returned value'),
<<<DUMP
array:3 [
array:4 [
$messageDump
"result" => array:2 [
"type" => "array"
"object" => Symfony\Component\VarDumper\Cloner\Data {%A
"value" => array:1 [
0 => "returned value"
]
]
]
DUMP
@ -123,21 +134,66 @@ DUMP
$messages = $collector->getMessages();
$this->assertCount(1, $messages);
$this->assertDumpMatchesFormat(<<<DUMP
array:3 [
$this->assertStringMatchesFormat(<<<DUMP
array:4 [
"bus" => "default"
"envelopeItems" => null
"message" => array:2 [
"type" => "Symfony\Component\Messenger\Tests\Fixtures\DummyMessage"
"object" => Symfony\Component\VarDumper\Cloner\Data {%A
%A+class: "Symfony\Component\Messenger\Tests\Fixtures\DummyMessage"%A
"value" => Symfony\Component\Messenger\Tests\Fixtures\DummyMessage %A
-message: "dummy message"
}
]
"exception" => array:2 [
"type" => "RuntimeException"
"message" => "foo"
"value" => RuntimeException %A
]
]
]
DUMP
, $messages[0]);
, $this->getDataAsString($messages[0]));
}
public function testKeepsOrderedDispatchCalls()
{
$firstBus = $this->getMockBuilder(MessageBusInterface::class)->getMock();
$firstBus = new TraceableMessageBus($firstBus);
$secondBus = $this->getMockBuilder(MessageBusInterface::class)->getMock();
$secondBus = new TraceableMessageBus($secondBus);
$collector = new MessengerDataCollector();
$collector->registerBus('first bus', $firstBus);
$collector->registerBus('second bus', $secondBus);
$firstBus->dispatch(new DummyMessage('#1'));
$secondBus->dispatch(new DummyMessage('#2'));
$secondBus->dispatch(new DummyMessage('#3'));
$firstBus->dispatch(new DummyMessage('#4'));
$secondBus->dispatch(new DummyMessage('#5'));
$collector->lateCollect();
$messages = $collector->getMessages();
$this->assertCount(5, $messages);
$this->assertSame('#1', $messages[0]['message']['value']['message']);
$this->assertSame('first bus', $messages[0]['bus']);
$this->assertSame('#2', $messages[1]['message']['value']['message']);
$this->assertSame('second bus', $messages[1]['bus']);
$this->assertSame('#3', $messages[2]['message']['value']['message']);
$this->assertSame('second bus', $messages[2]['bus']);
$this->assertSame('#4', $messages[3]['message']['value']['message']);
$this->assertSame('first bus', $messages[3]['bus']);
$this->assertSame('#5', $messages[4]['message']['value']['message']);
$this->assertSame('second bus', $messages[4]['bus']);
}
private function getDataAsString(Data $data): string
{
return rtrim($this->dumper->dump($data, true));
}
}

View File

@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Messenger\Tests\Fixtures;
use Symfony\Component\Messenger\EnvelopeItemInterface;
class AnEnvelopeItem implements EnvelopeItemInterface
{
/**
* {@inheritdoc}
*/
public function serialize()
{
return '';
}
/**
* {@inheritdoc}
*/
public function unserialize($serialized)
{
// noop
}
}

View File

@ -15,10 +15,10 @@ use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Asynchronous\Transport\ReceivedMessage;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\EnvelopeAwareInterface;
use Symfony\Component\Messenger\EnvelopeItemInterface;
use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Tests\Fixtures\AnEnvelopeItem;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
class MessageBusTest extends TestCase
@ -160,22 +160,3 @@ class MessageBusTest extends TestCase
$bus->dispatch($envelope);
}
}
class AnEnvelopeItem implements EnvelopeItemInterface
{
/**
* {@inheritdoc}
*/
public function serialize()
{
return '';
}
/**
* {@inheritdoc}
*/
public function unserialize($serialized)
{
return new self();
}
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Messenger\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Tests\Fixtures\AnEnvelopeItem;
use Symfony\Component\Messenger\Tests\Fixtures\DummyMessage;
use Symfony\Component\Messenger\TraceableMessageBus;
@ -28,19 +29,29 @@ class TraceableMessageBusTest extends TestCase
$traceableBus = new TraceableMessageBus($bus);
$this->assertSame($result, $traceableBus->dispatch($message));
$this->assertSame(array(array('message' => $message, 'result' => $result)), $traceableBus->getDispatchedMessages());
$this->assertCount(1, $tracedMessages = $traceableBus->getDispatchedMessages());
$this->assertArraySubset(array(
'message' => $message,
'result' => $result,
'envelopeItems' => null,
), $tracedMessages[0], true);
}
public function testItTracesResultWithEnvelope()
{
$envelope = Envelope::wrap($message = new DummyMessage('Hello'));
$envelope = Envelope::wrap($message = new DummyMessage('Hello'))->with($envelopeItem = new AnEnvelopeItem());
$bus = $this->getMockBuilder(MessageBusInterface::class)->getMock();
$bus->expects($this->once())->method('dispatch')->with($envelope)->willReturn($result = array('foo' => 'bar'));
$traceableBus = new TraceableMessageBus($bus);
$this->assertSame($result, $traceableBus->dispatch($envelope));
$this->assertSame(array(array('message' => $message, 'result' => $result)), $traceableBus->getDispatchedMessages());
$this->assertCount(1, $tracedMessages = $traceableBus->getDispatchedMessages());
$this->assertArraySubset(array(
'message' => $message,
'result' => $result,
'envelopeItems' => array($envelopeItem),
), $tracedMessages[0], true);
}
public function testItTracesExceptions()
@ -58,6 +69,11 @@ class TraceableMessageBusTest extends TestCase
$this->assertSame($exception, $e);
}
$this->assertSame(array(array('message' => $message, 'exception' => $exception)), $traceableBus->getDispatchedMessages());
$this->assertCount(1, $tracedMessages = $traceableBus->getDispatchedMessages());
$this->assertArraySubset(array(
'message' => $message,
'exception' => $exception,
'envelopeItems' => null,
), $tracedMessages[0], true);
}
}

View File

@ -29,21 +29,27 @@ class TraceableMessageBus implements MessageBusInterface
*/
public function dispatch($message)
{
$callTime = microtime(true);
$messageToTrace = $message instanceof Envelope ? $message->getMessage() : $message;
$envelopeItems = $message instanceof Envelope ? array_values($message->all()) : null;
try {
$result = $this->decoratedBus->dispatch($message);
$this->dispatchedMessages[] = array(
'envelopeItems' => $envelopeItems,
'message' => $messageToTrace,
'result' => $result,
'callTime' => $callTime,
);
return $result;
} catch (\Throwable $e) {
$this->dispatchedMessages[] = array(
'envelopeItems' => $envelopeItems,
'message' => $messageToTrace,
'exception' => $e,
'callTime' => $callTime,
);
throw $e;