From 963cc7a40dab0f6a119a60db671e97b5aa1b643c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Tue, 1 Aug 2023 21:15:44 +0200 Subject: [PATCH] [Worflow] Add a TraceableWorkflow --- .../FrameworkBundle/FrameworkBundle.php | 2 + .../views/Collector/workflow.html.twig | 73 ++++++++++- .../DataCollector/WorkflowDataCollector.php | 45 +++++++ .../Workflow/Debug/TraceableWorkflow.php | 122 ++++++++++++++++++ .../DependencyInjection/WorkflowDebugPass.php | 37 ++++++ src/Symfony/Component/Workflow/Registry.php | 2 +- 6 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 src/Symfony/Component/Workflow/Debug/TraceableWorkflow.php create mode 100644 src/Symfony/Component/Workflow/DependencyInjection/WorkflowDebugPass.php diff --git a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php index 688014feae103..e46e7dfe290f9 100644 --- a/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php +++ b/src/Symfony/Bundle/FrameworkBundle/FrameworkBundle.php @@ -70,6 +70,7 @@ use Symfony\Component\Validator\DependencyInjection\AddValidatorInitializersPass; use Symfony\Component\VarExporter\Internal\Hydrator; use Symfony\Component\VarExporter\Internal\Registry; +use Symfony\Component\Workflow\DependencyInjection\WorkflowDebugPass; use Symfony\Component\Workflow\DependencyInjection\WorkflowGuardListenerPass; // Help opcache.preload discover always-needed symbols @@ -189,6 +190,7 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new UnusedTagsPass(), PassConfig::TYPE_AFTER_REMOVING); $container->addCompilerPass(new ContainerBuilderDebugDumpPass(), PassConfig::TYPE_BEFORE_REMOVING, -255); $container->addCompilerPass(new CacheCollectorPass(), PassConfig::TYPE_BEFORE_REMOVING); + $this->addCompilerPassIfExists($container, WorkflowDebugPass::class); } } diff --git a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig index 5fba10fbb48ac..05bc799536bb6 100644 --- a/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig +++ b/src/Symfony/Bundle/WebProfilerBundle/Resources/views/Collector/workflow.html.twig @@ -1,5 +1,22 @@ {% extends '@WebProfiler/Profiler/layout.html.twig' %} +{% block toolbar %} + {% if collector.callsCount > 0 %} + {% set icon %} + {{ source('@WebProfiler/Icon/workflow.svg') }} + {{ collector.callsCount }} + {% endset %} + {% set text %} +
+ Workflow Calls + {{ collector.callsCount }} +
+ {% endset %} + + {{ include('@WebProfiler/Profiler/toolbar_item.html.twig', { link: profiler_url }) }} + {% endif %} +{% endblock %} + {% block menu %} @@ -45,15 +62,67 @@ }); -

Definitions

{% for name, data in collector.workflows %}
-

{{ name }}

+

{{ name }}{% if data.calls|length %} ({{ data.calls|length }}){% endif %}

+
+

Definition

                             {{ data.dump|raw }}
                         
+ +

Calls

