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を作成します。
    <?phpnamespace 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
    前項で指定したコントローラです。
    <?phpnamespace 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