ログイン時のログファイルを分離する

Last-modified: Wed, 05 Sep 2018 23:26:43 JST (98d)

Laravel5.6より、ログファイルの扱いが簡単になりました。
のわりに、ログの分離方法がいまいち分かり辛かったので、実際やってみました。
今回のお題は、ログイン時に別ログファイルに出力する方法です。
なお、このコードはここにあるものを使いました。

ログイン時に別ファイルでログを出力する

  1. まずは認証ロジックとページもろもろを作ります。といっても、Laravelでは、
    php artisan make:auth
    php artisan migrate
    と実行するだけでできてしまいます。
  2. 次にログファイルを分離します。
    1. まず、ログの設定ファイルである、app/config/logging.phpを開き、
      中にあるchannels配列に以下のようにカスタムチャンネルの設定を追記します。
              'authlog' => [
                  'driver' => 'custom',
                  'via' => \App\Logging\AuthLog::class,
                  'path' => storage_path('logs/auth.log'),
                  'level'      => 'debug', // 指定したハンドラで出力するログレベル
                  'activation' => 'error', // このログレベル以上で指定したハンドラで出力するレベルのログを出力する
                  'pass'       => 'info', // このログレベル以上は常に出力する
                  'days' => 30,  //保存日数
              ]
    2. 次に、カスタムチャンネルを作っていきます。
      まず、カスタムログのひな型となるLogDriveAbstract.phpを作成します。
      app/Logging/LogDriverAbstract.php
      <?php
      
      namespace App\Logging;
      
      use Illuminate\Log\Logger;
      use Illuminate\Support\Str;
      use InvalidArgumentException;
      use Monolog\Formatter\LineFormatter;
      use Monolog\Handler\HandlerInterface;
      use Monolog\Logger as Monolog;
      use Psr\Log\LoggerInterface;
      
      abstract class LogDriverAbstract
      {
          /**
           * The Log levels.
           *
           * @var array
           */
          protected $levels = [
              'debug'     => Monolog::DEBUG,
              'info'      => Monolog::INFO,
              'notice'    => Monolog::NOTICE,
              'warning'   => Monolog::WARNING,
              'error'     => Monolog::ERROR,
              'critical'  => Monolog::CRITICAL,
              'alert'     => Monolog::ALERT,
              'emergency' => Monolog::EMERGENCY,
          ];
      
          /**
           * Apply the configured taps for the logger.
           *
           * @param array                    $config
           * @param \Psr\Log\LoggerInterface $logger
           *
           * @return \Psr\Log\LoggerInterface
           */
          protected function tap(array $config, LoggerInterface $logger)
          {
              foreach ($config['tap'] ?? [] as $tap) {
                  list($class, $arguments) = $this->parseTap($tap);
      
                  app()->make($class)->__invoke($logger, ...explode(',', $arguments));
              }
      
              return $logger;
          }
      
          /**
           * Parse the given tap class string into a class name and arguments string.
           *
           * @param string $tap
           *
           * @return array
           */
          protected function parseTap($tap)
          {
              return Str::contains($tap, ':') ? explode(':', $tap, 2) : [$tap, ''];
          }
      
          /**
           * Prepare the handler for usage by Monolog.
           *
           * @param \Monolog\Handler\HandlerInterface $handler
           *
           * @return \Monolog\Handler\HandlerInterface
           */
          protected function prepareHandler(HandlerInterface $handler)
          {
              return $handler->setFormatter($this->formatter());
          }
      
          /**
           * Get a Monolog formatter instance.
           *
           * @return \Monolog\Formatter\FormatterInterface
           */
          protected function formatter()
          {
              return tap(new LineFormatter(null, null, true, true), function ($formatter) {
                  $formatter->includeStacktraces();
              });
          }
      
          /**
           * Extract the log channel from the given configuration.
           *
           * @param array $config
           *
           * @return string
           */
          protected function parseChannel(array $config)
          {
              if (!isset($config['name'])) {
                  return app()->bound('env') ? app()->environment() : 'production';
              }
      
              return $config['name'];
          }
      
          /**
           * Parse the string level into a Monolog constant.
           *
           * @param array $config
           *
           * @return int
           *
           * @throws \InvalidArgumentException
           */
          protected function level(array $config)
          {
              $level = $config['level'] ?? 'debug';
      
              if (isset($this->levels[$level])) {
                  return $this->levels[$level];
              }
      
              throw new InvalidArgumentException('Invalid log level.');
          }
      }
    3. 次に中身を実装していきます。
      app/Logging/AuthLog.php
      <?php
      
      namespace App\Logging;
      
      use Monolog\Logger;
      use Monolog\Handler\RotatingFileHandler;
      use Monolog\Handler\FingersCrossedHandler;
      use Monolog\Formatter\LineFormatter;
      use Monolog\Processor\WebProcessor;
      use Monolog\Processor\IntrospectionProcessor;
      
      class AuthLog extends LogDriverAbstract
      {
          /**
           * Create a custom Monolog instance.
           *
           * @param  array  $config config/logging.php で指定した authlog 以下のものを取得できる
           * @return \Monolog\Logger
           */
          public function __invoke(array $config)
          {
              // StreamHandler を生成
              $handler = $this->prepareHandler(
                  new RotatingFileHandler(
                      $config['path'], $config['days'] ?? 7, $this->level($config),
                      $config['bubble'] ?? true, $config['permission'] ?? null, $config['locking'] ?? false
                  )
              );
      
              // ログに出力するフォーマット
              $format = '[%datetime% %channel%.%level_name%] %message% [%context%] [ip:%extra.ip% agent:%extra.agent%]' . PHP_EOL;
      
              // StreamHandler にフォーマッタをセット
              $handler->setFormatter(
                  tap(new LineFormatter($format, null, true, true), function ($formatter) {
                  })
              );
              //extraフィールドにIPアドレスとユーザエージェントを追加
              $ip = new WebProcessor();
              //ユーザエージェントをextraフィールドに項目追加
              $ip->addExtraField("agent","HTTP_USER_AGENT");
              // 各ログハンドラにフォーマッタとプロセッサを設定
              $handler->pushProcessor($ip);
      
              // Monolog のインスタンスを生成して返す
              return new Logger($this->parseChannel($config), [
                  new FingersCrossedHandler(
                      $handler,
                      $config['activation'] ?? null,
                      0,
                      true,
                      true,
                      $config['pass'] ?? null
                  )
              ]);
          }
      }
      これで作成できました。
      app/Http/Controllers/HomeController.phpのindexメソッドあたりに、
      Logs('authlog')->info('test');
      とかくと、/storage/logs/配下にauth-YYYY-MM-DD.logのようなファイルができます。
      中身はうえで設定した内容が以下のように記録されます。
      [2018-09-05 15:45:04 local.INFO] test [] [ip:172.19.0.1 agent:Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:61.0) Gecko/20100101 Firefox/61.0]
  • extraフィールドに項目追加したい
    例えば、IPからホスト名を引いて、それを記録したい場合は、
            // ログに出力するフォーマット
            $format = '[%datetime% %channel%.%level_name%] %message% [%context%] [ip:%extra.ip% agent:%extra.agent% hostname:%extra.hostname%]' . PHP_EOL;
    のように、extra.hostnameのような項目を追加し、Monologインスタンスを生成する前に
            //ホスト名をセット
            $handler->pushProcessor(function ($record){
                $record['extra']['hostname'] = gethostbyaddr($_SERVER['REMOTE_ADDR']);
                return $record;
            });
    な感じで対応する項目をセットする処理を実装して、ハンドラに登録すればOKです。
    • 厳密にはREMOTE_ADDR使うとProxy経由のアクセスの際に正しく取れないのですがそれはまぁ・・・

