--> -->
Laravelのログは標準ではファイルに書き出されます。
量が少ないうちは良いのですが、多くなってくるとビューワー側がつらくなってきます。*1
そこで、MySQLにログを出力し、そこから読み出し、検索する処理を書いてみました。
なお、パッケージとして追加する方法もあるのですが、手動で追加する方法にしています。
気が向いたらパッケージ化するかも・・・(^^;)。
修正、追加が必要なファイルは以下の通りです。
次の項で1つずつ見ていきましょう。
この記事ではログの記録と閲覧を行いますが、まずはログの記録編です。
DB_LOG_TABLE=logs DB_LOG_CONNECTION=mysql_log
'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,
],
'log_max_days' => env('APP_LOG_MAX_DAYS', 7),これで、.envファイルに以下のように書けばそちらが優先されます。
APP_LOG_MAX_DAYS=7上記の指定で、7日以上前のログは随時削除されていきます。
/** 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
));
});
}
<?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();
}
}
}
-- 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を使用しました。
// IPアドレス制限をかける
Route::group(array('middleware' => 'auth.ipaddress'), function () {
Route::get('oldlogs', '\Rap2hpoutre\LaravelLogViewer\LogViewerController@index');
Route::any('logs', 'LogViewController@anyView');
});
<?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]);
}
}
<!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>
@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-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でステップ実行するなどして頑張ってみてください。
私はPhpStormで無駄な警告が出ないよう、Laravel IDE Helperを入れています。そのため、 use DB; なんて書き方をしています。IDE Helperを導入していない方は自分の環境に合わせて書き換えてくださいm(_ _)m。
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 != env('DB_LOG_CONNECTION', env('DB_CONNECTION', 'mysql')) ) {
Log::info("Query Time:[$query->time] $query->sql, data:[[" . implode(', ', $query->bindings). "]]");
}
});
ログをDBに出力する場合、phpunitのテストで問題があります。
1つはhandlerのclose()処理でDBアクセスが出来ないこと。理由は分かりませんが、testing環境の場合は呼ばれるタイミング的にオブジェクトが解放(?)されてしまっているのかもしれません。このため、ログを削除するタイミングがありません。テスト環境の初期化時(tests\TestCase\setUp())あたりで初期化(delete from logs;)してあげてください。
もう一つはテスト環境用にDatabaseを分けている場合、そちらにログ出力されてしまい、ビューワで見られない事です。ログだけ本番用に格納するのも気持ち悪いですし、ビューワ側で読めるようにしたいのですが、本番環境でtesting環境のDBアクセスがまだ上手くいっていません。
暫定的に、testing環境の時はbootstrap/app.phpでのhandlerのカスタマイズを行わないようにしていますが、$_ENVが必ずしも無い時もあるようで・・・暫定処理です(^^;)。
まあ、おいおい・・・(ぉぃぉぃ)。
Tweet
ファイルベースでページングや検索を行う場合、ファイル全体を読み込まなければならず、ログが大きくなると非常に重くなる。
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