--> -->

skimemo


skimemo - 日記/2018-09-03/Laravel5.4のログをMySQLに出力して閲覧する のバックアップ(No.6)


_ Laravel5.4のログをMySQLに出力して閲覧する

Laravelのログは標準ではファイルに書き出されます。

量が少ないうちは良いのですが、多くなってくるとビューワー側がつらくなってきます。*1

そこで、MySQLにログを出力し、そこから読み出し、検索する処理を書いてみました。
なお、パッケージとして追加する方法もあるのですが、手動で追加する方法にしています。
気が向いたらパッケージ化するかも・・・(^^;)。

e1.PNG

_ 修正・追加するファイル(概要)

修正、追加が必要なファイルは以下の通りです。

  • .env
  • config/database.php
  • config/app.php
  • bootstrap/app.php
  • app/Log/MySqlHandler.php
  • route/web.php
  • app/Http/Controllers/LogViewController.php
  • resources/views/admin/log_viewer.blade.php
  • logsテーブルの作成SQL

次の項で1つずつ見ていきましょう。

_ 修正・追加するファイル(ログの記録)

この記事ではログの記録と閲覧を行いますが、まずはログの記録編です。

  • .env
    参考文献1*2で実施している通り、トランザクション処理を考慮するとDBの接続はアプリ本体と分ける必要があります。そのための情報を記載します。
    DB_LOG_TABLE=logs
    DB_LOG_CONNECTION=mysql_log
  • config/database.php
    前項で指定した「mysql_log」を定義します。
    基本的には定義済みの「mysql」のコピペです。
      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
    
    'mysql_log' => [
        'driver' => 'mysql',
        'host' => env('DB_HOST', '127.0.0.1'),
        'port' => env('DB_PORT', '3306'),
        'database' => env('DB_DATABASE', 'forge'),
        'username' => env('DB_USERNAME', 'forge'),
        'password' => env('DB_PASSWORD', ''),
        'unix_socket' => env('DB_SOCKET', ''),
        'charset' => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix' => '',
        'strict' => true,
        'engine' => null,
        'fetch' => PDO::FETCH_ASSOC,
    ], 
  • config/app.php
    ログの保存日数を定義します。
    'log_max_days' => env('APP_LOG_MAX_DAYS', 7),
    これで、.envファイルに以下のように書けばそちらが優先されます。
    APP_LOG_MAX_DAYS=7
    上記の指定で、7日以上前のログは随時削除されていきます。
  • bootstrap/app.php
    参考文献2*3にも書かれているように、Monologの新たなHandlerを追記する方法として、bootstrap/app.php に以下の処理を追加します。
      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
    
    /** monologのhandlerカスタマイズ */
    if( !isset($_ENV['APP_ENV']) or $_ENV['APP_ENV']!='testing' ){
        $app->configureMonologUsing(function (Monolog\Logger $monolog) use ($app) {
            $monolog->pushHandler(new \App\Log\MySqlHandler(
                $monolog->toMonologLevel($app->make('config')->get('app.log_level', 'debug')),
                $app->make('config')->get('app.log_max_days', 7),
                $app->environment('testing'),
                true
            ));
        });
    } 
    環境変数APP_ENVを見て、テスト環境の時は通常のログ出力を行うようにしています(後述)。
  • app/Log/MySqlHandler.php
    次に前項で指定した新しいHandlerを作成します。
      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
    
    <?php
     
    namespace App\Log;
     
    use DB;
    use Monolog\Handler\AbstractHandler;
    use Monolog\Logger;
     
    class MySqlHandler extends AbstractHandler
    {
     
        protected $table;
        protected $connection;
        protected $maxdays;
        protected $isTesting;
     
        /**
         * @param int $level The minimum logging level at which this handler will be triggered
         * @param int $maxdays The days of keep the logs records.
         * @param bool $isTesting Whether the environment is testing or not.
         * @param bool $bubble Whether the messages that are handled can bubble up the stack or not
         */
        public function __construct($level = Logger::DEBUG, $maxdays = 7, $isTesting = false, $bubble = true)
        {
            $this->table      = env('DB_LOG_TABLE', 'logs');
            $this->connection = env('DB_LOG_CONNECTION', env('DB_CONNECTION', 'mysql'));
            $this->maxdays = $maxdays;
            $this->isTesting = $isTesting;
            parent::__construct($level, $bubble);
        }
     
        /**
         * {@inheritdoc}
         */
        public function handle(array $record)
        {
            if ($record['level'] < $this->level) {
                return false;
            }
     
            // write log to mysql table
            $data = [
                'message'     => $record['message'],
                'channel'     => $record['channel'],    // local/provider...
                'level'       => $record['level'],
                'level_name'  => $record['level_name'],
                'context'     => json_encode($record['context']),
                'remote_addr' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null,
                'user_agent'  => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : null,
                'created_at'  => $record['datetime']->format("Y-m-d H:i:s.u"),
            ];
     
            if( null!=$this->connection and null!=$this->table ) {
                DB::connection($this->connection)->table($this->table)->insert($data);
            }
     
            return true;
        }
     
        /**
         * delete old logs
         */
        public function close() {
            parent::close();
            // DB has a no function in case of testing.
            if( !$this->isTesting and null!=$this->connection and null!=$this->table ) {
                DB::connection($this->connection)
                    ->table($this->table)
                    ->where('created_at','<',DB::Raw('DATE_ADD(NOW(), INTERVAL -'.$this->maxdays.' DAY)'))
                    ->delete();
            }
        }
     
    }
  • logsテーブルの作成SQL
    最後にテーブルを作ります。
    -- DROP TABLE logs;
    CREATE TABLE logs (
    	id		BIGINT NOT NULL AUTO_INCREMENT,
    	channel		VARCHAR(10),
    	level		INT,
    	level_name	VARCHAR(10),
    	message		LONGTEXT,
    	context		TEXT,
    	remote_addr	VARCHAR(40),
    	user_agent	TEXT,
    	created_at	TIMESTAMP(6) default CURRENT_TIMESTAMP,
    	CONSTRAINT PRIMARY KEY( id )
    );
    CREATE INDEX logs_idx1 on logs ( remote_addr );
    CREATE INDEX logs_idx2 on logs ( level );