+ + + + + + + + + + + + + {% for call in data.calls %} + + + + + + + + + {% endfor %} + +
#CallArgsReturnExceptionDuration
{{ loop.index }} + {{ call.method }}() + {% if call.previousMarking ?? null %} +
+ Previous marking: + {{ profiler_dump(call.previousMarking) }} + {% endif %} +
+ {{ profiler_dump(call.args) }} + + {% if call.return is defined %} + {% if call.return is same as true %} + true + {% elseif call.return is same as false %} + false + {% else %} + {{ profiler_dump(call.return) }} + {% endif %} + {% endif %} + + {% if call.exception is defined %} + {{ profiler_dump(call.exception) }} + {% endif %} + + {{ call.duration }}ms +
{% endfor %} diff --git a/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php b/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php index 2839d31c71dcb..69780121b35c3 100644 --- a/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php +++ b/src/Symfony/Component/Workflow/DataCollector/WorkflowDataCollector.php @@ -15,7 +15,12 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\DataCollector\DataCollector; use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\VarDumper\Caster\Caster; +use Symfony\Component\VarDumper\Cloner\Stub; +use Symfony\Component\Workflow\Debug\TraceableWorkflow; use Symfony\Component\Workflow\Dumper\MermaidDumper; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\TransitionBlocker; /** * @author Grégoire Pineau @@ -34,11 +39,17 @@ public function collect(Request $request, Response $response, \Throwable $except public function lateCollect(): void { foreach ($this->workflows as $workflow) { + $calls = []; + if ($workflow instanceof TraceableWorkflow) { + $calls = $this->cloneVar($workflow->getCalls()); + } + // We always use a workflow type because we want to mermaid to // create a node for transitions $dumper = new MermaidDumper(MermaidDumper::TRANSITION_TYPE_WORKFLOW); $this->data['workflows'][$workflow->getName()] = [ 'dump' => $dumper->dump($workflow->getDefinition()), + 'calls' => $calls, ]; } } @@ -57,4 +68,38 @@ public function getWorkflows(): array { return $this->data['workflows'] ?? []; } + + public function getCallsCount(): int + { + $i = 0; + foreach ($this->getWorkflows() as $workflow) { + $i += \count($workflow['calls']); + } + + return $i; + } + + protected function getCasters(): array + { + $casters = [ + ...parent::getCasters(), + TransitionBlocker::class => function ($v, array $a, Stub $s, $isNested) { + unset( + $a[sprintf(Caster::PATTERN_PRIVATE, $v::class, 'code')], + $a[sprintf(Caster::PATTERN_PRIVATE, $v::class, 'parameters')], + ); + + $s->cut += 2; + + return $a; + }, + Marking::class => function ($v, array $a, Stub $s, $isNested) { + $a[Caster::PREFIX_VIRTUAL.'.places'] = array_keys($v->getPlaces()); + + return $a; + }, + ]; + + return $casters; + } } diff --git a/src/Symfony/Component/Workflow/Debug/TraceableWorkflow.php b/src/Symfony/Component/Workflow/Debug/TraceableWorkflow.php new file mode 100644 index 0000000000000..b8ac0867d4194 --- /dev/null +++ b/src/Symfony/Component/Workflow/Debug/TraceableWorkflow.php @@ -0,0 +1,122 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\Debug; + +use Symfony\Component\Stopwatch\Stopwatch; +use Symfony\Component\Workflow\Definition; +use Symfony\Component\Workflow\Marking; +use Symfony\Component\Workflow\MarkingStore\MarkingStoreInterface; +use Symfony\Component\Workflow\Metadata\MetadataStoreInterface; +use Symfony\Component\Workflow\TransitionBlockerList; +use Symfony\Component\Workflow\WorkflowInterface; + +/** + * @author Grégoire Pineau + */ +class TraceableWorkflow implements WorkflowInterface +{ + private array $calls = []; + + public function __construct( + private readonly WorkflowInterface $workflow, + private readonly Stopwatch $stopwatch, + ) { + } + + public function getMarking(object $subject, array $context = []): Marking + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function can(object $subject, string $transitionName): bool + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function buildTransitionBlockerList(object $subject, string $transitionName): TransitionBlockerList + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function apply(object $subject, string $transitionName, array $context = []): Marking + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function getEnabledTransitions(object $subject): array + { + return $this->callInner(__FUNCTION__, \func_get_args()); + } + + public function getName(): string + { + return $this->workflow->getName(); + } + + public function getDefinition(): Definition + { + return $this->workflow->getDefinition(); + } + + public function getMarkingStore(): MarkingStoreInterface + { + return $this->workflow->getMarkingStore(); + } + + public function getMetadataStore(): MetadataStoreInterface + { + return $this->workflow->getMetadataStore(); + } + + public function getCalls(): array + { + return $this->calls; + } + + private function callInner(string $method, array $args): mixed + { + $sMethod = $this->workflow::class.'::'.$method; + $this->stopwatch->start($sMethod, 'workflow'); + + $previousMarking = null; + if ('apply' === $method) { + try { + $previousMarking = $this->workflow->getMarking($args[0]); + } catch (\Throwable) { + } + } + + try { + $return = $this->workflow->{$method}(...$args); + + $this->calls[] = [ + 'method' => $method, + 'duration' => $this->stopwatch->stop($sMethod)->getDuration(), + 'args' => $args, + 'previousMarking' => $previousMarking ?? null, + 'return' => $return, + ]; + + return $return; + } catch (\Throwable $exception) { + $this->calls[] = [ + 'method' => $method, + 'duration' => $this->stopwatch->stop($sMethod)->getDuration(), + 'args' => $args, + 'previousMarking' => $previousMarking ?? null, + 'exception' => $exception, + ]; + + throw $exception; + } + } +} diff --git a/src/Symfony/Component/Workflow/DependencyInjection/WorkflowDebugPass.php b/src/Symfony/Component/Workflow/DependencyInjection/WorkflowDebugPass.php new file mode 100644 index 0000000000000..634605dffa5ee --- /dev/null +++ b/src/Symfony/Component/Workflow/DependencyInjection/WorkflowDebugPass.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Workflow\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Workflow\Debug\TraceableWorkflow; + +/** + * Adds all configured security voters to the access decision manager. + * + * @author Grégoire Pineau + */ +class WorkflowDebugPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + foreach ($container->findTaggedServiceIds('workflow') as $id => $attributes) { + $container->register("debug.{$id}", TraceableWorkflow::class) + ->setDecoratedService($id) + ->setArguments([ + new Reference("debug.{$id}.inner"), + new Reference('debug.stopwatch'), + ]); + } + } +} diff --git a/src/Symfony/Component/Workflow/Registry.php b/src/Symfony/Component/Workflow/Registry.php index e9d9481fb0655..bfe25fae47b04 100644 --- a/src/Symfony/Component/Workflow/Registry.php +++ b/src/Symfony/Component/Workflow/Registry.php @@ -41,7 +41,7 @@ public function has(object $subject, string $workflowName = null): bool return false; } - public function get(object $subject, string $workflowName = null): Workflow + public function get(object $subject, string $workflowName = null): WorkflowInterface { $matched = [];