skimemo


skimemo - 日記/2018-09-03/Laravel5.4のログをMySQLに出力して閲覧する

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

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

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

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

e1.PNG

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

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

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

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

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

  • .env
    参考文献1*2で実施している通り、トランザクション処理を考慮するとDBの接続はアプリ本体と分ける必要があります。そのための情報を記載します。
    DB_LOG_TABLE=logs
    DB_LOG_CONNECTION=mysql_log
  • config/database.php
    前項で指定した「mysql_log」を定義します。
    基本的には定義済みの「mysql」のコピペです。
    また、log用変数を.envから読み込む設定を追加します。
        'default' => env('DB_CONNECTION', 'mysql'),
        'log' => env('DB_LOG_CONNECTION', 'mysql_log'),
        'logtable' => env('DB_LOG_TABLE', 'logs'),
    
            :
    
        'connections' => [
            :
        '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 に以下の処理を追加します。
    /** 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を作成します。
    <?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      = config()->get('database.logtable');
            $this->connection = config()->get('database.log');
            $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(6),
    	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にしています。
    // 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
    前項で指定したコントローラです。
    <?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');
            $delete = Input::get('delete');
            $level_chk = [];
    
            if( $delete == 'all' ){
                DB::connection(config()->get('database.log'))->table('logs')->delete();
                return redirect(route('logs',['rpp'=>$rpp]));        // URLから'delete=all'を消すためredirectする
            }
    
            // 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(config()->get('database.log'))
                ->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
    最後にビューです。
    <!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>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">
    
        <script>
            // 削除確認
            function confirmDelete(){
                return ( confirm("!DELETE ALL LOGS! " + "Are you sure?") );
            }
        </script>
    
    </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-2 text-muted" align="left" style="margin-top: 30px;">
                    <a href="{{URL::route('logs',"delete=all&rpp=$rpp")}}" onclick="return confirmDelete();">Delete all logs</a>
                </div>
                <div class="col-sm-4" align="right" style="margin-top: 20px;" id="totop">
                    <a href="#"><h4 class="glyphicon glyphicon-circle-arrow-up text-muted"></h4></a>
                </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 でログ出力していたので、以下のように処理を追加しました。

public function boot()
 {
    // DBのSQLをログ出力する
    DB::listen(function ($query) {
        // LogのDBへの書き出しはログ出力しない(無限loopになる)
        if( $query->connectionName !=  config()->get('database.log') ) {
            Log::info("Query Time:[$query->time] $query->sql, data:[[" . implode(', ', $query->bindings). "]]");
        }
    }); 

_ unit test中のログについて

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

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

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

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

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

_ .envの読み込み失敗について(2019/2/25追記)

環境によるのかも知れませんが、時々.envの読み込みに失敗する事があるようです。
このため、読み込みに失敗してもヘンな事にならないよう、log関係の設定をdatabase.phpで読み込み、デフォルト値も設定するようにしました。(それまではController内やHandler内で直接env()で読んでいた)
本番環境ではちゃんと php artisan config:cache しましょう。

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


 
Last-modified: 2019-02-25 (月) 16:05:29 (28d)