_ 修正・追加するファイル(ログの閲覧)

次にビューワの方を作成していきます。

Laravelではページネーション*4というページ管理機能を持っています。この機能が生成するHTMLは Bootstrap CSSフレームワーク に対応したものです。また、参考にした従来のビューア(rap2hpoutre/laravel-log-viewer)もBootstrapを使用していたことから、今回も全体的にBootstrapを使用しました。

  • route/web.php
    ログビューワのURLを定義します。どこでも良いのですが私はアクセスしやすいトップにしています。
    特定の環境からしか見られないようIPアドレスで制限をかけています。
    フィルタ指定条件などのフォームを扱うため、anyにしています。
      1
      2
      3
      4
      5
    
    // IPアドレス制限をかける
    Route::group(array('middleware' => 'auth.ipaddress'), function () {
        Route::get('oldlogs', '\Rap2hpoutre\LaravelLogViewer\LogViewerController@index');
        Route::any('logs', 'LogViewController@anyView');
    }); 
    (移行時に古いログを見る必要もあるため、従来のログViewer用のURLも残しています)
  • app/Http/Controllers/LogViewController.php
    前項で指定したコントローラです。
      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    
    <?php
     
    namespace App\Http\Controllers;
     
    use DB;
    use Illuminate\Support\Facades\Input;
    use Monolog\Logger;
    use View;
     
    class LogViewController extends Controller {
     
        // 各行のクラス名
        private $levels_classes = [
            'DEBUG' => '',
            'INFO' => 'info',
            'NOTICE' => 'info',
            'WARNING' => 'warning',
            'ERROR' => 'danger',
            'CRITICAL' => 'danger',
            'ALERT' => 'danger',
            'EMERGENCY' => 'danger',
        ];
     
        // 行頭のアイコン
        private $levels_imgs = [
            'DEBUG' => 'debug',    // 出ないけどその方が分かりやすい
            'INFO' => 'info',
            'NOTICE' => 'info',
            'WARNING' => 'warning',
            'ERROR' => 'warning',
            'CRITICAL' => 'warning',
            'ALERT' => 'warning',
            'EMERGENCY' => 'warning',
        ];
     
        // levelでのDB検索用
        private $levels_value = [
            'debug' => Logger::DEBUG,
            'info' => Logger::INFO,
            'notice' => Logger::NOTICE,
            'warning' => Logger::WARNING,
            'error' => Logger::ERROR,
            'critical' => Logger::CRITICAL,
            'alert' => Logger::ALERT,
            'emergency' => Logger::EMERGENCY,
        ];
     
        // level用チェック初期値
        private $level_chk = [
            'debug'=>1,
            'info'=>1,
            'notice'=>1,
            'warning'=>1,
            'error'=>1,
            'critical'=>1,
            'alert'=>1,
            'emergency'=>1,
        ];
     
        // special words(検索文字列において特別な意味を持つワード)
        private $search_sw = [
            'ip:'=>'remote_addr',    // 'ip:'に続く文字列はremote_addrから検索する
            'ua:'=>'user_agent',
            ''=>'message',
        ];
     
        // 1ページの表示件数
        private $rpp_list = [
            25=>'25',
            50=>'50',
            100=>'100'
        ];
     
     
        /**
         * ログViewer: MySQLに格納されたログを表示する
         * @return mixed
         */
        public function anyView() {
            $search = Input::get('search');
            $rpp = intval(Input::get('rpp'));    // rows per page
            $chk_get = Input::get('level_chk');
            $level_chk = [];
     
            // checkの初期値をセット
            if( null == $chk_get ){
                $level_chk = $this->level_chk;
            } else {
                foreach( $this->level_chk as $name => $check ) {
                    $level_chk[$name] = isset($chk_get[$name]) ? $chk_get[$name] : 0;
                }
            }
     
            // 検索(ログに記録されないようログ用のコネクションでアクセス)
            $tmpSql = DB::connection(env('DB_LOG_CONNECTION', env('DB_CONNECTION', 'mysql')))
                ->table('logs')
                ->select('*',DB::raw('concat(created_at) as created_at_ms'))    // _ms=with microsec.
                ->orderBy('created_at','desc');
            if( !empty($search) ){
                // キーワード検索
                foreach( explode(" ", $search) as $keyword ){
                    foreach($this->search_sw as $sw => $field){
                        if( empty($sw) or substr($keyword,0,strlen($sw))==$sw ){
                            $tmpSql = $tmpSql->where($field,'like','%'.substr($keyword,strlen($sw)).'%');
                            break;
                        }
                    }
                }
            }
            // レベルで絞る
            $aryKeys = array_keys($level_chk,true);
            if( count($aryKeys)>0 and count($aryKeys)<count($level_chk) ){
                // 全チェックかノーチェック以外は条件を付ける
                $arySearch = [];
                foreach( $aryKeys as $value ){
                    $arySearch[] = $this->levels_value[$value];
                }
                $tmpSql = $tmpSql->whereIn('level',$arySearch);
            }
            // 検索GO!
            $logs = $tmpSql->paginate($rpp==0 ? current($this->rpp_list) : $rpp );
     
            // 表示用の値をセット
            foreach($logs as $key => $value){
                $logs[$key]->levels_classes = $this->levels_classes[$value->level_name];
                $logs[$key]->levels_imgs = $this->levels_imgs[$value->level_name];
                $uaa = explode(' ',$value->user_agent);
                $logs[$key]->short_user_agent = end($uaa);    // end()の中は必ず変数の必要あり
            }
            // View表示
            return View::make('admin.log_viewer',['logs'=>$logs, 'search'=>$search, 'rpp'=>$rpp, 'rpp_list'=>$this->rpp_list, 'level_chk'=>$level_chk]);
        }
     
    }
  • resources/views/admin/log_viewer.blade.php
    最後にビューです。
      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    
    <!DOCTYPE html>
    <html lang="ja">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width">
        <meta name="author" content="">
        <meta name="description" content="">
        <META name="viewport" content="width=device-width,initial-scale=1.0"/>
        <title>{{$APPTITLE}}Log Viewer</title>
     
        <!-- Bootstrap -->
        <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.1/css/bootstrap.min.css">
        <link rel="stylesheet" href="https://cdn.datatables.net/plug-ins/9dcbecd42ad/integration/bootstrap/3/dataTables.bootstrap.css">
     
    </head>
     
    <BODY marginheight="0" marginwidth="0">
     
    {{--ヘッダ部分--}}
    {{Form::open(['id'=>"topform"])}}
    <div class="container-fluid form-inline">
        {{--タイトル--}}
        <div class="col-sm-4">
            <h2><span class="glyphicon glyphicon-list" aria-hidden="true"></span> Laravel Log Viewer</h2>
        </div>
        <div class="col-sm-8" style="margin-top: 20px; margin-bottom: 0;">
            {{--検索キーワード--}}
            <div class="form-group col-sm-7">
                <label for="search" class="control-label">Search:</label>
                {{Form::input('text','search',$search,['class'=>"form-control", 'id'=>"search", 'placeholder'=>"search string", 'style'=>'width:300px;'])}}
                <div style="margin-left: 60px;">
                    <small id="passwordHelpBlock" class="form-text text-muted">
                        Ex. "ip:127.0.0.1 ua:Firefox any strings"
                    </small>
                </div>
            </div>
            {{--表示件数--}}
            <div class="form-group col-sm-4">
                <label for="rpp" class="control-label">Show</label>
                {{Form::select('rpp',$rpp_list,null,['class'=>'form-control', 'id'=>'rpp'])}}
                <label for="rpp" class="control-label">Entries</label>
            </div>
            {{--updateボタン--}}
            <div class="col-sm-1">
                {{Form::submit('update',['class'=>'btn btn-primary'])}}
            </div>
        </div>
    </div>
     
    {{--明細部分--}}
    <div class="container-fluid">
        <div class="col-sm-12 col-md-12 table-container">
            <div class="row">
                {{--レベルのチェックボックス--}}
                <div class="col-sm-6" style="margin-top: 30px;">
                    @foreach($level_chk as $level_name => $check)
                        {{Form::checkbox("level_chk[$level_name]",true,$level_chk[$level_name],['class'=>'form-check-input','id'=>"chk_$level_name"])}}
                        <label class="form-check-label text-muted" for="chk_{{$level_name}}"><small>{{$level_name}}</small></label>&nbsp;
                    @endforeach
                </div>
                {{--ページャー--}}
                <div class="col-sm-6" align="right">
                    {{ $logs->appends(['rpp'=>$rpp, 'search'=>$search, 'level_chk'=>$level_chk])->links() }}
                </div>
            </div>
            <div class="row">
                <table id="table-log" class="table table-condensed">
                <thead>
                <tr>
                    <th width="10%">Level</th>
                    <th width="8%">IP Adrs</th>
                    <th>UA</th>
                    <th>Date</th>
                    <th>Content</th>
                </tr>
                </thead>
                <tbody>
     
                @foreach($logs as $key => $log)
                    <tr data-display="stack{{$key}}" class="{{$log->levels_classes}}">
                        <td class="text-{{$log->levels_classes}}">
                            <span class="glyphicon glyphicon-{{$log->levels_imgs}}-sign" aria-hidden="true"></span> &nbsp;{{$log->level_name}}
                        </td>
                        <td class="text">{{$log->remote_addr}}</td>
                        <td class="text">{{$log->short_user_agent}}</td>
                        <td class="date">{{$log->created_at_ms}}</td>
                        <td class="text">
                            @if(strpos($log->message,"\n")!==false)
                                <a class="pull-right expand btn btn-default btn-xs" data-display="stack{{$key}}">
                                    <span class="glyphicon glyphicon-search"></span>
                                </a>
                                {{substr($log->message,0,strpos($log->message,"\n"))}}
                            @else
                                {{$log->message}}
                            @endif
                            @if(strpos($log->message,"\n")!==false)
                                <div class="stack" id="stack{{$key}}" style="display: none; white-space: pre-wrap;">{{ trim(substr($log->message,strpos($log->message,"\n"))) }}</div>
                            @endif
                        </td>
                    </tr>
                @endforeach
     
                </tbody>
                </table>
            </div>
            <div align="right">
                {{--最上部へ--}}
                <div class="col-sm-6" align="right" style="margin-top: 20px;" id="totop">
                    <h4 class="glyphicon glyphicon-circle-arrow-up text-muted"></h4>
                </div>
                {{--ページャー--}}
                <div class="col-sm-6" align="right">
                    {{ $logs->appends(['rpp'=>$rpp, 'search'=>$search, 'level_chk'=>$level_chk])->links() }}
                </div>
            </div>
        </div>
     </div>
     {{Form::close()}}
     
     
     <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
     {{Html::script("js/jquery-1.11.0.min.js")}}
     <script>
        $(document).ready(function () {
            // 折りたたみ展開/戻し
            $('.table-container tr').on('click', function () {
                $('#' + $(this).data('display')).toggle();
            });
        });
        $('#rpp').on('change', function () {
            $('#topform').submit();
        });
        $('#totop').on('click', function() {
            window.scrollTo(0,0);
        });
    </script>
     
    </body>
    </HTML> 

    以上で完成です!

    うまく動かない場合にデバッグが難しい(ログが出ないから)のですが、「var_dump() & exit;」を駆使するか、xdebugでステップ実行するなどして頑張ってみてください。


_ ソースコード中のuse文について

私はPhpStormで無駄な警告が出ないよう、Laravel IDE Helperを入れています。そのため、 use DB; なんて書き方をしています。IDE Helperを導入していない方は自分の環境に合わせて書き換えてくださいm(_ _)m。

_ SQL文をログ出力している場合の注意

SQL文をログ出力している場合、「SQL実行→LOG出力→LOGをDBに出力→SQL実行→・・・」の無限ループになります。
このため、ログ出力の為のSQL文をログ出力しないようif文を入れる必要があります。
私は app/Providers/AppServiceProvider.php でログ出力していたので、以下のように処理を追加しました。

  1
  2
  3
  4
  5
  6
  7
  8
  9
public function boot()
 {
    // DBのSQLをログ出力する
    DB::listen(function ($query) {
        // LogのDBへの書き出しはログ出力しない(無限loopになる)
        if( $query->connectionName != env('DB_LOG_CONNECTION', env('DB_CONNECTION', 'mysql')) ) {
            Log::info("Query Time:[$query->time] $query->sql, data:[[" . implode(', ', $query->bindings). "]]");
        }
    }); 

_ unit test中のログについて

ログをDBに出力する場合、phpunitのテストで問題があります。

1つはhandlerのclose()処理でDBアクセスが出来ないこと。理由は分かりませんが、testing環境の場合は呼ばれるタイミング的にオブジェクトが解放(?)されてしまっているのかもしれません。このため、ログを削除するタイミングがありません。テスト環境の初期化時(tests\TestCase\setUp())あたりで初期化してあげてください。

もう一つはテスト環境用にDatabaseを分けている場合、そちらにログ出力されてしまい、ビューワで見られない事です。ログだけ本番用に格納するのも気持ち悪いので、ビューワ側で読めるようにしたいのですが、本番環境でtesting環境のDBアクセスがまだ上手くいっていません。

暫定的に、testing環境の時はbootstrap/app.phpでのhandlerのカスタマイズを行わないようにしていますが、$_ENVが必ずしも無い時もあるようで・・・暫定処理です(^^;)。

まあ、おいおい・・・(ぉぃぉぃ)。

Category: [Linux] - 17:06:03



 


ファイルベースでページングや検索を行う場合、ファイル全体を読み込まなければならず、ログが大きくなると非常に重くなる。
DB化することで検索の高速化、途中のログの抽出の低負荷化を実現できる。


LaravelのログをMysqlで管理する。
https://laravel.cg0.xyz/laravel-mysql-email-log/


Laravelのログを標準エラーに出力する
https://qiita.com/iakio/items/86086e046f73826c9bef


Laravel 5.4 データベース:ペジネーション
https://readouble.com/laravel/5.4/ja/pagination.html