-
Notifications
You must be signed in to change notification settings - Fork 0
Scheduled bg commands stdout log
macropay-solutions edited this page Jun 20, 2025
·
1 revision
Logging to stdout will not work in AWS Cloudwatch with this setup:
'stdout' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stdout',
],
],
$schedule->command('system:command')->everyMinute()
->withoutOverlapping(30)->onOneServer()->runInBackground();
To circumvent it you can choose from 2 alternatives:
A. Create two macros:
Schedule::macro(
'customCommand',
/**
* Add a new Artisan command event to the schedule.
* @see \Illuminate\Console\Scheduling\Schedule::command
*/
function (string $command, array $parameters = []): Event {
/** @var Schedule $this */
if (class_exists($command)) {
$command = Container::getInstance()->make($command);
return $this->execCustomEvent(
Application::formatCommandString($command->getName()),
$parameters,
)->description($command->getDescription());
}
return $this->execCustomEvent(
Application::formatCommandString($command),
$parameters
);
}
);
Schedule::macro(
'execCustomEvent',
/**
* Add a new command event to the schedule.
* @see \Illuminate\Console\Scheduling\Schedule::exec
*/
function (string $command, array $parameters = []): Event {
//** @var Schedule $this */
if ([] !== $parameters) {
$command .= ' ' . $this->compileParameters($parameters);
}
$this->events[] = $event = new class ($this->eventMutex, $command, $this->timezone) extends Event {
/**
* @inheritDoc
*/
public function getDefaultOutput()
{
return (DIRECTORY_SEPARATOR === '\\') ? 'NUL' : '/dev/stdout'; // changed from '/dev/null'
}
/**
* @inheritDoc
*/
protected function ensureOutputIsBeingCaptured()
{
if (is_null($this->output)) { // removed: || $this->output == $this->getDefaultOutput()
$this->sendOutputTo(storage_path('logs/schedule-' . sha1($this->mutexName()) . '.log'));
}
}
/**
* @inheritDoc
*/
protected function execute($container)
{
return Process::fromShellCommandline(
$this->buildCommand(),
base_path(),
$this->runInBackground ? [CustomStreamHandler::LOG_VIA_QUEUE_JOB => CustomStreamHandler::LOG_VIA_QUEUE_JOB,] : null,
null,
null
)->run(fn () => true);
}
};
return $event;
}
);
B. Extend the Illuminate\Console\Scheduling\Event
and register it in the container instead of its parent
namespace App/Events;
use Illuminate\Console\Scheduling\Event as Base;
class Event extends Base
{
/**
* @inheritDoc
*/
public function getDefaultOutput()
{
return (DIRECTORY_SEPARATOR === '\\') ? 'NUL' : '/dev/stdout'; // changed from '/dev/null'
}
/**
* @inheritDoc
*/
protected function ensureOutputIsBeingCaptured()
{
if (is_null($this->output)) { // removed: || $this->output == $this->getDefaultOutput()
$this->sendOutputTo(storage_path('logs/schedule-' . sha1($this->mutexName()) . '.log'));
}
}
/**
* @inheritDoc
*/
protected function execute($container)
{
return Process::fromShellCommandline(
$this->buildCommand(),
base_path(),
$this->runInBackground ?
[\App\LogHandlers\CustomStreamHandler::LOG_VIA_QUEUE_JOB => \App\LogHandlers\CustomStreamHandler::LOG_VIA_QUEUE_JOB,] :
null
null,
null
)->run(fn () => true);
}
}
// register the child in container
use App/Events/Event as ChildEvent;
use Illuminate\Console\Scheduling\Event
$this->app->bind(Event::class, function (Application $app, array $args = []): ChildEvent {
return new ChildEvent(...$args));
});
Create a custom logger:
namespace App\LogHandlers;
use App\Jobs\LoggerJob;
use Monolog\Handler\StreamHandler;
use Monolog\Utils;
/**
* Writes to any queue.
*/
class CustomStreamHandler extends StreamHandler
{
public const LOG_VIA_QUEUE_JOB = 'LOG_VIA_QUEUE_JOB';
/** 256 KB in bytes - maximum message size in SQS */
protected const MAX_MESSAGE_SIZE = 262144;
/** 100 KB in bytes - head message size for new error log */
protected const HEAD_MESSAGE_SIZE = 102400;
/**
* @throws \InvalidArgumentException
*/
protected function write(array $record): void
{
if (self::LOG_VIA_QUEUE_JOB !== \env(self::LOG_VIA_QUEUE_JOB)) {
parent::write($record);
return;
}
if (!isset($record['formatted']) || 'string' !== gettype($messageBody = $record['formatted'])) {
throw new \InvalidArgumentException('QueueHandler accepts only formatted records as a string' .
Utils::getRecordMessageForException($record));
}
if (strlen($messageBody) >= static::MAX_MESSAGE_SIZE) {
$messageBody = Utils::substr($messageBody, 0, static::HEAD_MESSAGE_SIZE);
}
LoggerJob::dispatch($messageBody, $this->level);
}
}
Create a logger job:
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class LoggerJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 300;
public bool $failOnTimeout = true;
public function __construct(protected string $message, protected int $level)
{
}
public function handle(): void
{
Log::log(\strtolower(\Monolog\Logger::getLevelName($this->level)), $this->message);
}
public function failed(\Throwable $e): void
{
Log::error('Job ' . __CLASS__ . ' with payload: ' . \json_encode(\get_object_vars($this)) . ', error: ' .
$e->getMessage() . ', trace: ' . $e->getTraceAsString());
}
}
Edit the channel config:
'stdout' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => \App\LogHandlers\CustomStreamHandler::class,
'with' => [
'stream' => 'php://stdout',
],
],
And run it like:
A.
$schedule->customCommand('system:command')->everyMinute()
->withoutOverlapping(30)->onOneServer()->runInBackground();
B.
$schedule->command('system:command')->everyMinute()
->withoutOverlapping(30)->onOneServer()->runInBackground();