--> -->

skimemo


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


  • 追加された行はこの色です。
  • 削除された行はこの色です。
#blog2navi()
*Laravel5.4のログをMySQLに出力して閲覧する [#z88053ca]

~Laravelのログは標準ではファイルに書き出されます。
~量が少ないうちは良いのですが、多くなってくるとビューワー側がつらくなってきます。((ファイルベースでページングや検索を行う場合、ファイル全体を読み込まなければならず、ログが大きくなると非常に重くなる。&br;DB化することで検索の高速化、途中のログの抽出の低負荷化を実現できる。))~
~そこで、MySQLにログを出力し、そこから読み出し、検索する処理を書いてみました。~
なお、パッケージとして追加する方法もあるのですが、''手動で追加する方法''にしています。~
気が向いたらパッケージ化するかも・・・(^^;)。~
CENTER:&ref(e1.PNG);

** 修正・追加するファイル(概要) [#p270e838]
修正、追加が必要なファイルは以下の通りです。~
-- .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つずつ見ていきましょう。
** 修正・追加するファイル(ログの記録) [#td119ec6]
この記事ではログの記録と閲覧を行いますが、まずはログの記録編です。
- .env~
参考文献1((LaravelのログをMysqlで管理する。&br;[[https://laravel.cg0.xyz/laravel-mysql-email-log/]]))で実施している通り、トランザクション処理を考慮するとDBの接続はアプリ本体と分ける必要があります。そのための情報を記載します。
 DB_LOG_TABLE=logs
 DB_LOG_CONNECTION=mysql_log

- config/database.php~
前項で指定した「mysql_log」を定義します。~
基本的には定義済みの「mysql」のコピペです。
#code(php){{
'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((Laravelのログを標準エラーに出力する&br;https://qiita.com/iakio/items/86086e046f73826c9bef))にも書かれているように、Monologの新たなHandlerを追記する方法として、bootstrap/app.php に以下の処理を追加します。
#code(php){{
/** monologのhandlerカスタマイズ */
$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
	));
});
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
		));
	});
}
}}
環境変数&color(black,lightgray){APP_ENV};を見て、テスト環境の時は通常のログ出力を行うようにしています(後述)。
- app/Log/MySqlHandler.php~
次に前項で指定した新しいHandlerを作成します。
#code(php){{
<?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 default CURRENT_TIMESTAMP,
	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を使用しました。
- route/web.php~
ログビューワのURLを定義します。どこでも良いのですが私はアクセスしやすいトップにしています。~
特定の環境からしか見られないようIPアドレスで制限をかけています。~
フィルタ指定条件などのフォームを扱うため、anyにしています。
#code(php){{
// IPアドレス制限をかける
Route::group(array('middleware' => 'auth.ipaddress'), function () {
	Route::get('oldlogs', '\Rap2hpoutre\LaravelLogViewer\LogViewerController@index');
	Route::any('logs', 'LogViewController@anyView');
});
}}
&size(11){(移行時に古いログを見る必要もあるため、従来のログViewer用のURLも残しています)};
- app/Http/Controllers/LogViewController.php~
前項で指定したコントローラです。
#code(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');
		$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')->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~
最後にビューです。
#code(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>{{$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}}</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文について [#j9272dfa]
私は[[PhpStorm:https://www.jetbrains.com/phpstorm/]]で無駄な警告が出ないよう、[[Laravel IDE Helper:https://qiita.com/michiomochi@github/items/fc70230402972c99472f]]を入れています。そのため、 &color(black,lightgray){use DB;}; なんて書き方をしています。IDE Helperを導入していない方は自分の環境に合わせて書き換えてくださいm(_ _)m。

** SQL文をログ出力している場合の注意 [#ga675609]
SQL文をログ出力している場合、「SQL実行→LOG出力→LOGをDBに出力→SQL実行→・・・」の無限ループになります。~
このため、ログ出力の為のSQL文をログ出力しないようif文を入れる必要があります。~
私は &color(black,lightgray){app/Providers/AppServiceProvider.php}; でログ出力していたので、以下のように処理を追加しました。
#code(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). "]]");
		}
	});
}}
** unit test中のログについて [#j7b60afe]
~ログをDBに出力する場合、phpunitのテストで問題があります。
~1つはhandlerのclose()処理でDBアクセスが出来ないこと。理由は分かりませんが、testing環境の場合は呼ばれるタイミング的にオブジェクトが解放(?)されてしまっているのかもしれません。このため、ログを削除するタイミングがありません。テスト環境の初期化時(tests\TestCase\setUp())あたりで初期化してあげてください。
~もう一つはテスト環境用にDatabaseを分けている場合、そちらにログ出力されてしまい、ビューワで見られない事です。ログだけ本番用に格納するのも気持ち悪いので、ビューワ側で読めるようにしたいのですが、本番環境でtesting環境のDBアクセスがまだ上手くいっていません。
~暫定的に、testing環境の時はbootstrap/app.phpでのhandlerのカスタマイズを行わないようにしていますが、$_ENVが必ずしも無い時もあるようで・・・暫定処理です(^^;)。
~まあ、おいおい・・・(ぉぃぉぃ)。
#htmlinsert(twitterbutton.html)
RIGHT:Category: &#x5b;[[Linux>日記/Category/Linux]]&#x5d; - 17:06:03
----
RIGHT:&blog2trackback();
#comment(above)
#blog2navi()