From f4d4dfc62c8dc632c2ce4d66af10c84125a90994 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Fri, 9 Oct 2026 17:07:55 +0200 Subject: [PATCH] feat(manager) #157 Configure the level of the end of process logs with the new logs option (global or per process): success_level (default info) for Process succeed, and failed_level (default debug) for the new Process failed log Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + config/services/registry.yaml | 1 + docs/01-quick_start.md | 4 +- docs/cookbooks/performances_monitoring.md | 6 +- docs/reference/01-process_definition.md | 19 ++ src/Configuration/ProcessConfiguration.php | 19 ++ .../CleverAgeProcessExtension.php | 1 + src/DependencyInjection/Configuration.php | 51 +++- src/Manager/ProcessManager.php | 85 ++++--- src/Registry/ProcessConfigurationRegistry.php | 8 +- .../DependencyInjection/ConfigurationTest.php | 91 +++++++ tests/Manager/ProcessManagerEndLogTest.php | 231 ++++++++++++++++++ 12 files changed, 468 insertions(+), 49 deletions(-) create mode 100644 tests/DependencyInjection/ConfigurationTest.php create mode 100644 tests/Manager/ProcessManagerEndLogTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d744af..3beab81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Latest * [#143](https://github.com/cleverage/process-bundle/issues/143) Improve PHPStan level from 6 to 7, and fix the level 8 errors that do not require a signature change (the remaining ones, due to nullable return types such as `AbstractConfigurableTask::getOptions(): ?array`, will be fixed in v6.0). Default to a `ContextualOptionResolver` in ProcessState and to a property accessor in ConditionTrait when none is set. * [#94](https://github.com/cleverage/process-bundle/issues/94) TransformerTrait: the `transformers` option (TransformerTask, MappingTransformer, ArrayMapTransformer, CachedTransformer, RulesTransformer, generic transformers) also accepts a list, whose items are a transformer code without options (`- trim`) or a single `code: options` map (`- callback: {...}`), to chain the same transformer without `#` suffix. The map syntax is still supported. Update documentation, add tests. * [#117](https://github.com/cleverage/process-bundle/issues/117) MappingTransformer: writing a target property that is a nested path (e.g. `address.city`) on an array destination, which silently created an `address.city` literal key, is deprecated and will throw an `\UnexpectedValueException` in v6.0. Use `[address.city]` for a literal key or `[address][city]` for a nested array; a simple property name (e.g. `name`) is still added as a key. Update documentation, add tests. +* [#157](https://github.com/cleverage/process-bundle/issues/157) Configure the level of the logs written at the end of each process with the new `logs` option, globally (`clever_age_process.logs`) or per process (`configurations..logs`): `success_level` (default `info`) for `Process succeed`, and `failed_level` (default `debug`) for the new `Process failed` log, written when a process fails, with or without exception. Update documentation, add tests. ## Fixes * [#143](https://github.com/cleverage/process-bundle/issues/143) Fix InputIteratorTask: an `\IteratorAggregate` input whose `getIterator()` does not return an `\Iterator` (e.g. another `\IteratorAggregate`) is iterated instead of failing with a `TypeError`. Update documentation, add tests. diff --git a/config/services/registry.yaml b/config/services/registry.yaml index 48d5e469..f84b16c0 100644 --- a/config/services/registry.yaml +++ b/config/services/registry.yaml @@ -5,6 +5,7 @@ services: arguments: - ~ - ~ + - ~ cleverage_process.registry.transformer: class: CleverAge\ProcessBundle\Registry\TransformerRegistry diff --git a/docs/01-quick_start.md b/docs/01-quick_start.md index 758d6456..739e3e94 100644 --- a/docs/01-quick_start.md +++ b/docs/01-quick_start.md @@ -51,12 +51,14 @@ framework: You can use `./bin/console config:dump-reference clever_age_process` to have a summary of current configuration. -The configuration has three root keys: +The configuration has four root keys: - `configurations`: your processes (see [process definition](reference/01-process_definition.md)) - `generic_transformers`: reusable transformers built from configuration (see [generic transformers definition](reference/03-generic_transformers_definition.md)) - `default_error_strategy`: the behavior of a task that encounters an error when it does not define its own `error_strategy`. Allowed values are `stop` (the default) and `skip`. +- `logs`: the levels of the logs written at the end of each process, `success_level` (`info` by default) and + `failed_level` (`debug` by default), see [process definition](reference/01-process_definition.md#global-attributes) We recommend keeping the `stop` default, and then specify task by task which one can be skipped: diff --git a/docs/cookbooks/performances_monitoring.md b/docs/cookbooks/performances_monitoring.md index b6fdc807..89cec006 100644 --- a/docs/cookbooks/performances_monitoring.md +++ b/docs/cookbooks/performances_monitoring.md @@ -52,8 +52,10 @@ SQL n/a n/a ## Built-in timing information Before profiling, the process logs already give some timing information: -- on success, the process manager logs `Process succeed` (level `info`, channel `cleverage_process`) - with the total `duration` of the process, in seconds, in the record context +- at the end, the process manager logs `Process succeed` (level `info` by default, channel + `cleverage_process`) or `Process failed` (level `debug` by default), with the total `duration` of + the process, in seconds, in the record context. Both levels can be configured, see the `logs` option of the + [process definition](../reference/01-process_definition.md) - at `debug` level, the same channel logs each task execution (`Processing task `, `Proceeding task ...`, `Flushing task ...`): with a formatter displaying milliseconds, it shows where the time is spent. With the Monolog console handler of the Symfony recipe, `-vvv` displays debug records in the console: diff --git a/docs/reference/01-process_definition.md b/docs/reference/01-process_definition.md index 3b8675ff..250ed8b5 100644 --- a/docs/reference/01-process_definition.md +++ b/docs/reference/01-process_definition.md @@ -7,6 +7,9 @@ YAML Configuration ```yaml clever_age_process: default_error_strategy: + logs: + success_level: + failed_level: configurations: : description: @@ -15,6 +18,9 @@ clever_age_process: end_point: public: options: + logs: + success_level: + failed_level: tasks: : ``` @@ -25,6 +31,16 @@ Global attributes **default_error_strategy**: optional, either *stop* (default) or *skip*. Error strategy used by every task that does not define its own `error_strategy` (see [task definition](02-task_definition.md)). +**logs**: optional [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424) severities of the logs written at the end +of each process (channel `cleverage_process`, with the `duration` of the process in seconds in the record context): +* `success_level` (default *info*): level of the `Process succeed` log +* `failed_level` (default *debug*): level of the `Process failed` log, written when the process fails, + either with an exception (in addition to the `Critical process failure` log) or when it is stopped without exception + (e.g. by a [StopTask](tasks/stop_task.md)) + +Without UI, the success log is the only trace of a succeeded process: lower its level (e.g. *debug*) only if processes +are followed by other means. + Process attributes ------------------ @@ -51,6 +67,9 @@ from the process list (unless `--all` is used) but execution is still allowed. through `ProcessConfiguration::getOptions()`, e.g. [cleverage/ui-process-bundle](https://github.com/cleverage/ui-process-bundle) reads its `ui` key to configure the launch form. +**logs**: optional `success_level` and `failed_level` overriding, for this process only, the global +[`logs`](#global-attributes) levels (e.g. a scheduled process that often has nothing to do). + **tasks**: list of task definitions contained in the process, indexed by task code. See [task definition](02-task_definition.md). diff --git a/src/Configuration/ProcessConfiguration.php b/src/Configuration/ProcessConfiguration.php index b62aa723..37fe30cd 100644 --- a/src/Configuration/ProcessConfiguration.php +++ b/src/Configuration/ProcessConfiguration.php @@ -15,6 +15,7 @@ use CleverAge\ProcessBundle\Exception\CircularProcessException; use CleverAge\ProcessBundle\Exception\MissingTaskConfigurationException; +use Psr\Log\LogLevel; /** * Holds the processes configuration to launch a task. @@ -44,6 +45,8 @@ public function __construct( protected string $description = '', protected string $help = '', protected bool $public = true, + protected string $successLogLevel = LogLevel::INFO, + protected string $failedLogLevel = LogLevel::DEBUG, ) { } @@ -93,6 +96,22 @@ public function isPublic(): bool return $this->public; } + /** + * Level of the "Process succeed" log. + */ + public function getSuccessLogLevel(): string + { + return $this->successLogLevel; + } + + /** + * Level of the "Process failed" log. + */ + public function getFailedLogLevel(): string + { + return $this->failedLogLevel; + } + public function isPrivate(): bool { return !$this->public; diff --git a/src/DependencyInjection/CleverAgeProcessExtension.php b/src/DependencyInjection/CleverAgeProcessExtension.php index cf7830bf..1c8c5cf1 100644 --- a/src/DependencyInjection/CleverAgeProcessExtension.php +++ b/src/DependencyInjection/CleverAgeProcessExtension.php @@ -40,6 +40,7 @@ public function load(array $configs, ContainerBuilder $container): void $processConfigurationRegistry = $container->getDefinition('cleverage_process.registry.process_configuration'); $processConfigurationRegistry->replaceArgument(0, $config['configurations']); $processConfigurationRegistry->replaceArgument(1, $config['default_error_strategy']); + $processConfigurationRegistry->replaceArgument(2, $config['logs']); // Automatic transformer creation from config foreach ($config['generic_transformers'] as $transformerCode => $transformerConfig) { diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index bdb9b8ef..0f5a7c63 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -27,6 +27,17 @@ */ class Configuration implements ConfigurationInterface { + protected const LOG_LEVELS = [ + LogLevel::EMERGENCY, + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::ERROR, + LogLevel::WARNING, + LogLevel::NOTICE, + LogLevel::INFO, + LogLevel::DEBUG, + ]; + public function __construct( protected string $root = 'clever_age_process', ) { @@ -42,6 +53,19 @@ public function getConfigTreeBuilder(): TreeBuilder ->values([TaskConfiguration::STRATEGY_SKIP, TaskConfiguration::STRATEGY_STOP]) ->defaultValue(TaskConfiguration::STRATEGY_STOP); + // Default log levels of the end of the processes + $logsDefinition = $definition->arrayNode('logs') + ->addDefaultsIfNotSet() + ->children(); + $logsDefinition->enumNode('success_level') + ->values(self::LOG_LEVELS) + ->defaultValue(LogLevel::INFO) + ->info('Level of the "Process succeed" log'); + $logsDefinition->enumNode('failed_level') + ->values(self::LOG_LEVELS) + ->defaultValue(LogLevel::DEBUG) + ->info('Level of the "Process failed" log'); + $this->appendRootProcessConfigDefinition($definition); $this->appendRootTransformersConfigDefinition($definition); @@ -125,6 +149,20 @@ protected function appendProcessConfigDefinition(NodeBuilder $definition): void ->arrayNode('options') ->prototype('variable') ->end() + ->end() + ->arrayNode('logs') + ->info('Override the default log levels of the end of the process') + ->addDefaultsIfNotSet() + ->children() + ->enumNode('success_level') + ->values([...self::LOG_LEVELS, null]) + ->defaultNull() + ->end() + ->enumNode('failed_level') + ->values([...self::LOG_LEVELS, null]) + ->defaultNull() + ->end() + ->end() ->end(); /** @var ArrayNodeDefinition $tasksArrayDefinition */ @@ -145,17 +183,6 @@ protected function appendProcessConfigDefinition(NodeBuilder $definition): void protected function appendTaskConfigDefinition(NodeBuilder $definition): void { - $logLevels = [ - LogLevel::EMERGENCY, - LogLevel::ALERT, - LogLevel::CRITICAL, - LogLevel::ERROR, - LogLevel::WARNING, - LogLevel::NOTICE, - LogLevel::INFO, - LogLevel::DEBUG, - ]; - $definition->scalarNode('service') ->isRequired(); $definition->scalarNode('description') @@ -168,7 +195,7 @@ protected function appendTaskConfigDefinition(NodeBuilder $definition): void $definition->scalarNode('error_strategy') ->defaultNull(); $definition->enumNode('log_level') - ->values($logLevels) + ->values(self::LOG_LEVELS) ->defaultValue(LogLevel::CRITICAL); foreach (['outputs', 'errors', 'error_outputs'] as $nodeName) { diff --git a/src/Manager/ProcessManager.php b/src/Manager/ProcessManager.php index 188f6515..43732660 100644 --- a/src/Manager/ProcessManager.php +++ b/src/Manager/ProcessManager.php @@ -129,44 +129,54 @@ protected function doExecute(string $processCode, mixed $input = null, array $co $processConfiguration = $this->processConfigurationRegistry->getProcessConfiguration($processCode); $processHistory = $this->initializeStates($processConfiguration, $context); $this->processHistory = $processHistory; - $this->checkProcess($processConfiguration); - // First initialize the whole stack in a linear way, tasks are initialized in the order they are configured - foreach ($processConfiguration->getTaskConfigurations() as $taskConfiguration) { - $this->initialize($taskConfiguration); - } + try { + $this->checkProcess($processConfiguration); - // If defined, set the input of a task - if ($processConfiguration->getEntryPoint() instanceof TaskConfiguration) { - $processConfiguration->getEntryPoint() - ->getState() - ->setInput($input); - } elseif (null !== $input) { - $this->processLogger->warning('Process has no entry point for input'); - } + // First initialize the whole stack in a linear way, tasks are initialized in the order they are configured + foreach ($processConfiguration->getTaskConfigurations() as $taskConfiguration) { + $this->initialize($taskConfiguration); + } - // Resolve task from main branch, starting by the end - $taskList = array_reverse($processConfiguration->getTaskConfigurations()); - $allowedTasks = $processConfiguration->getMainTaskGroup(); - foreach ($taskList as $taskConfiguration) { - if (\in_array($taskConfiguration->getCode(), $allowedTasks, true)) { - $this->resolve($taskConfiguration); + // If defined, set the input of a task + if ($processConfiguration->getEntryPoint() instanceof TaskConfiguration) { + $processConfiguration->getEntryPoint() + ->getState() + ->setInput($input); + } elseif (null !== $input) { + $this->processLogger->warning('Process has no entry point for input'); } - } - // Finalize the process in a linear way - foreach ($processConfiguration->getTaskConfigurations() as $taskConfiguration) { - $this->finalize($taskConfiguration); - } + // Resolve task from main branch, starting by the end + $taskList = array_reverse($processConfiguration->getTaskConfigurations()); + $allowedTasks = $processConfiguration->getMainTaskGroup(); + foreach ($taskList as $taskConfiguration) { + if (\in_array($taskConfiguration->getCode(), $allowedTasks, true)) { + $this->resolve($taskConfiguration); + } + } - $this->endProcess($processHistory); + // Finalize the process in a linear way + foreach ($processConfiguration->getTaskConfigurations() as $taskConfiguration) { + $this->finalize($taskConfiguration); + } - // If defined, return the output of a task - $returnValue = null; - if ($processConfiguration->getEndPoint() instanceof TaskConfiguration) { - $returnValue = $processConfiguration->getEndPoint() - ->getState() - ->getOutput(); + $this->endProcess($processHistory); + + // If defined, return the output of a task + $returnValue = null; + if ($processConfiguration->getEndPoint() instanceof TaskConfiguration) { + $returnValue = $processConfiguration->getEndPoint() + ->getState() + ->getOutput(); + } + } catch (\Throwable $error) { + if ($processHistory->isStarted()) { + $processHistory->setFailed(); + } + $this->endProcess($processHistory); + + throw $error; } $this->processHistory = $parentProcessHistory; @@ -519,16 +529,27 @@ protected function handleState(ProcessState $state): void protected function endProcess(ProcessHistory $history): void { + $processConfiguration = $this->processConfigurationRegistry->getProcessConfiguration($history->getProcessCode()); + // Do not change state if already set if ($history->isStarted()) { $history->setSuccess(); - $this->processLogger->info( + $this->processLogger->log( + $processConfiguration->getSuccessLogLevel(), "Process {$history->getProcessCode()} succeed", [ 'duration' => $history->getDuration(), ] ); + } elseif ($history->isFailed()) { + $this->processLogger->log( + $processConfiguration->getFailedLogLevel(), + "Process {$history->getProcessCode()} failed", + [ + 'duration' => $history->getDuration(), + ] + ); } } diff --git a/src/Registry/ProcessConfigurationRegistry.php b/src/Registry/ProcessConfigurationRegistry.php index d22feea6..d860b802 100644 --- a/src/Registry/ProcessConfigurationRegistry.php +++ b/src/Registry/ProcessConfigurationRegistry.php @@ -30,11 +30,13 @@ class ProcessConfigurationRegistry protected array $processConfigurations = []; /** - * @param array> $rawConfiguration + * @param array> $rawConfiguration + * @param array{success_level?: string, failed_level?: string} $defaultLogLevels */ public function __construct( protected array $rawConfiguration, protected string $defaultErrorStrategy, + protected array $defaultLogLevels = [], ) { } @@ -105,7 +107,9 @@ protected function resolveConfiguration(string $processCode): void $rawProcessConfiguration['end_point'], $rawProcessConfiguration['description'], $rawProcessConfiguration['help'], - $rawProcessConfiguration['public'] + $rawProcessConfiguration['public'], + $rawProcessConfiguration['logs']['success_level'] ?? $this->defaultLogLevels['success_level'] ?? LogLevel::INFO, + $rawProcessConfiguration['logs']['failed_level'] ?? $this->defaultLogLevels['failed_level'] ?? LogLevel::DEBUG ); // Set links between tasks diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 00000000..41d0af4a --- /dev/null +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,91 @@ +process(['configurations' => ['test.process' => ['tasks' => []]]]); + + self::assertSame(['success_level' => 'info', 'failed_level' => 'debug'], $config['logs']); + self::assertSame( + ['success_level' => null, 'failed_level' => null], + $config['configurations']['test.process']['logs'] + ); + } + + public function testLogLevelsCanBeConfigured(): void + { + $config = $this->process([ + 'logs' => ['success_level' => 'debug', 'failed_level' => 'error'], + 'configurations' => [ + 'test.process' => [ + 'logs' => ['failed_level' => 'critical'], + 'tasks' => [], + ], + ], + ]); + + self::assertSame(['success_level' => 'debug', 'failed_level' => 'error'], $config['logs']); + self::assertEquals( + ['success_level' => null, 'failed_level' => 'critical'], + $config['configurations']['test.process']['logs'] + ); + } + + public function testInvalidDefaultLogLevelIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process(['logs' => ['success_level' => 'verbose']]); + } + + public function testInvalidProcessLogLevelIsRejected(): void + { + $this->expectException(InvalidConfigurationException::class); + + $this->process(['configurations' => ['test.process' => ['logs' => ['failed_level' => 'verbose'], 'tasks' => []]]]); + } + + public function testDefaultLogLevelsArePassedToTheProcessConfigurationRegistry(): void + { + $container = new ContainerBuilder(); + (new CleverAgeProcessExtension())->load([['logs' => ['success_level' => 'debug']]], $container); + + self::assertSame( + ['success_level' => 'debug', 'failed_level' => 'debug'], + $container->getDefinition('cleverage_process.registry.process_configuration')->getArgument(2) + ); + } + + /** + * @param array $config + * + * @return array + */ + private function process(array $config): array + { + return (new Processor())->processConfiguration(new Configuration(), [$config]); + } +} diff --git a/tests/Manager/ProcessManagerEndLogTest.php b/tests/Manager/ProcessManagerEndLogTest.php new file mode 100644 index 00000000..4b6ae803 --- /dev/null +++ b/tests/Manager/ProcessManagerEndLogTest.php @@ -0,0 +1,231 @@ +}> + */ + private array $records = []; + + public function testSucceededProcessIsLoggedAsInfoByDefault(): void + { + $this->createProcessManager('@test.constant')->execute('test.process'); + + self::assertSame([[LogLevel::INFO, 'Process test.process succeed']], $this->getEndRecords()); + } + + public function testSucceededProcessUsesTheDefaultSuccessLevel(): void + { + $this->createProcessManager('@test.constant', defaultLogLevels: ['success_level' => LogLevel::DEBUG]) + ->execute('test.process'); + + self::assertSame([[LogLevel::DEBUG, 'Process test.process succeed']], $this->getEndRecords()); + } + + public function testSucceededProcessUsesTheLevelOfTheProcess(): void + { + $this->createProcessManager( + '@test.constant', + processLogLevels: ['success_level' => LogLevel::NOTICE, 'failed_level' => null], + defaultLogLevels: ['success_level' => LogLevel::DEBUG] + )->execute('test.process'); + + self::assertSame([[LogLevel::NOTICE, 'Process test.process succeed']], $this->getEndRecords()); + } + + public function testSkippedErrorDoesNotFailTheProcess(): void + { + $this->createProcessManager('@test.failing', 'skip')->execute('test.process'); + + self::assertSame([[LogLevel::INFO, 'Process test.process succeed']], $this->getEndRecords()); + } + + public function testStoppedProcessIsLoggedAsFailedAsDebugByDefault(): void + { + $this->createProcessManager('@test.stop')->execute('test.process'); + + self::assertSame([[LogLevel::DEBUG, 'Process test.process failed']], $this->getEndRecords()); + } + + public function testStoppedProcessUsesTheDefaultFailedLevel(): void + { + $this->createProcessManager('@test.stop', defaultLogLevels: ['failed_level' => LogLevel::ERROR]) + ->execute('test.process'); + + self::assertSame([[LogLevel::ERROR, 'Process test.process failed']], $this->getEndRecords()); + } + + public function testFailingProcessIsLoggedAsFailedWithTheLevelOfTheProcess(): void + { + $processManager = $this->createProcessManager( + '@test.failing', + processLogLevels: ['success_level' => null, 'failed_level' => LogLevel::WARNING], + defaultLogLevels: ['failed_level' => LogLevel::ERROR] + ); + + try { + $processManager->execute('test.process'); + self::fail('The process should have failed'); + } catch (ProcessFailedException) { + } + + self::assertSame([[LogLevel::WARNING, 'Process test.process failed']], $this->getEndRecords()); + } + + /** + * @param array{success_level: ?string, failed_level: ?string}|null $processLogLevels + * @param array{success_level?: string, failed_level?: string} $defaultLogLevels + */ + private function createProcessManager( + string $service, + string $errorStrategy = 'stop', + ?array $processLogLevels = null, + array $defaultLogLevels = [], + ): ProcessManager { + $failingTask = new class implements TaskInterface { + public function execute(ProcessState $state): void + { + throw new \LogicException('Something went wrong'); + } + }; + + $container = new Container(); + $container->set('test.constant', new ConstantOutputTask()); + $container->set('test.failing', $failingTask); + $container->set('test.stop', new StopTask()); + + $rawConfiguration = [ + 'options' => [], + 'entry_point' => null, + 'end_point' => null, + 'description' => '', + 'help' => '', + 'public' => true, + 'tasks' => [ + 'entry' => $this->createTaskConfiguration('@test.constant', ['output' => 'value'], ['last']), + 'last' => $this->createTaskConfiguration($service, errorStrategy: $errorStrategy), + ], + ]; + if (null !== $processLogLevels) { + $rawConfiguration['logs'] = $processLogLevels; + } + + $logger = new class(function (string $level, string $message, array $context): void { + $this->records[] = [$level, $message, $context]; + }) extends PsrAbstractLogger { + public function __construct( + private readonly \Closure $collector, + ) { + } + + /** + * @param array $context + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + ($this->collector)((string) $level, (string) $message, $context); + } + }; + + return new ProcessManager( + $container, + new ProcessLogger($logger), + new TaskLogger(new NullLogger()), + new ProcessConfigurationRegistry(['test.process' => $rawConfiguration], 'stop', $defaultLogLevels), + new ContextualOptionResolver(), + new EventDispatcher(), + ); + } + + /** + * Level and message of the end of process logs, which must have an integer duration. + * + * @return list + */ + private function getEndRecords(): array + { + $endRecords = []; + foreach ($this->records as [$level, $message, $context]) { + if (str_ends_with($message, ' succeed') || str_ends_with($message, ' failed')) { + self::assertIsInt($context['duration'] ?? null); + $endRecords[] = [$level, $message]; + } + } + + return $endRecords; + } + + /** + * @param array $options + * @param list $outputs + * + * @return array + */ + private function createTaskConfiguration( + string $service, + array $options = [], + array $outputs = [], + ?string $errorStrategy = null, + ): array { + return [ + 'service' => $service, + 'options' => $options, + 'description' => '', + 'help' => '', + 'outputs' => $outputs, + 'errors' => [], + 'error_outputs' => [], + 'error_strategy' => $errorStrategy, + 'log_level' => null, + ]; + } +}