From e2da2a1c2434f8fde2c59d94189c9f79949a3b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Fri, 17 Apr 2026 15:03:12 +0200 Subject: [PATCH 1/6] Add a new bridge with Monolog that allows JobExecution log to file --- composer.json | 10 +- phpstan.neon | 1 + src/batch-monolog/.gitattributes | 8 + .../.github/workflows/lockdown.yml | 27 ++++ src/batch-monolog/.gitignore | 4 + src/batch-monolog/LICENSE | 19 +++ src/batch-monolog/README.md | 41 +++++ src/batch-monolog/composer.json | 32 ++++ src/batch-monolog/phpunit.xml | 25 +++ .../src/StreamJobExecutionLogger.php | 84 ++++++++++ .../src/StreamJobExecutionLoggerFactory.php | 51 +++++++ .../StreamJobExecutionLoggerFactoryTest.php | 136 +++++++++++++++++ .../tests/StreamJobExecutionLoggerTest.php | 144 ++++++++++++++++++ src/batch/composer.json | 3 +- src/batch/src/Factory/JobExecutionFactory.php | 2 +- .../InMemoryJobExecutionLoggerFactory.php | 6 +- .../NullJobExecutionLoggerFactory.php | 2 +- .../JobExecutionLoggerFactoryInterface.php | 2 +- .../InMemoryJobExecutionLoggerFactoryTest.php | 12 +- .../NullJobExecutionLoggerFactoryTest.php | 2 +- 20 files changed, 593 insertions(+), 18 deletions(-) create mode 100644 src/batch-monolog/.gitattributes create mode 100644 src/batch-monolog/.github/workflows/lockdown.yml create mode 100644 src/batch-monolog/.gitignore create mode 100644 src/batch-monolog/LICENSE create mode 100644 src/batch-monolog/README.md create mode 100644 src/batch-monolog/composer.json create mode 100644 src/batch-monolog/phpunit.xml create mode 100644 src/batch-monolog/src/StreamJobExecutionLogger.php create mode 100644 src/batch-monolog/src/StreamJobExecutionLoggerFactory.php create mode 100644 src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php create mode 100644 src/batch-monolog/tests/StreamJobExecutionLoggerTest.php diff --git a/composer.json b/composer.json index 1f42fcbf..47791ad9 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "doctrine/orm": "^3.0", "doctrine/persistence": "^4.0", "league/flysystem": "^3.0", + "monolog/monolog": "^3.0", "openspout/openspout": "^4.0", "psr/container": "^1.0", "psr/event-dispatcher": "^1.0", @@ -63,7 +64,8 @@ "yokai/batch-symfony-messenger": "self.version", "yokai/batch-symfony-serializer": "self.version", "yokai/batch-symfony-uid": "self.version", - "yokai/batch-symfony-validator": "self.version" + "yokai/batch-symfony-validator": "self.version", + "yokai/batch-monolog": "self.version" }, "autoload": { "psr-4": { @@ -78,7 +80,8 @@ "Yokai\\Batch\\Bridge\\Symfony\\Messenger\\": "src/batch-symfony-messenger/src/", "Yokai\\Batch\\Bridge\\Symfony\\Serializer\\": "src/batch-symfony-serializer/src/", "Yokai\\Batch\\Bridge\\Symfony\\Uid\\": "src/batch-symfony-uid/src/", - "Yokai\\Batch\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/src/" + "Yokai\\Batch\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/src/", + "Yokai\\Batch\\Bridge\\Monolog\\": "src/batch-monolog/src/" } }, "autoload-dev": { @@ -98,7 +101,8 @@ "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Messenger\\": "src/batch-symfony-messenger/tests/", "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Serializer\\": "src/batch-symfony-serializer/tests/", "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Uid\\": "src/batch-symfony-uid/tests/", - "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/tests/" + "Yokai\\Batch\\Tests\\Bridge\\Symfony\\Validator\\": "src/batch-symfony-validator/tests/", + "Yokai\\Batch\\Tests\\Bridge\\Monolog\\": "src/batch-monolog/tests/" } } } diff --git a/phpstan.neon b/phpstan.neon index 1c6f6faf..575f0b05 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,3 +15,4 @@ parameters: - src/batch-symfony-messenger/src/ - src/batch-symfony-serializer/src/ - src/batch-symfony-validator/src/ + - src/batch-monolog/src/ diff --git a/src/batch-monolog/.gitattributes b/src/batch-monolog/.gitattributes new file mode 100644 index 00000000..c75a9010 --- /dev/null +++ b/src/batch-monolog/.gitattributes @@ -0,0 +1,8 @@ +.github export-ignore +.gitattributes export-ignore +.gitignore export-ignore +docs/ export-ignore +tests/ export-ignore +LICENSE export-ignore +phpunit.xml export-ignore +*.md export-ignore diff --git a/src/batch-monolog/.github/workflows/lockdown.yml b/src/batch-monolog/.github/workflows/lockdown.yml new file mode 100644 index 00000000..c97070ec --- /dev/null +++ b/src/batch-monolog/.github/workflows/lockdown.yml @@ -0,0 +1,27 @@ +name: 'Lock down Pull Requests' + +on: + pull_request: + types: opened + +jobs: + lockdown: + runs-on: ubuntu-latest + steps: + - uses: dessant/repo-lockdown@v4 + with: + github-token: ${{ github.token }} + close-pr: true + lock-pr: true + pr-comment: > + Thanks for your pull request! + + However, this repository does not accept pull requests, + see the README for details. + + If you want to contribute, + you should instead open a pull request on the main repository: + + https://github.com/yokai-php/batch-src + + Thank you diff --git a/src/batch-monolog/.gitignore b/src/batch-monolog/.gitignore new file mode 100644 index 00000000..88259615 --- /dev/null +++ b/src/batch-monolog/.gitignore @@ -0,0 +1,4 @@ +/.phpunit.result.cache +/tests/.artifacts/ +/vendor/ +/composer.lock diff --git a/src/batch-monolog/LICENSE b/src/batch-monolog/LICENSE new file mode 100644 index 00000000..2a58154b --- /dev/null +++ b/src/batch-monolog/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2026 Yann Eugoné + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/batch-monolog/README.md b/src/batch-monolog/README.md new file mode 100644 index 00000000..cf761edb --- /dev/null +++ b/src/batch-monolog/README.md @@ -0,0 +1,41 @@ +# monolog/monolog bridge for Batch processing library + +[![Latest Stable Version](https://img.shields.io/packagist/v/yokai/batch-monolog?style=flat-square)](https://packagist.org/packages/yokai/batch-monolog) +[![Downloads Monthly](https://img.shields.io/packagist/dm/yokai/batch-monolog?style=flat-square)](https://packagist.org/packages/yokai/batch-monolog) + +[`monolog/monolog`](https://github.com/Seldaek/monolog) bridge for [Batch](https://github.com/yokai-php/batch) processing library. + + +# Installation + +``` +composer require yokai/batch-monolog +``` + + +## Documentation + +Please read the [dedicated documentation page](https://yokai-batch.readthedocs.io/en/1.x/bridges/monolog.html). + +This package provides: + +- an [job execution logger factory](https://github.com/yokai-php/batch-monolog/blob/1.x/src/StreamJobExecutionLoggerFactory.php) that create a local file logger +- an [job execution logger](https://github.com/yokai-php/batch-monolog/blob/1.x/src/StreamJobExecutionLogger.php) that write job logs to a local file + + +## Contribution + +This package is a readonly split of a [larger repository](https://github.com/yokai-php/batch-src), +containing all tests and sources for all librairies of the batch universe. + +Please feel free to open an [issue](https://github.com/yokai-php/batch-src/issues) +or a [pull request](https://github.com/yokai-php/batch-src/pulls) +in the [main repository](https://github.com/yokai-php/batch-src). + +The library was originally created by [Yann Eugoné](https://github.com/yann-eugone). +See the list of [contributors](https://github.com/yokai-php/batch-src/contributors). + + +## License + +This library is under MIT [LICENSE](LICENSE). diff --git a/src/batch-monolog/composer.json b/src/batch-monolog/composer.json new file mode 100644 index 00000000..7b53dd6c --- /dev/null +++ b/src/batch-monolog/composer.json @@ -0,0 +1,32 @@ +{ + "name": "yokai/batch-monolog", + "description": "monolog/monolog bridge for yokai/batch", + "keywords": ["batch", "job", "logging", "monolog"], + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Yann Eugoné", + "email": "eugone.yann@gmail.com" + } + ], + "require": { + "php": "^8.2", + "monolog/monolog": "^3.0", + "psr/log": "^1.0|^2.0|^3.0", + "yokai/batch": "^1.0.0" + }, + "autoload": { + "psr-4": { + "Yokai\\Batch\\Bridge\\Monolog\\": "src/" + } + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "autoload-dev": { + "psr-4": { + "Yokai\\Batch\\Tests\\Bridge\\Monolog\\": "tests/" + } + } +} diff --git a/src/batch-monolog/phpunit.xml b/src/batch-monolog/phpunit.xml new file mode 100644 index 00000000..f2bbace6 --- /dev/null +++ b/src/batch-monolog/phpunit.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + ./tests + + + + + + ./src + + + diff --git a/src/batch-monolog/src/StreamJobExecutionLogger.php b/src/batch-monolog/src/StreamJobExecutionLogger.php new file mode 100644 index 00000000..bdd7d50c --- /dev/null +++ b/src/batch-monolog/src/StreamJobExecutionLogger.php @@ -0,0 +1,84 @@ + $processors Monolog processors applied to every log record. + * @param FormatterInterface|null $formatter Optional Monolog formatter for the stream handler. + */ + public function __construct( + private readonly string $absolutePath, + private readonly string $reference, + array $processors = [], + FormatterInterface|null $formatter = null, + ) { + $handler = new StreamHandler($absolutePath); + if ($formatter !== null) { + $handler->setFormatter($formatter); + } + $this->logger = new Logger('yokai/batch', [$handler], $processors); + } + + /** + * @param Level|LogLevel::* $level + */ + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->logger->log($level, $message, $context); + } + + public function getReference(): string + { + return $this->reference; + } + + public function getLogs(): iterable + { + $handle = @\fopen($this->absolutePath, 'r'); + if ($handle === false) { + return; + } + + try { + while (($line = \fgets($handle)) !== false) { + yield \rtrim($line, "\n\r"); + } + } finally { + \fclose($handle); + } + } + + public function getLogsContent(): string + { + $contents = @\file_get_contents($this->absolutePath); + if ($contents === false) { + return ''; + } + + return $contents; + } +} diff --git a/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php b/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php new file mode 100644 index 00000000..7890e92d --- /dev/null +++ b/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php @@ -0,0 +1,51 @@ + $processors Monolog processors added to every logger instance. + * @param FormatterInterface|null $formatter Optional Monolog formatter applied to the stream handler. + */ + public function __construct( + private string $directory, + private array $processors = [], + private FormatterInterface|null $formatter = null, + ) { + } + + public function create(string $jobExecutionId): JobExecutionLoggerInterface + { + return $this->createLogger("{$jobExecutionId}.log"); + } + + public function restore(string $logsReference): JobExecutionLoggerInterface + { + return $this->createLogger($logsReference); + } + + private function createLogger(string $logsReference): StreamJobExecutionLogger + { + return new StreamJobExecutionLogger( + absolutePath: $this->directory . \DIRECTORY_SEPARATOR . $logsReference, + reference: $logsReference, + processors: $this->processors, + formatter: $this->formatter, + ); + } +} diff --git a/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php b/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php new file mode 100644 index 00000000..64c03d46 --- /dev/null +++ b/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php @@ -0,0 +1,136 @@ +tmpDir = \sys_get_temp_dir() . '/yokai-batch-monolog-factory-test-' . \uniqid(); + \mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + foreach (\glob($this->tmpDir . '/*') ?: [] as $file) { + \unlink($file); + } + \rmdir($this->tmpDir); + } + + public function testCreateReturnsStreamJobExecutionLogger(): void + { + $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); + + self::assertInstanceOf(StreamJobExecutionLogger::class, $factory->create('exec-1')); + } + + public function testCreateUsesJobExecutionIdAsRelativeReference(): void + { + $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); + + $logger = $factory->create('exec-abc'); + + self::assertSame('exec-abc.log', $logger->getReference()); + } + + public function testCreateGeneratesDistinctReferencePerExecutionId(): void + { + $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); + + $first = $factory->create('exec-1'); + $second = $factory->create('exec-2'); + + self::assertNotSame($first->getReference(), $second->getReference()); + } + + public function testCreatedLoggerIsInitiallyEmpty(): void + { + $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); + + $logger = $factory->create('exec-empty'); + + self::assertSame([], \iterator_to_array($logger->getLogs())); + self::assertSame('', $logger->getLogsContent()); + } + + public function testCreateCreatesDirectoryIfMissing(): void + { + $nestedDir = $this->tmpDir . '/nested/dir'; + $factory = new StreamJobExecutionLoggerFactory($nestedDir); + + $logger = $factory->create('exec-1'); + $logger->info('hello'); + + self::assertDirectoryExists($nestedDir); + + \unlink($nestedDir . '/exec-1.log'); + \rmdir($nestedDir); + \rmdir(\dirname($nestedDir)); + } + + public function testRestoreReturnsLoggerWithGivenReference(): void + { + $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); + + $logger = $factory->restore('existing.log'); + + self::assertSame('existing.log', $logger->getReference()); + } + + public function testRestoreReadsExistingFileContent(): void + { + $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); + + \file_put_contents($this->tmpDir . '/existing.log', "line one\nline two\n"); + + $logger = $factory->restore('existing.log'); + + self::assertStringContainsString('line one', $logger->getLogsContent()); + self::assertStringContainsString('line two', $logger->getLogsContent()); + } + + public function testProcessorIsPassedToLogger(): void + { + $factory = new StreamJobExecutionLoggerFactory( + $this->tmpDir, + [new PsrLogMessageProcessor()], + ); + + $logger = $factory->create('exec-proc'); + $logger->info('value is {val}', ['val' => 'hello']); + + self::assertStringContainsString('value is hello', $logger->getLogsContent()); + + \unlink($this->tmpDir . '/exec-proc.log'); + } + + public function testFormatterIsPassedToLogger(): void + { + $factory = new StreamJobExecutionLoggerFactory( + $this->tmpDir, + [], + new JsonFormatter(), + ); + + $logger = $factory->create('exec-fmt'); + $logger->info('json log'); + + $decoded = \json_decode($logger->getLogsContent(), true); + + self::assertIsArray($decoded); + self::assertSame('json log', $decoded['message']); + + \unlink($this->tmpDir . '/exec-fmt.log'); + } +} diff --git a/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php b/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php new file mode 100644 index 00000000..8533e2f3 --- /dev/null +++ b/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php @@ -0,0 +1,144 @@ +tmpDir = \sys_get_temp_dir() . '/yokai-batch-monolog-test-' . \uniqid(); + \mkdir($this->tmpDir, 0755, true); + } + + protected function tearDown(): void + { + foreach (\glob($this->tmpDir . '/*') ?: [] as $file) { + \unlink($file); + } + \rmdir($this->tmpDir); + } + + public function testGetReferenceReturnsTheGivenReference(): void + { + $logger = $this->createLogger('test.log'); + + self::assertSame('test.log', $logger->getReference()); + } + + public function testGetReferenceDoesNotReturnAbsolutePath(): void + { + $logger = $this->createLogger('exec-abc.log'); + + self::assertStringNotContainsString($this->tmpDir, $logger->getReference()); + } + + public function testGetLogsIsEmptyWhenFileDoesNotExist(): void + { + $path = $this->tmpDir . '/nonexistent.log'; + $logger = new StreamJobExecutionLogger($path, 'nonexistent.log'); + + self::assertSame([], \iterator_to_array($logger->getLogs())); + } + + public function testGetLogsContentIsEmptyWhenFileDoesNotExist(): void + { + $path = $this->tmpDir . '/nonexistent.log'; + $logger = new StreamJobExecutionLogger($path, 'nonexistent.log'); + + self::assertSame('', $logger->getLogsContent()); + } + + public function testLogWritesToFile(): void + { + $logger = $this->createLogger('test.log'); + + $logger->info('hello world'); + + self::assertFileExists($this->tmpDir . '/test.log'); + self::assertStringContainsString('hello world', $logger->getLogsContent()); + } + + public function testGetLogsYieldsLines(): void + { + $logger = $this->createLogger('test.log'); + + $logger->info('first message'); + $logger->warning('second message'); + + $lines = \iterator_to_array($logger->getLogs()); + + self::assertCount(2, $lines); + self::assertStringContainsString('first message', $lines[0]); + self::assertStringContainsString('second message', $lines[1]); + } + + public function testGetLogsContentReturnsFullContent(): void + { + $logger = $this->createLogger('test.log'); + + $logger->error('something failed'); + + self::assertStringContainsString('something failed', $logger->getLogsContent()); + } + + public function testGetLogsIsLazy(): void + { + $logger = $this->createLogger('test.log'); + + $logger->debug('line one'); + $logger->debug('line two'); + $logger->debug('line three'); + + $first = null; + foreach ($logger->getLogs() as $line) { + $first = $line; + break; + } + + self::assertNotNull($first); + self::assertStringContainsString('line one', $first); + } + + public function testProcessorIsApplied(): void + { + $logger = $this->createLogger('test.log', [new PsrLogMessageProcessor()]); + + $logger->info('value is {val}', ['val' => 'hello']); + + self::assertStringContainsString('value is hello', $logger->getLogsContent()); + } + + public function testFormatterIsApplied(): void + { + $logger = $this->createLogger('test.log', [], new JsonFormatter()); + + $logger->info('json log'); + + $decoded = \json_decode($logger->getLogsContent(), true); + + self::assertIsArray($decoded); + self::assertSame('json log', $decoded['message']); + } + + private function createLogger( + string $filename, + array $processors = [], + mixed $formatter = null, + ): StreamJobExecutionLogger { + return new StreamJobExecutionLogger( + $this->tmpDir . '/' . $filename, + $filename, + $processors, + $formatter, + ); + } +} diff --git a/src/batch/composer.json b/src/batch/composer.json index c176f5ec..b91034c7 100644 --- a/src/batch/composer.json +++ b/src/batch/composer.json @@ -38,6 +38,7 @@ "yokai/batch-symfony-framework": "Integrate to Symfony framework via a bundle", "yokai/batch-symfony-messenger": "Trigger jobs using message dispatch", "yokai/batch-symfony-serializer": "Process items using (de)normalization, serialize job execution for certain storages", - "yokai/batch-symfony-validator": "Skip invalid items during process" + "yokai/batch-symfony-validator": "Skip invalid items during process", + "yokai/batch-monolog": "Write job execution logs to a file via Monolog" } } diff --git a/src/batch/src/Factory/JobExecutionFactory.php b/src/batch/src/Factory/JobExecutionFactory.php index 0a40e2ba..786e3b7b 100644 --- a/src/batch/src/Factory/JobExecutionFactory.php +++ b/src/batch/src/Factory/JobExecutionFactory.php @@ -35,7 +35,7 @@ public function create(string $name, array $configuration = []): JobExecution id: $id, jobName: $name, parameters: new JobParameters($configuration), - logger: $this->loggerFactory->create(), + logger: $this->loggerFactory->create($id), ); $jobExecution->setLaunchedAt(new DateTimeImmutable()); diff --git a/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php b/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php index 34f5a937..50e3570b 100644 --- a/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php +++ b/src/batch/src/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactory.php @@ -5,19 +5,17 @@ namespace Yokai\Batch\Factory\JobExecutionLoggerFactory; use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface; -use Yokai\Batch\JobExecutionLogs; use Yokai\Batch\Logger\InMemoryJobExecutionLogger; use Yokai\Batch\Logger\JobExecutionLoggerInterface; /** * Default {@see JobExecutionLoggerFactoryInterface} implementation. * - * Creates a {@see InMemoryJobExecutionLogger} backed by an in-memory {@see JobExecutionLogs} string. - * This preserves the behaviour that existed before the logger became pluggable. + * Creates an {@see InMemoryJobExecutionLogger}, preserving the behaviour that existed before the logger became pluggable. */ final class InMemoryJobExecutionLoggerFactory implements JobExecutionLoggerFactoryInterface { - public function create(): JobExecutionLoggerInterface + public function create(string $jobExecutionId): JobExecutionLoggerInterface { return new InMemoryJobExecutionLogger(); } diff --git a/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php b/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php index 5892e465..fa8141d2 100644 --- a/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php +++ b/src/batch/src/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactory.php @@ -15,7 +15,7 @@ */ final class NullJobExecutionLoggerFactory implements JobExecutionLoggerFactoryInterface { - public function create(): JobExecutionLoggerInterface + public function create(string $jobExecutionId): JobExecutionLoggerInterface { return new NullJobExecutionLogger(); } diff --git a/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php b/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php index c4d51ebb..96c7d81a 100644 --- a/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php +++ b/src/batch/src/Factory/JobExecutionLoggerFactoryInterface.php @@ -18,7 +18,7 @@ interface JobExecutionLoggerFactoryInterface /** * Creates a fresh logger for a new job execution. */ - public function create(): JobExecutionLoggerInterface; + public function create(string $jobExecutionId): JobExecutionLoggerInterface; /** * Restores a logger from a previously stored reference. diff --git a/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php b/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php index 486762e0..a0a60382 100644 --- a/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php +++ b/src/batch/tests/Factory/JobExecutionLoggerFactory/InMemoryJobExecutionLoggerFactoryTest.php @@ -14,7 +14,7 @@ public function testCreatesJobExecutionLogger(): void { $factory = new InMemoryJobExecutionLoggerFactory(); - $logger = $factory->create(); + $logger = $factory->create('job-execution-id'); self::assertInstanceOf(InMemoryJobExecutionLogger::class, $logger); } @@ -23,8 +23,8 @@ public function testCreatesDistinctInstancesEachCall(): void { $factory = new InMemoryJobExecutionLoggerFactory(); - $first = $factory->create(); - $second = $factory->create(); + $first = $factory->create('job-execution-id'); + $second = $factory->create('job-execution-id'); self::assertNotSame($first, $second); } @@ -33,7 +33,7 @@ public function testLogsAreInitiallyEmpty(): void { $factory = new InMemoryJobExecutionLoggerFactory(); - $logger = $factory->create(); + $logger = $factory->create('job-execution-id'); self::assertSame('', $logger->getReference()); self::assertSame([], \iterator_to_array($logger->getLogs())); @@ -43,8 +43,8 @@ public function testLogsAreIsolatedBetweenInstances(): void { $factory = new InMemoryJobExecutionLoggerFactory(); - $first = $factory->create(); - $second = $factory->create(); + $first = $factory->create('job-execution-id'); + $second = $factory->create('job-execution-id'); $first->info('message for first'); diff --git a/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php b/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php index a9d6e8ed..af76999d 100644 --- a/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php +++ b/src/batch/tests/Factory/JobExecutionLoggerFactory/NullJobExecutionLoggerFactoryTest.php @@ -14,7 +14,7 @@ public function testCreateReturnsNullLogger(): void { $factory = new NullJobExecutionLoggerFactory(); - self::assertInstanceOf(NullJobExecutionLogger::class, $factory->create()); + self::assertInstanceOf(NullJobExecutionLogger::class, $factory->create('job-execution-id')); } public function testRestoreReturnsNullLoggerRegardlessOfReference(): void From 362723d94065825fdd842c4d93a1d39fedc7756b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Fri, 17 Apr 2026 15:50:06 +0200 Subject: [PATCH 2/6] Allow log files to be stored in subdirectories --- .../src/StreamJobExecutionLoggerFactory.php | 42 ++++++++--- .../StreamJobExecutionLoggerFactoryTest.php | 70 +++++++++++++------ .../tests/StreamJobExecutionLoggerTest.php | 53 ++++++++++++-- 3 files changed, 129 insertions(+), 36 deletions(-) diff --git a/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php b/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php index 7890e92d..c20096a6 100644 --- a/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php +++ b/src/batch-monolog/src/StreamJobExecutionLoggerFactory.php @@ -18,34 +18,60 @@ final readonly class StreamJobExecutionLoggerFactory implements JobExecutionLoggerFactoryInterface { /** - * @param string $directory Directory where log files are stored. - * @param list $processors Monolog processors added to every logger instance. - * @param FormatterInterface|null $formatter Optional Monolog formatter applied to the stream handler. + * @param string $directory Directory where log files are stored. + * @param list $processors Monolog processors added to every logger instance. + * @param FormatterInterface|null $formatter Optional Monolog formatter applied to the stream handler. + * @param int $subDirectories Number of subdirectory levels to create from the job execution id. + * @param int $charsPerDirectory Number of characters from the job execution id used per subdirectory level. */ public function __construct( private string $directory, private array $processors = [], private FormatterInterface|null $formatter = null, + private int $subDirectories = 0, + private int $charsPerDirectory = 0, ) { } public function create(string $jobExecutionId): JobExecutionLoggerInterface { - return $this->createLogger("{$jobExecutionId}.log"); + $subdir = $this->subdir($jobExecutionId); + $reference = ($subdir !== '' ? $subdir . \DIRECTORY_SEPARATOR : '') . $jobExecutionId . '.log'; + + return $this->buildLogger($reference); } public function restore(string $logsReference): JobExecutionLoggerInterface { - return $this->createLogger($logsReference); + return $this->buildLogger($logsReference); } - private function createLogger(string $logsReference): StreamJobExecutionLogger + private function buildLogger(string $reference): StreamJobExecutionLogger { return new StreamJobExecutionLogger( - absolutePath: $this->directory . \DIRECTORY_SEPARATOR . $logsReference, - reference: $logsReference, + absolutePath: $this->directory . \DIRECTORY_SEPARATOR . $reference, + reference: $reference, processors: $this->processors, formatter: $this->formatter, ); } + + /** + * Splits the job execution id into subdirectory segments based on configured depth and segment length. + * For example, with subDirectories=2 and charsPerDirectory=2, the id + * 60996f72-4f54-4184-9268-35ffdecf0de6 + * is stored at: + * └─ 60/ + * └─ 99/ + * └─ 60996f72-4f54-4184-9268-35ffdecf0de6.log + */ + private function subdir(string $id): string + { + $parts = []; + for ($i = 0, $start = 0; $i < $this->subDirectories; $i++, $start += $this->charsPerDirectory) { + $parts[] = \substr($id, $start, $this->charsPerDirectory); + } + + return \implode(\DIRECTORY_SEPARATOR, $parts); + } } diff --git a/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php b/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php index 64c03d46..7299cb3e 100644 --- a/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php +++ b/src/batch-monolog/tests/StreamJobExecutionLoggerFactoryTest.php @@ -7,6 +7,7 @@ use Monolog\Formatter\JsonFormatter; use Monolog\Processor\PsrLogMessageProcessor; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLogger; use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory; @@ -16,16 +17,13 @@ final class StreamJobExecutionLoggerFactoryTest extends TestCase protected function setUp(): void { - $this->tmpDir = \sys_get_temp_dir() . '/yokai-batch-monolog-factory-test-' . \uniqid(); + $this->tmpDir = ARTIFACT_DIR . '/stream-job-execution-logger-factory-' . \uniqid(); \mkdir($this->tmpDir, 0755, true); } protected function tearDown(): void { - foreach (\glob($this->tmpDir . '/*') ?: [] as $file) { - \unlink($file); - } - \rmdir($this->tmpDir); + (new Filesystem())->remove($this->tmpDir); } public function testCreateReturnsStreamJobExecutionLogger(): void @@ -64,21 +62,6 @@ public function testCreatedLoggerIsInitiallyEmpty(): void self::assertSame('', $logger->getLogsContent()); } - public function testCreateCreatesDirectoryIfMissing(): void - { - $nestedDir = $this->tmpDir . '/nested/dir'; - $factory = new StreamJobExecutionLoggerFactory($nestedDir); - - $logger = $factory->create('exec-1'); - $logger->info('hello'); - - self::assertDirectoryExists($nestedDir); - - \unlink($nestedDir . '/exec-1.log'); - \rmdir($nestedDir); - \rmdir(\dirname($nestedDir)); - } - public function testRestoreReturnsLoggerWithGivenReference(): void { $factory = new StreamJobExecutionLoggerFactory($this->tmpDir); @@ -111,8 +94,6 @@ public function testProcessorIsPassedToLogger(): void $logger->info('value is {val}', ['val' => 'hello']); self::assertStringContainsString('value is hello', $logger->getLogsContent()); - - \unlink($this->tmpDir . '/exec-proc.log'); } public function testFormatterIsPassedToLogger(): void @@ -130,7 +111,50 @@ public function testFormatterIsPassedToLogger(): void self::assertIsArray($decoded); self::assertSame('json log', $decoded['message']); + } + + public function testCreateWithSubDirectoriesBuildsNestedReference(): void + { + $factory = new StreamJobExecutionLoggerFactory( + $this->tmpDir, + subDirectories: 2, + charsPerDirectory: 2, + ); + + $logger = $factory->create('60996f72-4f54-4184-9268-35ffdecf0de6'); + + self::assertSame('60/99/60996f72-4f54-4184-9268-35ffdecf0de6.log', $logger->getReference()); + } + + public function testCreateWithSubDirectoriesWritesFileInSubdirectory(): void + { + $factory = new StreamJobExecutionLoggerFactory( + $this->tmpDir, + subDirectories: 2, + charsPerDirectory: 2, + ); + + $logger = $factory->create('60996f72-4f54-4184-9268-35ffdecf0de6'); + $logger->info('nested log'); + + self::assertFileExists($this->tmpDir . '/60/99/60996f72-4f54-4184-9268-35ffdecf0de6.log'); + self::assertStringContainsString('nested log', $logger->getLogsContent()); + } + + public function testRestoreWithSubDirectoryReferenceReadsFileContent(): void + { + $factory = new StreamJobExecutionLoggerFactory( + $this->tmpDir, + subDirectories: 2, + charsPerDirectory: 2, + ); + + \mkdir($this->tmpDir . '/60/99', 0755, true); + \file_put_contents($this->tmpDir . '/60/99/exec.log', "restored line\n"); + + $logger = $factory->restore('60/99/exec.log'); - \unlink($this->tmpDir . '/exec-fmt.log'); + self::assertSame('60/99/exec.log', $logger->getReference()); + self::assertStringContainsString('restored line', $logger->getLogsContent()); } } diff --git a/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php b/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php index 8533e2f3..1c62935a 100644 --- a/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php +++ b/src/batch-monolog/tests/StreamJobExecutionLoggerTest.php @@ -7,6 +7,7 @@ use Monolog\Formatter\JsonFormatter; use Monolog\Processor\PsrLogMessageProcessor; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLogger; final class StreamJobExecutionLoggerTest extends TestCase @@ -15,16 +16,13 @@ final class StreamJobExecutionLoggerTest extends TestCase protected function setUp(): void { - $this->tmpDir = \sys_get_temp_dir() . '/yokai-batch-monolog-test-' . \uniqid(); + $this->tmpDir = ARTIFACT_DIR . '/stream-job-execution-logger-' . \uniqid(); \mkdir($this->tmpDir, 0755, true); } protected function tearDown(): void { - foreach (\glob($this->tmpDir . '/*') ?: [] as $file) { - \unlink($file); - } - \rmdir($this->tmpDir); + (new Filesystem())->remove($this->tmpDir); } public function testGetReferenceReturnsTheGivenReference(): void @@ -129,11 +127,56 @@ public function testFormatterIsApplied(): void self::assertSame('json log', $decoded['message']); } + public function testGetReferenceReturnsNestedReferenceAsIs(): void + { + $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99'); + + self::assertSame('60/99/exec-1.log', $logger->getReference()); + } + + public function testLogWritesToFileInSubdirectory(): void + { + $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99'); + + $logger->info('nested message'); + + self::assertFileExists($this->tmpDir . '/60/99/exec-1.log'); + self::assertStringContainsString('nested message', $logger->getLogsContent()); + } + + public function testGetLogsReadsFromNestedPath(): void + { + $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99'); + + $logger->info('first line'); + $logger->warning('second line'); + + $lines = \iterator_to_array($logger->getLogs()); + + self::assertCount(2, $lines); + self::assertStringContainsString('first line', $lines[0]); + self::assertStringContainsString('second line', $lines[1]); + } + + public function testGetLogsContentReadsFromNestedPath(): void + { + $logger = $this->createLogger('60/99/exec-1.log', subdir: '60/99'); + + $logger->error('nested error'); + + self::assertStringContainsString('nested error', $logger->getLogsContent()); + } + private function createLogger( string $filename, array $processors = [], mixed $formatter = null, + string $subdir = '', ): StreamJobExecutionLogger { + if ($subdir !== '') { + \mkdir($this->tmpDir . '/' . $subdir, 0755, true); + } + return new StreamJobExecutionLogger( $this->tmpDir . '/' . $filename, $filename, From 3eca6e9eb49c9a78d7585e4af801f58cf13326b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Fri, 17 Apr 2026 16:19:44 +0200 Subject: [PATCH 3/6] Configure logging within the Symfony bundle --- .../src/DependencyInjection/Configuration.php | 56 +++++++++++ .../YokaiBatchExtension.php | 50 ++++++++++ .../src/Resources/services/core.php | 3 - .../YokaiBatchExtensionTest.php | 94 +++++++++++++++++++ 4 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php index ec2b0288..49fda934 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/Configuration.php +++ b/src/batch-symfony-framework/src/DependencyInjection/Configuration.php @@ -20,6 +20,18 @@ * parameters: ParametersConfig, * id: string, * ui: UserInterfaceConfig, + * logging: LoggingConfig, + * } + * @phpstan-type LoggingConfig array{ + * type: 'memory'|'null'|'stream'|'service', + * service: string|null, + * stream: array{ + * directory: string, + * sub_directories: int, + * chars_per_directory: int, + * processors: list, + * formatter: string|null, + * }, * } * @phpstan-type StorageConfig array{ * dsn?: string, @@ -80,6 +92,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->append($this->parameters()) ->append($this->id()) ->append($this->ui()) + ->append($this->logging()) ->end() ; @@ -318,4 +331,47 @@ private function ui(): ArrayNodeDefinition return $node; } + + private function logging(): ArrayNodeDefinition + { + /** @var ArrayNodeDefinition $node */ + $node = (new TreeBuilder('logging'))->getRootNode(); + + $node + ->addDefaultsIfNotSet() + ->children() + ->enumNode('type') + ->values(['memory', 'null', 'stream', 'service']) + ->defaultValue('memory') + ->end() + ->scalarNode('service') + ->defaultNull() + ->end() + ->arrayNode('stream') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('directory') + ->defaultValue('%kernel.logs_dir%/batch') + ->end() + ->integerNode('sub_directories') + ->defaultValue(0) + ->min(0) + ->end() + ->integerNode('chars_per_directory') + ->defaultValue(0) + ->min(0) + ->end() + ->arrayNode('processors') + ->scalarPrototype()->end() + ->end() + ->scalarNode('formatter') + ->defaultNull() + ->end() + ->end() + ->end() + ->end() + ; + + return $node; + } } diff --git a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php index 5fa2cae3..c6afea14 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php +++ b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php @@ -18,12 +18,15 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALJobExecutionStorage; +use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Form\JobFilterType; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\PaginationConfiguration; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\SonataAdminTemplating; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\TemplatingInterface; use Yokai\Batch\Factory\JobExecutionIdGeneratorInterface; +use Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory; +use Yokai\Batch\Factory\JobExecutionLoggerFactory\NullJobExecutionLoggerFactory; use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface; use Yokai\Batch\Factory\JobExecutionParametersBuilder\PerJobJobExecutionParametersBuilder; use Yokai\Batch\Factory\JobExecutionParametersBuilder\StaticJobExecutionParametersBuilder; @@ -40,6 +43,7 @@ * @phpstan-import-type LauncherConfig from Configuration * @phpstan-import-type ParametersConfig from Configuration * @phpstan-import-type UserInterfaceConfig from Configuration + * @phpstan-import-type LoggingConfig from Configuration */ final class YokaiBatchExtension extends Extension { @@ -70,6 +74,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->configureLauncher($container, $config['launcher']); $this->configureParameters($container, $config['parameters']); $this->configureUserInterface($container, $loader, $config['ui']); + $this->configureLogging($container, $config['logging']); $jobExecutionIdGeneratorDefinition = JobExecutionIdGeneratorDefinitionFactory::fromType($config['id']); $container->setDefinition(JobExecutionIdGeneratorInterface::class, $jobExecutionIdGeneratorDefinition); @@ -238,4 +243,49 @@ private function configureUserInterface(ContainerBuilder $container, LoaderInter ->addArgument($pagination['page_size']) ->addArgument($pagination['page_range']); } + + /** + * @param LoggingConfig $config + */ + private function configureLogging(ContainerBuilder $container, array $config): void + { + if ($config['type'] === 'service') { + if ($config['service'] === null) { + throw new LogicException( + 'Cannot configure service logging: provide a service to use.', + ); + } + + $container->setAlias(JobExecutionLoggerFactoryInterface::class, $config['service']); + + return; + } + + if ($config['type'] === 'stream') { + if (!$this->installed('monolog')) { + throw new LogicException( + 'Cannot configure stream logging: install "yokai/batch-monolog" first.', + ); + } + + $container->register(JobExecutionLoggerFactoryInterface::class, StreamJobExecutionLoggerFactory::class) + ->setArguments([ + $config['stream']['directory'], + \array_map(fn(string $id) => new Reference($id), $config['stream']['processors']), + $config['stream']['formatter'] !== null ? new Reference($config['stream']['formatter']) : null, + $config['stream']['sub_directories'], + $config['stream']['chars_per_directory'], + ]); + + return; + } + + if ($config['type'] === 'null') { + $container->register(JobExecutionLoggerFactoryInterface::class, NullJobExecutionLoggerFactory::class); + + return; + } + + $container->register(JobExecutionLoggerFactoryInterface::class, InMemoryJobExecutionLoggerFactory::class); + } } diff --git a/src/batch-symfony-framework/src/Resources/services/core.php b/src/batch-symfony-framework/src/Resources/services/core.php index 25639a78..1ae10e3b 100644 --- a/src/batch-symfony-framework/src/Resources/services/core.php +++ b/src/batch-symfony-framework/src/Resources/services/core.php @@ -7,7 +7,6 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Yokai\Batch\Factory\JobExecutionFactory; use Yokai\Batch\Factory\JobExecutionIdGeneratorInterface; -use Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory; use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface; use Yokai\Batch\Factory\JobExecutionParametersBuilder\ChainJobExecutionParametersBuilder; use Yokai\Batch\Factory\JobExecutionParametersBuilderInterface; @@ -27,8 +26,6 @@ service(JobExecutionLoggerFactoryInterface::class), ]) - ->set(JobExecutionLoggerFactoryInterface::class, InMemoryJobExecutionLoggerFactory::class) - ->set(JobExecutionFactory::class) ->args([ service(JobExecutionIdGeneratorInterface::class), diff --git a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php index ed7a03e8..64e5e2c1 100644 --- a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php +++ b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php @@ -15,6 +15,7 @@ use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\Reference; use Yokai\Batch\Bridge\Doctrine\DBAL\DoctrineDBALJobExecutionStorage; +use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory; use Yokai\Batch\Bridge\Symfony\Console\RunCommandJobLauncher; use Yokai\Batch\Bridge\Symfony\Framework\DependencyInjection\YokaiBatchExtension; use Yokai\Batch\Bridge\Symfony\Framework\UserInterface\Templating\ConfigurableTemplating; @@ -26,6 +27,8 @@ use Yokai\Batch\Bridge\Symfony\Uid\Factory\TimeBasedUuidJobExecutionIdGenerator; use Yokai\Batch\Bridge\Symfony\Uid\Factory\UlidJobExecutionIdGenerator; use Yokai\Batch\Factory\JobExecutionIdGeneratorInterface; +use Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory; +use Yokai\Batch\Factory\JobExecutionLoggerFactory\NullJobExecutionLoggerFactory; use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface; use Yokai\Batch\Factory\JobExecutionParametersBuilder\ChainJobExecutionParametersBuilder; use Yokai\Batch\Factory\JobExecutionParametersBuilder\PerJobJobExecutionParametersBuilder; @@ -531,6 +534,97 @@ public static function id(): \Generator ]; } + #[DataProvider('logging')] + public function testLogging( + array $config, + \Closure|null $configure, + string $expectedClass, + \Closure|null $assert = null, + ): void { + $container = $this->createContainer($config, $configure); + + $definition = $this->getDefinition($container, JobExecutionLoggerFactoryInterface::class); + self::assertNotNull($definition); + self::assertSame($expectedClass, $definition->getClass()); + + if ($assert !== null) { + $assert($container->findDefinition(JobExecutionLoggerFactoryInterface::class)); + } + } + + public static function logging(): \Generator + { + yield 'Default config' => [ + [], + null, + InMemoryJobExecutionLoggerFactory::class, + ]; + yield 'Memory explicit' => [ + ['logging' => ['type' => 'memory']], + null, + InMemoryJobExecutionLoggerFactory::class, + ]; + yield 'Null' => [ + ['logging' => ['type' => 'null']], + null, + NullJobExecutionLoggerFactory::class, + ]; + yield 'Stream default directory' => [ + ['logging' => ['type' => 'stream']], + null, + StreamJobExecutionLoggerFactory::class, + function (Definition $definition) { + [$directory] = $definition->getArguments(); + self::assertSame('%kernel.logs_dir%/batch', $directory); + }, + ]; + yield 'Stream custom directory' => [ + ['logging' => ['type' => 'stream', 'stream' => ['directory' => '/custom/logs']]], + null, + StreamJobExecutionLoggerFactory::class, + function (Definition $definition) { + [$directory] = $definition->getArguments(); + self::assertSame('/custom/logs', $directory); + }, + ]; + yield 'Stream with subdirectories' => [ + ['logging' => ['type' => 'stream', 'stream' => ['directory' => '/tmp', 'sub_directories' => 2, 'chars_per_directory' => 3]]], + null, + StreamJobExecutionLoggerFactory::class, + function (Definition $definition) { + [, , , $subDirectories, $charsPerDirectory] = $definition->getArguments(); + self::assertSame(2, $subDirectories); + self::assertSame(3, $charsPerDirectory); + }, + ]; + yield 'Stream with processors' => [ + ['logging' => ['type' => 'stream', 'stream' => ['processors' => ['my.processor']]]], + fn(ContainerBuilder $container) => $container->register('my.processor'), + StreamJobExecutionLoggerFactory::class, + function (Definition $definition) { + [, $processors] = $definition->getArguments(); + self::assertCount(1, $processors); + self::assertInstanceOf(Reference::class, $processors[0]); + self::assertSame('my.processor', (string)$processors[0]); + }, + ]; + yield 'Stream with formatter' => [ + ['logging' => ['type' => 'stream', 'stream' => ['formatter' => 'my.formatter']]], + fn(ContainerBuilder $container) => $container->register('my.formatter'), + StreamJobExecutionLoggerFactory::class, + function (Definition $definition) { + [, , $formatter] = $definition->getArguments(); + self::assertInstanceOf(Reference::class, $formatter); + self::assertSame('my.formatter', (string)$formatter); + }, + ]; + yield 'Custom service' => [ + ['logging' => ['type' => 'service', 'service' => NullJobExecutionLoggerFactory::class]], + fn(ContainerBuilder $container) => $container->register(NullJobExecutionLoggerFactory::class), + NullJobExecutionLoggerFactory::class, + ]; + } + private function createContainer(array $config, \Closure|null $configure = null): ContainerBuilder { $container = new ContainerBuilder(); From a60aec32e6872d452b7876d2bcd870052b8c9b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Mon, 20 Apr 2026 10:39:16 +0200 Subject: [PATCH 4/6] Add documentation around job execution logger & framework configuration --- docs/docs/bridges.rst | 1 + docs/docs/bridges/monolog.rst | 45 +++++++++++++ .../stream-logger-factory-with-processors.php | 13 ++++ ...eam-logger-factory-with-subdirectories.php | 15 +++++ .../bridges/monolog/stream-logger-factory.php | 9 +++ docs/docs/bridges/symfony-framework.rst | 63 +++++++++++++++++++ .../core-concepts/job-execution-logger.rst | 56 +++++++++++++++++ docs/docs/core-concepts/job-execution.rst | 3 +- docs/docs/index.rst | 2 + .../Documentation/DocumentationLinksTest.php | 5 ++ 10 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 docs/docs/bridges/monolog.rst create mode 100644 docs/docs/bridges/monolog/stream-logger-factory-with-processors.php create mode 100644 docs/docs/bridges/monolog/stream-logger-factory-with-subdirectories.php create mode 100644 docs/docs/bridges/monolog/stream-logger-factory.php create mode 100644 docs/docs/core-concepts/job-execution-logger.rst diff --git a/docs/docs/bridges.rst b/docs/docs/bridges.rst index 7c737214..862241c1 100644 --- a/docs/docs/bridges.rst +++ b/docs/docs/bridges.rst @@ -12,6 +12,7 @@ Here is the complete list of what to expect: Doctrine ORM Doctrine Persistence Flysystem + Monolog OpenSpout Symfony Console Symfony Messenger diff --git a/docs/docs/bridges/monolog.rst b/docs/docs/bridges/monolog.rst new file mode 100644 index 00000000..8238c8af --- /dev/null +++ b/docs/docs/bridges/monolog.rst @@ -0,0 +1,45 @@ +Bridge with ``monolog/monolog`` +============================================================ + +Refer to the `official documentation `__ on Monolog's website. + +This bridge provides file-based log storage for job executions using Monolog's ``StreamHandler``. + + +Store job execution logs in files +------------------------------------------------------------ + +| The + `StreamJobExecutionLoggerFactory `__ + creates one log file per job execution, named after the job execution id. +| Logs written during execution are readable afterwards via the same reference. + +.. literalinclude:: monolog/stream-logger-factory.php + :language: php + +.. seealso:: + | :doc:`What is a job execution logger? ` + | :doc:`Bridge with Symfony Framework ` + + +Organize log files in subdirectories +------------------------------------------------------------ + +| By default, all log files land in the same directory. +| For large workloads this can become a filesystem performance issue. +| You can configure the factory to spread files across nested subdirectories, + similarly to how Git stores objects: + +.. literalinclude:: monolog/stream-logger-factory-with-subdirectories.php + :language: php + + +Customize the Monolog stack +------------------------------------------------------------ + +| You can provide Monolog processors and a custom formatter to control how records are written. +| Processors are applied to every log record before it is written. +| The formatter controls the final string representation written to the file. + +.. literalinclude:: monolog/stream-logger-factory-with-processors.php + :language: php diff --git a/docs/docs/bridges/monolog/stream-logger-factory-with-processors.php b/docs/docs/bridges/monolog/stream-logger-factory-with-processors.php new file mode 100644 index 00000000..42d52ddc --- /dev/null +++ b/docs/docs/bridges/monolog/stream-logger-factory-with-processors.php @@ -0,0 +1,13 @@ +` + +Custom logger factory service +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If none of the built-in types fit your needs, you can point to your own service: + +.. code-block:: yaml + + # config/packages/yokai_batch.yaml + yokai_batch: + logging: + type: service + service: App\Batch\MyJobExecutionLoggerFactory + + User interface to visualize ``JobExecution`` ------------------------------------------------------------ diff --git a/docs/docs/core-concepts/job-execution-logger.rst b/docs/docs/core-concepts/job-execution-logger.rst new file mode 100644 index 00000000..5c41dd91 --- /dev/null +++ b/docs/docs/core-concepts/job-execution-logger.rst @@ -0,0 +1,56 @@ +Job execution logger +============================================================ + +What is a job execution logger? +------------------------------------------------------------ + +Every ``JobExecution`` carries a +`JobExecutionLoggerInterface `__ +instance that records log messages produced during the execution. + +It extends PSR-3's ``LoggerInterface``, so anything you can do with a standard logger +(``info``, ``warning``, ``error``, etc.) works here. +On top of that, it exposes two extra methods to read the logs back: + +* ``getLogs(): iterable`` — streams log lines lazily, suitable for large files or HTTP streaming +* ``getLogsContent(): string`` — returns the full log content as a string (loads everything in memory) + +It also exposes ``getReference(): string``, a string serialized alongside the ``JobExecution`` +that allows the log storage to be restored after the execution is loaded back from storage. + +How are loggers created and restored? +------------------------------------------------------------ + +The +`JobExecutionLoggerFactoryInterface `__ +is responsible for the full lifecycle: + +* ``create(string $jobExecutionId)`` — called when a new ``JobExecution`` is created. + Returns a fresh logger ready to receive messages. +* ``restore(string $logsReference)`` — called when a ``JobExecution`` is loaded from storage. + Reconstructs the logger from the reference that was serialized with the execution. + +You should never have to call these methods yourself; the framework handles it internally. + +What implementations exist? +------------------------------------------------------------ + +**Built-in implementations:** + +* `InMemoryJobExecutionLoggerFactory `__ + (default): keeps logs in memory. Logs are lost when the process ends. +* `NullJobExecutionLoggerFactory `__: + discards all log messages. + +**From bridges:** + +* From ``monolog/monolog`` bridge: + + * `StreamJobExecutionLoggerFactory `__: + writes one log file per job execution using Monolog's ``StreamHandler``. + Logs survive the process and can be read back from the file. + +.. seealso:: + | :doc:`What is a job execution? ` + | :doc:`Bridge with Monolog ` + | :doc:`Bridge with Symfony Framework ` diff --git a/docs/docs/core-concepts/job-execution.rst b/docs/docs/core-concepts/job-execution.rst index 8864368c..d538e96e 100644 --- a/docs/docs/core-concepts/job-execution.rst +++ b/docs/docs/core-concepts/job-execution.rst @@ -20,9 +20,10 @@ What kind of information does it hold? * ``JobExecution::$failures``: A list of failures (usually exceptions) * ``JobExecution::$warnings``: A list of warnings (usually skipped items) * ``JobExecution::$summary``: A summary (can contain any data you wish to store) -* ``JobExecution::$logs``: Some logs +* ``JobExecution::$logger``: The associated logger * ``JobExecution::$childExecutions``: Some child execution .. seealso:: | :doc:`How is a job execution created? ` | :doc:`How can I retrieve a job execution afterwards? ` + | :doc:`How are job execution logs stored? ` diff --git a/docs/docs/index.rst b/docs/docs/index.rst index 4939881f..7f12914d 100644 --- a/docs/docs/index.rst +++ b/docs/docs/index.rst @@ -67,6 +67,7 @@ Explore some of the things that could be built with **Yokai Batch**: core-concepts/item-job core-concepts/job-execution core-concepts/job-execution-storage + core-concepts/job-execution-logger core-concepts/job-with-children core-concepts/job-parameter-accessor core-concepts/aware-interfaces @@ -98,6 +99,7 @@ Explore some of the things that could be built with **Yokai Batch**: Doctrine ORM Doctrine Persistence Flysystem + Monolog OpenSpout Symfony Console Symfony Framework diff --git a/tests/convention/Documentation/DocumentationLinksTest.php b/tests/convention/Documentation/DocumentationLinksTest.php index 5ebf4bba..f439bd82 100644 --- a/tests/convention/Documentation/DocumentationLinksTest.php +++ b/tests/convention/Documentation/DocumentationLinksTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface; use Yokai\Batch\Job\Item\ItemProcessorInterface; use Yokai\Batch\Job\Item\ItemReaderInterface; use Yokai\Batch\Job\Item\ItemWriterInterface; @@ -127,5 +128,9 @@ public static function interfaceRules(): iterable 'docs/docs/core-concepts/item-job/item-writer.rst', ItemWriterInterface::class, ]; + yield 'JobExecutionLoggerFactoryInterface' => [ + 'docs/docs/core-concepts/job-execution-logger.rst', + JobExecutionLoggerFactoryInterface::class, + ]; } } From 803c55b2699fa0a0755f98931deb41adb0245c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Mon, 20 Apr 2026 11:10:14 +0200 Subject: [PATCH 5/6] Add missing tests to cover all cases --- .../YokaiBatchExtension.php | 17 +++++++++++-- .../YokaiBatchExtensionTest.php | 24 +++++++++++++++---- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php index c6afea14..ee82ffb1 100644 --- a/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php +++ b/src/batch-symfony-framework/src/DependencyInjection/YokaiBatchExtension.php @@ -47,6 +47,19 @@ */ final class YokaiBatchExtension extends Extension { + /** + * @var \Closure(string): bool + */ + private \Closure $packageChecker; + + /** + * @param (\Closure(string): bool)|null $packageChecker Optional override for package detection, defaults to {@see InstalledVersions::isInstalled()}. + */ + public function __construct(\Closure|null $packageChecker = null) + { + $this->packageChecker = $packageChecker ?? InstalledVersions::isInstalled(...); + } + /** * @param list> $configs */ @@ -84,8 +97,8 @@ public function load(array $configs, ContainerBuilder $container): void private function installed(string $package): bool { - return InstalledVersions::isInstalled('yokai/batch-src') - || InstalledVersions::isInstalled('yokai/batch-' . $package); + return ($this->packageChecker)('yokai/batch-src') + || ($this->packageChecker)('yokai/batch-' . $package); } private function getLoader(ContainerBuilder $container): LoaderInterface diff --git a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php index 64e5e2c1..fd3e8285 100644 --- a/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php +++ b/src/batch-symfony-framework/tests/DependencyInjection/YokaiBatchExtensionTest.php @@ -449,10 +449,10 @@ public static function parameters(): \Generator } #[DataProvider('errors')] - public function testErrors(array $config, Exception $error): void + public function testErrors(array $config, Exception $error, \Closure|null $packageChecker = null): void { $this->expectExceptionObject($error); - $this->createContainer($config); + $this->createContainer($config, packageChecker: $packageChecker); } public static function errors(): \Generator @@ -487,6 +487,15 @@ public static function errors(): \Generator ['launcher' => ['default' => 'invalid', 'launchers' => ['invalid' => 'unknown://unknown']]], new LogicException('Unsupported job launcher type "unknown".'), ]; + yield 'Logging : Service type without service id' => [ + ['logging' => ['type' => 'service']], + new LogicException('Cannot configure service logging: provide a service to use.'), + ]; + yield 'Logging : Stream type without monolog installed' => [ + ['logging' => ['type' => 'stream']], + new LogicException('Cannot configure stream logging: install "yokai/batch-monolog" first.'), + static fn(string $package): bool => false, + ]; yield 'Per job parameters value must be an array' => [ ['parameters' => ['per_job' => ['job.foo' => 'string']]], new InvalidConfigurationException( @@ -625,14 +634,19 @@ function (Definition $definition) { ]; } - private function createContainer(array $config, \Closure|null $configure = null): ContainerBuilder - { + private function createContainer( + array $config, + \Closure|null $configure = null, + \Closure|null $packageChecker = null, + ): ContainerBuilder { $container = new ContainerBuilder(); if ($configure !== null) { $configure($container); } $bundle = new YokaiBatchBundle(); - $extension = $bundle->getContainerExtension(); + $extension = $packageChecker !== null + ? new YokaiBatchExtension($packageChecker) + : $bundle->getContainerExtension(); \assert($extension instanceof YokaiBatchExtension); $container->registerExtension($extension); $container->loadFromExtension('yokai_batch', $config); From 954b7074553a276b0b6d48b374fac77bde7472c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yann=20Eugon=C3=A9?= Date: Mon, 20 Apr 2026 11:43:31 +0200 Subject: [PATCH 6/6] Add instructions to upgrade guide --- UPGRADE-1.0.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/UPGRADE-1.0.md b/UPGRADE-1.0.md index dd4454bc..0e93b227 100644 --- a/UPGRADE-1.0.md +++ b/UPGRADE-1.0.md @@ -104,7 +104,7 @@ a small, pluggable interface hierarchy. | Class / Interface | Description | |-------------------|-------------| | `Yokai\Batch\Logger\JobExecutionLoggerInterface` | PSR-3 logger + `getReference()`, `getLogs()`, `getLogsContent()` | -| `Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface` | Creates and restores loggers (`create()` / `restore(string $ref)`) | +| `Yokai\Batch\Factory\JobExecutionLoggerFactoryInterface` | Creates and restores loggers (`create(string $id)` / `restore(string $ref)`) | | `Yokai\Batch\Logger\InMemoryJobExecutionLogger` | Default implementation — accumulates logs in-memory | | `Yokai\Batch\Logger\NullJobExecutionLogger` | Discards all logs — useful in tests | | `Yokai\Batch\Factory\JobExecutionLoggerFactory\InMemoryJobExecutionLoggerFactory` | Factory for the in-memory logger (registered by default by the Symfony bundle) | @@ -169,6 +169,79 @@ forward to the current job execution logger during job execution. Replace any re --- +### New bridge: `yokai/batch-monolog` + +A new optional bridge package adds file-based log storage for job executions via +[Monolog](https://seldaek.github.io/monolog)'s `StreamHandler`. + +```bash +composer require yokai/batch-monolog +``` + +Each job execution gets its own log file named after its id: + +```php +use Yokai\Batch\Bridge\Monolog\StreamJobExecutionLoggerFactory; + +new StreamJobExecutionLoggerFactory( + directory: '/var/log/batch', +); +``` + +For large workloads, files can be spread across nested subdirectories (git-object style): + +```php +new StreamJobExecutionLoggerFactory( + directory: '/var/log/batch', + subDirectories: 2, + charsPerDirectory: 2, + // a job "60996f72" is stored at /var/log/batch/60/99/60996f72.log +); +``` + +Monolog processors and a custom formatter can also be injected to control how records are written. + +In a Symfony application, configure the bridge through the bundle (see below). + +--- + +### Symfony bundle configuration — job execution log storage + +A new `logging` key controls how job execution logs are stored. +The default behaviour is unchanged (`memory` — in-memory, lost at process end): + +```yaml +# config/packages/yokai_batch.yaml +yokai_batch: + logging: + type: memory # memory (default) | null | stream | service +``` + +To persist logs to files (requires `yokai/batch-monolog`): + +```yaml +yokai_batch: + logging: + type: stream + stream: + directory: '%kernel.logs_dir%/batch' + sub_directories: 0 # subdirectory depth (0 = flat) + chars_per_directory: 0 # characters per subdirectory level + processors: [] # Monolog processor service ids + formatter: ~ # Monolog formatter service id (optional) +``` + +To use a custom service implementing `JobExecutionLoggerFactoryInterface`: + +```yaml +yokai_batch: + logging: + type: service + service: App\Batch\MyJobExecutionLoggerFactory +``` + +--- + ### Symfony bundle configuration — storage DSN The `storage` key of the `yokai_batch` bundle now accepts a **DSN string** as a shorthand: