Skip to content

Commit 487b954

Browse files
committed
Redirect stdout loggers to stderr preventing json response polutions
1 parent 3ccfd9a commit 487b954

File tree

5 files changed

+152
-3
lines changed

5 files changed

+152
-3
lines changed

src/Command/SubprocessJobRunnerCommand.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@
2020
use Cake\Console\Arguments;
2121
use Cake\Console\ConsoleIo;
2222
use Cake\Core\ContainerInterface;
23+
use Cake\Log\Engine\ConsoleLog;
24+
use Cake\Log\Log;
2325
use Cake\Queue\Job\Message;
2426
use Cake\Queue\Queue\Processor;
2527
use Enqueue\Null\NullConnectionFactory;
2628
use Enqueue\Null\NullMessage;
2729
use Interop\Queue\Message as QueueMessage;
2830
use Interop\Queue\Processor as InteropProcessor;
31+
use Psr\Log\LoggerInterface;
2932
use Psr\Log\NullLogger;
3033
use RuntimeException;
3134
use Throwable;
@@ -160,8 +163,10 @@ protected function executeJob(array $data): string
160163
}
161164
}
162165

166+
$logger = $this->configureLogging($data);
167+
163168
$message = new Message($queueMessage, $context, $this->container);
164-
$processor = new Processor(new NullLogger(), $this->container);
169+
$processor = new Processor($logger, $this->container);
165170

166171
$result = $processor->processMessage($message);
167172

@@ -170,6 +175,34 @@ protected function executeJob(array $data): string
170175
return is_string($result) ? $result : (string)$result;
171176
}
172177

178+
/**
179+
* Configure logging to use STDERR to prevent job logs from contaminating STDOUT.
180+
* Reconfigures all CakePHP loggers to write to STDERR with no additional formatting.
181+
*
182+
* @param array<string, mixed> $data Job data
183+
* @return \Psr\Log\LoggerInterface
184+
*/
185+
protected function configureLogging(array $data): LoggerInterface
186+
{
187+
// Drop all existing loggers to prevent duplicate logging
188+
foreach (Log::configured() as $loggerName) {
189+
Log::drop($loggerName);
190+
}
191+
192+
// Configure a single stderr logger
193+
Log::setConfig('default', [
194+
'className' => ConsoleLog::class,
195+
'stream' => 'php://stderr',
196+
]);
197+
198+
$logger = Log::engine('default');
199+
if (!$logger instanceof LoggerInterface) {
200+
$logger = new NullLogger();
201+
}
202+
203+
return $logger;
204+
}
205+
173206
/**
174207
* Output result as JSON to STDOUT.
175208
*

src/Command/WorkerCommand.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,21 @@ protected function getLogger(Arguments $args): LoggerInterface
172172
return $logger ?? new NullLogger();
173173
}
174174

175+
/**
176+
* Get logger for subprocess output.
177+
* Always returns a real logger for subprocess mode to show job logs.
178+
*
179+
* @param \Cake\Console\Arguments $args Arguments
180+
* @return \Psr\Log\LoggerInterface
181+
*/
182+
protected function getSubprocessLogger(Arguments $args): LoggerInterface
183+
{
184+
$loggerName = (string)$args->getOption('logger');
185+
$logger = Log::engine($loggerName);
186+
187+
return $logger ?? new NullLogger();
188+
}
189+
175190
/**
176191
* Creates and returns a Processor object
177192
*
@@ -201,15 +216,20 @@ protected function getProcessor(Arguments $args, ConsoleIo $io, LoggerInterface
201216
if ($args->getOption('subprocess') || ($config['subprocess']['enabled'] ?? false)) {
202217
$subprocessConfig = array_merge(
203218
$config['subprocess'] ?? [],
204-
['enabled' => true],
219+
[
220+
'enabled' => true,
221+
'logger' => $config['logger'] ?? (string)$args->getOption('logger'),
222+
],
205223
);
206224

207225
if ($processorClass !== Processor::class && !is_subclass_of($processorClass, Processor::class)) {
208226
$io->error('Subprocess mode is only supported with the default Processor class');
209227
$this->abort();
210228
}
211229

212-
$processor = new SubprocessProcessor($logger, $subprocessConfig, $this->container);
230+
// Use a real logger for subprocess output so logs are visible
231+
$subprocessLogger = $this->getSubprocessLogger($args);
232+
$processor = new SubprocessProcessor($subprocessLogger, $subprocessConfig, $this->container);
213233
} else {
214234
$processor = new $processorClass($logger, $this->container);
215235
}

src/Queue/SubprocessProcessor.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ protected function prepareJobData(QueueMessage $message): array
185185
'messageClass' => get_class($message),
186186
'body' => $body,
187187
'properties' => $properties,
188+
'logger' => $this->config['logger'] ?? 'stderr',
188189
];
189190
}
190191

@@ -281,6 +282,11 @@ protected function executeInSubprocess(array $jobData): array
281282
}
282283

283284
$errorOutput .= $chunk;
285+
// Stream subprocess logs to parent's stderr in real-time
286+
// Skip in PHPUnit test context to avoid test framework issues
287+
if (!defined('PHPUNIT_COMPOSER_INSTALL') && !defined('__PHPUNIT_PHAR__')) {
288+
fwrite(STDERR, $chunk);
289+
}
284290
}
285291
}
286292

tests/TestCase/Command/SubprocessJobRunnerCommandTest.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Enqueue\Null\NullMessage;
2525
use Interop\Queue\Processor;
2626
use ReflectionClass;
27+
use TestApp\Job\MultilineLogJob;
2728
use TestApp\TestProcessor;
2829

2930
class SubprocessJobRunnerCommandTest extends TestCase
@@ -252,4 +253,67 @@ public function testOutputResult(): void
252253

253254
$method->invoke($command, $io, ['success' => true, 'result' => 'ack']);
254255
}
256+
257+
/**
258+
* Test that subprocess jobs with multiple log lines properly separate logs from JSON output
259+
*/
260+
public function testLogsRedirectedToStderr(): void
261+
{
262+
$jobData = [
263+
'messageClass' => NullMessage::class,
264+
'body' => [
265+
'class' => [MultilineLogJob::class, 'execute'],
266+
'args' => [],
267+
],
268+
'properties' => [],
269+
'logger' => 'debug',
270+
];
271+
272+
$command = 'php ' . ROOT . 'bin/cake.php queue subprocess-runner';
273+
274+
$descriptors = [
275+
0 => ['pipe', 'r'],
276+
1 => ['pipe', 'w'],
277+
2 => ['pipe', 'w'],
278+
];
279+
280+
$process = proc_open($command, $descriptors, $pipes);
281+
$this->assertIsResource($process);
282+
283+
// Write job data to STDIN
284+
$jobDataJson = json_encode($jobData);
285+
if ($jobDataJson !== false) {
286+
fwrite($pipes[0], $jobDataJson);
287+
}
288+
289+
fclose($pipes[0]);
290+
291+
// Read STDOUT and STDERR
292+
$stdout = stream_get_contents($pipes[1]);
293+
fclose($pipes[1]);
294+
295+
$stderr = stream_get_contents($pipes[2]);
296+
fclose($pipes[2]);
297+
298+
proc_close($process);
299+
300+
// STDOUT should be valid JSON without any log messages
301+
$result = json_decode($stdout, true);
302+
$this->assertIsArray($result, 'STDOUT should contain valid JSON: ' . $stdout);
303+
$this->assertArrayHasKey('success', $result);
304+
$this->assertTrue($result['success']);
305+
$this->assertSame(Processor::ACK, $result['result']);
306+
307+
// STDOUT should not contain any job log messages
308+
$this->assertStringNotContainsString('Job execution started', $stdout);
309+
$this->assertStringNotContainsString('Processing step', $stdout);
310+
$this->assertStringNotContainsString('Job execution finished', $stdout);
311+
312+
// All log messages should be in STDERR
313+
$this->assertStringContainsString('Job execution started', $stderr);
314+
$this->assertStringContainsString('Processing step 1 completed', $stderr);
315+
$this->assertStringContainsString('Processing step 2 completed', $stderr);
316+
$this->assertStringContainsString('Processing step 3 completed', $stderr);
317+
$this->assertStringContainsString('Job execution finished', $stderr);
318+
}
255319
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace TestApp\Job;
5+
6+
use Cake\Log\LogTrait;
7+
use Cake\Queue\Job\JobInterface;
8+
use Cake\Queue\Job\Message;
9+
use Interop\Queue\Processor;
10+
use Psr\Log\LogLevel;
11+
12+
class MultilineLogJob implements JobInterface
13+
{
14+
use LogTrait;
15+
16+
public function execute(Message $message): ?string
17+
{
18+
$this->log('Job execution started', LogLevel::INFO);
19+
$this->log('Processing step 1 completed', LogLevel::INFO);
20+
$this->log('Processing step 2 completed', LogLevel::INFO);
21+
$this->log('Processing step 3 completed', LogLevel::INFO);
22+
$this->log('Job execution finished', LogLevel::INFO);
23+
24+
return Processor::ACK;
25+
}
26+
}

0 commit comments

Comments
 (0)