ログインイベントを利用してログをとる

先ほどのやり方では、ログインに限らず単に標準ログとは違うチャンネルを作成して、それを呼び出しているだけでした。
ここでは、認証ロジックが発行するログインイベントをフックし、それでログ記録を行います。
Laravel5.6では、認証ロジックが発行する(正式には発火というかな?)イベントは

Registered会員登録完了時
Attemptingログイン試行時
Loginログイン成功時
Failedログイン失敗時
Logoutログアウト成功時
Lockoutログイン連続失敗でIPブロックされたとき
PasswordResetパスワードリセット時

以上になります。(ここ参照)
ここでは、サンプルとしてすべての認証イベントをフックしてみます。
まずは、app/Providers/EventServicePrivider.phpにイベントを登録します。
$listen配列には最初App\Events\Eventが登録されていると思いますので、その下に以下のような感じで追記していきます。

    protected $listen = [
        'App\Events\Event' => [
            'App\Listeners\EventListener',
        ],
        'Illuminate\Auth\Events\Registered' => [
            'App\Listeners\LogRegisteredUser',
        ],

        'Illuminate\Auth\Events\Attempting' => [
            'App\Listeners\LogAuthenticationAttempt',
        ],

        'Illuminate\Auth\Events\Login' => [
            'App\Listeners\LogSuccessfulLogin',
        ],

        'Illuminate\Auth\Events\Failed' => [
            'App\Listeners\LogFailedLogin',
        ],

        'Illuminate\Auth\Events\Logout' => [
            'App\Listeners\LogSuccessfulLogout',
        ],

        'Illuminate\Auth\Events\Lockout' => [
            'App\Listeners\LogLockout',
        ],

        'Illuminate\Auth\Events\PasswordReset' => [
            'App\Listeners\LogPasswordReset',
        ],
    ];

登録したら、イベントのひな型を生成します。
以下のコマンドを実行します。

php artisan event:generate

すると、
app\Listeners配下にひな形のファイルができます。
たとえば、ログイン試行中のログをとりたい場合、
LogAuthenticationAttempt.phpのhandlerメソッドを以下のような感じで実装します。

    public function handle(Attempting $event)
    {
        Logs('authlog')->info('ログイン試行中');
    }

ログイン完了やログアウトなどはユーザIDが取れますので、以下のような感じでユーザIDをログに記録することができます。
例えば、LogRegisterdUser.phpでは

    public function handle(Registered $event)
    {
        $user = $event->user;
        Logs('authlog')->info('ユーザ登録完了',['user:' . $user->id]);
    }

な感じで記録することができます。
このように、Laravelでは特定のイベントに対するリスナーを実装することで、特定イベント発生時に処理をさせることができます。


Counter: 54, today: 2, yesterday: 0

このページの参照回数は、54です。