Skip to content

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();