--> -->
#blog2navi() *Laravel5.4のログをMySQLに出力して閲覧する [#z88053ca] Laravelのログは標準ではファイルに書き出されます。 量が少ないうちは良いのですが、多くなってくるとビューワー側がつらくなってきます。 そこで、MySQLにログを出力し、そこから読み出し、検索する処理を書いてみました。 なお、パッケージとして追加する方法もあるのですが、手動で追加する方法にしています。 気が向いたらパッケージ化するかも・・・(^^;)。 ~Laravelのログは標準ではファイルに書き出されます。 ~量が少ないうちは良いのですが、多くなってくるとビューワー側がつらくなってきます。((ファイルベースでページングや検索を行う場合、ファイル全体を読み込まなければならず、ログが大きくなると非常に重くなる。&br;DB化することで検索の高速化、途中のログの抽出の低負荷化を実現できる。))~ ~そこで、MySQLにログを出力し、そこから読み出し、検索する処理を書いてみました。~ なお、パッケージとして追加する方法もあるのですが、''手動で追加する方法''にしています。~ 気が向いたらパッケージ化するかも・・・(^^;)。~ CENTER:&ref(e1.PNG); ** 修正・追加するファイル(概要) [#p270e838] 修正、追加が必要なファイルは以下の通りです。~ -- [[.env>#env]] -- [[config/database.php>#database]] -- [[config/app.php>#configapp]] -- [[bootstrap/app.php>#bootstrapapp]] -- [[app/Log/MySqlHandler.php>#mysqlhandler]] -- [[route/web.php>#web]] -- [[app/Http/Controllers/LogViewController.php>#logviewcontroller]] -- [[resources/views/admin/log_viewer.blade.php>#logviewer]] -- [[logsテーブルの作成SQL>#create]] 次の項で1つずつ見ていきましょう。 ** 修正・追加するファイル(ログの記録) [#td119ec6] この記事ではログの記録と閲覧を行いますが、まずはログの記録編です。 - &aname(env);.env~ 参考文献1((LaravelのログをMysqlで管理する。&br;[[https://laravel.cg0.xyz/laravel-mysql-email-log/]]))で実施している通り、トランザクション処理を考慮するとDBの接続はアプリ本体と分ける必要があります。そのための情報を記載します。 DB_LOG_TABLE=logs DB_LOG_CONNECTION=mysql_log - &aname(database);config/database.php~ 前項で指定した「mysql_log」を定義します。~ 基本的には定義済みの「mysql」のコピペです。~ また、log用変数を.envから読み込む設定を追加します。 #code(php,nonumber,nooutline){{ '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, ], }} - &aname(configapp);config/app.php~ ログの保存日数を定義します。 'log_max_days' => env('APP_LOG_MAX_DAYS', 7), これで、.envファイルに以下のように書けばそちらが優先されます。 APP_LOG_MAX_DAYS=7 上記の指定で、7日以上前のログは随時削除されていきます。 - &aname(bootstrapapp);bootstrap/app.php~ 参考文献2((Laravelのログを標準エラーに出力する&br;https://qiita.com/iakio/items/86086e046f73826c9bef))にも書かれているように、Monologの新たなHandlerを追記する方法として、bootstrap/app.php に以下の処理を追加します。 #code(php,nonumber,nooutline){{ /** 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 )); }); } }} 環境変数 &inlinecode{APP_ENV}; を見て、テスト環境の時は通常のログ出力を行うようにしています(後述)。 - &aname(mysqlhandler);app/Log/MySqlHandler.php~ 次に前項で指定した新しいHandlerを作成します。 #code(php,nonumber,nooutline){{ <?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(); } } } }} - &aname(create);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 ); ** 修正・追加するファイル(ログの閲覧) [#h17b6894] ~次にビューワの方を作成していきます。 ~Laravelではページネーション((Laravel 5.4 データベース:ペジネーション&br;[[https://readouble.com/laravel/5.4/ja/pagination.html]]))というページ管理機能を持っています。この機能が生成するHTMLは [[Bootstrap CSSフレームワーク:https://getbootstrap.com/]] に対応したものです。また、参考にした従来のビューア([[rap2hpoutre/laravel-log-viewer:https://github.com/rap2hpoutre/laravel-log-viewer]])もBootstrapを使用していたことから、今回も全体的にBootstrapを使用しました。 - &aname(web);route/web.php~ ログビューワのURLを定義します。どこでも良いのですが私はアクセスしやすいトップにしています。~ 特定の環境からしか見られないようIPアドレスで制限をかけています。~ フィルタ指定条件などのフォームを扱うため、anyにしています。 #code(php,nonumber,nooutline){{ // IPアドレス制限をかける Route::group(array('middleware' => 'auth.ipaddress'), function () { Route::get('oldlogs', '\Rap2hpoutre\LaravelLogViewer\LogViewerController@index'); Route::any('logs', 'LogViewController@anyView'); }); }} &size(11){(移行時に古いログを見る必要もあるため、従来のログViewer用のURLも残しています)}; - &aname(logviewcontroller);app/Http/Controllers/LogViewController.php~ 前項で指定したコントローラです。 #code(php,nonumber,nooutline){{ <?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]); } } }} - &aname(logviewer);resources/views/admin/log_viewer.blade.php~ 最後にビューです。 #code(php,nonumber,nooutline){{{{ <!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> @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> {{$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文について [#j9272dfa] 私は[[PhpStorm:https://www.jetbrains.com/phpstorm/]]で無駄な警告が出ないよう、[[Laravel IDE Helper:https://qiita.com/michiomochi@github/items/fc70230402972c99472f]]を入れています。そのため、 &inlinecode{use DB;}; なんて書き方をしています。IDE Helperを導入していない方は自分の環境に合わせて書き換えてくださいm(_ _)m。 ** SQL文をログ出力している場合の注意 [#ga675609] SQL文をログ出力している場合、「SQL実行→LOG出力→LOGをDBに出力→SQL実行→・・・」の無限ループになります。~ このため、ログ出力の為のSQL文をログ出力しないようif文を入れる必要があります。~ 私は &inlinecode{app/Providers/AppServiceProvider.php}; でログ出力していたので、以下のように処理を追加しました。 #code(php,nonumber,nooutline){{ 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中のログについて [#j7b60afe] ~ログを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追記) [#m4141bb8] 環境によるのかも知れませんが、時々.envの読み込みに失敗する事があるようです。~ このため、読み込みに失敗してもヘンな事にならないよう、log関係の設定をdatabase.phpで読み込み、デフォルト値も設定するようにしました。(それまではController内やHandler内で直接env()で読んでいた)~ 本番環境ではちゃんと &inlinecode{php artisan config:cache}; しましょう。 #htmlinsert(twitterbutton.html) RIGHT:Category: [[[Linux>日記/Category/Linux]]] - 17:06:03 ---- RIGHT:&blog2trackback(); #comment(above) #blog2navi()