開発進行中も本番モードでの運用時も、ソフトウェアアプリケーションにおいてロギングは大切です。
サーバーを運用しているなら、サーバーサイドの言語選択にかかわりなく無数のライブラリーを利用でき、広範に及ぶストレージメカニズムやログ出力を扱う際の各種ツールも使えます。
しかし、クライアント側アプリケーションとなるとロギングは見過ごされがちで、利用できる手法もかなり限られています。
この記事ではクライアント側アプリケーション、特にJavaScriptを中心としたシングルページアプリケーション(SPA)におけるロギングの実装方法を紹介します。
コンソール
エラーとメッセージのロギング方法でもっとも一般的かつ分かりやすいのは、おそらくコンソールの使用でしょう。基本的な手法に思えますが、開発中のデバッグにおいてコンソールがとても重要なツールであることには疑問の余地がなく、ここから始めるのがおすすめです。
察しがつくように、特にIEにおいてconsoleオブジェクトの実装方法はいつも同じというわけではありません。とはいえ、次の4つの主要なメソッドは一般的に利用可能です。
console.log()
console.info()
console.warn()
console.error()
上記4つのメソッドのそれぞれの出力には微妙な違いがあり、実装されている大半のコンソール(デベロッパーツール)では使われるメソッド、つまりロギングのレベルによってメッセージをフィルタリングできます。
ブラウザー間のばらつきを軽減するため、たとえば、『this one from Paul Irish』で説明されているようなラッパー関数を利用できます。WHATWGはconsole APIの標準化を試みているものの、この仕様はまだ初期段階にあって実装の見込みは当分なさそうです。
ヒント:コードでconsole.log()構文をよく使うなら、アプリケーションを製品化する際、Gruntにはgrunt-remove-loggingやgrunt-strip、Gulpにはgulp-strip-debugなどのツールが役立ちます。
コンソールの機能強化
コンソールを「パワーアップ」できるライブラリーを2つ紹介します。
■Logdown
Logdownはコンソールの拡張機能を提供する軽量ライブラリーです。
Logdownでは、インスタンス化の際にプレフィックスを指定できます。以下は、ログメッセージをモジュールごとに分けられる一例です。
var uiLogger = new Logdown({prefix: 'MyApp:UI'});
var networkServiceLogger = new Logdown({prefix: 'MyApp:Network'});
さらに、次のようにプレフィックスに従ってロガーの有効・無効を設定できます。
Logdown.disable('MyApp:UI');
Logdown.enable('MyApp:Network');
Logdown.disable('MyApp:*'); // wildcards are supported, too
ロガーを無効にして効果的に停止できます。
インスタンス化が完了したロガーではlog()、warn()、info()、error()メソッドを使ってメッセージをロギングできます。
var logger = new Logdown();
logger.log('Page changed');
logger.warn('XYZ has been deprecated in favour of 123');
logger.info('Informational message here');
logger.error('Server API not available!');
Logdownは次のようにMarkdown記法にも対応しています。
var logger = new Logdown({markdown: true}); // Technically "markdown: true" isn't required; it's enabled by default
logger.warn('_XYZ_ has been *deprecated* in favour of _123_');
■console.message
もう1つのライブラリーとして、コンソールの出力を美しく表示するconsole.messageがあります。
下の短いアニメーションは、ドキュメントにあるconsole.messageの機能の一部を示しています。
テキストのフォーマット、メッセージのグループ化と折り畳み、対話型のDOM要素やオブジェクトのログへの送信、画像の読み込みといったメソッドに関し、ライブラリーは基本的にメソッドチェーン対応のインターフェイスを提供しています。
コンソールの限界
コンソールはアプリケーションの構築中に大きな役割を果たし、開いた状態にしておけますが、たまたまユーザーを監視しているときにユーザーもブラウザーでWebコンソールを開いているという状況でなければ、結果が得られません。
代案として、リモートでアクセスできるようにエラーや開発中のデバッグメッセージをどこかのサーバーに送信できます。
検討できる別の方法
ここまで利用できそうな方法を説明してきましたが、さらに考えられる手法を解説します。
グローバルエラーの捕捉
控えめに言っても、ハンドルされていない例外の捕捉とロギングには価値があり、window.onerrorを使って実行できます。次にとてもシンプルな例を示します。
window.onerror = function(message, file, line) {
console.log('An error occured at line ' + line + ' of ' + file + ': ' + message);
};
スタックトレース
スタックトレースではエラーが発生するとより詳しい情報が提供されるので、開発時に使うとメリットがあります。スタックトレースの組み込みに役立つライブラリーを2つ紹介します。
■TraceKit
TraceKitを使えば例外にスタックトレースを埋め込み、例外を定期的に読み取って、たとえば、サーバ側のロギングコンポーネントに送るなど、なんらかの処理を実行できます。
コードは次のようになります。
TraceKit.report.subscribe(function yourLogger(errorReport) {
//send via ajax to server, or use console.error in development
//to get you started see: https://gist.github.com/4491219
});
アプリケーション側のコードは次のとおりです。
try {
/*
* your application code here
*
*/
throw new Error('oops');
} catch (e) {
TraceKit.report(e); //error with stack trace gets normalized and sent to subscriber
}
■stacktrace.js
ドキュメントによると、stacktrace.jsは「あらゆるWebブラウザーにおけるスタックトレース取得のためのフレームワーク非依存型マイクロライブラリー」です。
提供されているprintStackTrace()メソッドはエラーハンドラー内で使用でき、スタックトレースをロギング機能に追加できます。たとえばサーバー側のロガーを次のように拡張できます。
function log(data, level) {
$.post(
'https://your-app.com/api/logger',
{
context : navigator.userAgent,
level : level || 'error',
data : data,
stack_trace : printStackTrace()
}
);
}
クライアント側のエラーをロギングしてサーバーに送信する
ログエントリをサーバーに送信することには、次のようなたくさんのメリットがあります。
- 物理的に、作業に使用中のコンピューターからでなくても、アプリケーションからログエントリを捕捉できる
- サーバー側とクライアント側のログを同じ場所で、同じツールを使って管理できる
- アラート、たとえば、深刻なエラー発生時のSlack通知やSMSなどを設定できる
- コンソールの使用や閲覧ができない場合、モバイルデバイスでWebを開いているときなどでも、起きていることを把握しやすくなる
ログをサーバーに送信する方法を説明します。
ユーザーのサーバー側ロガーを使う
場合によっては、ユーザーのサーバー側のロギングメカニズムを活用するのがもっともシンプルでしょう。
以下はjQueryを使用した、とてもシンプルなクライアント側のコード例です。
function log(data, level) {
$.post(
'https://your-app.com/api/logger',
{
context : navigator.userAgent,
level : level || 'error',
data : data
}
);
}
たとえば、次のように使えます。
try {
// some function
} catch (e) {
log({
error : e.message
});
}
log('Informational message here', 'info');
これを踏まえて、上の例に対応するとても基本的なサーバー側コンポーネントを以下に示します。Expressを使ってNode.jsでビルドされており、優れたロギングライブラリー「Winston」を使用しています。
/**
* Load the dependencies
*/
var express = require( 'express' );
var bodyParser = require('body-parser');
var winston = require( 'winston' );
/**
* Create the Express app
*/
var app = express();
app.use(bodyParser.urlencoded({ extended: true }));
/**
* Instantiate the logger
*/
var logger = new ( winston.Logger )({
transports: [
new ( winston.transports.Console )(
{
level: 'error'
}
),
new ( winston.transports.DailyRotateFile )(
{
filename: 'logs/client.log',
datePattern: '.yyyy-MM-dd'
}
)
]
});
app.post ('/api/logger', function( req, res, next ) {
logger.log(
req.body.level || 'error',
'Client: ' + req.body.data
);
return res.send( 'OK' );
});
var server = app.listen( 8080, function() {
console.log( 'Listening on port %d', server.address().port );
});
実際、このロガーは単純化され過ぎているため、次のような基本的な制限があります。
- たいていのロギングメカニズムでは、ある種のエントリをフィルタリングして除外できるようにロギングのレベルが最低に設定されている
- ログエントリーが直接送られるので、サーバー側コンポーネントの過負荷状態を招くおそれがある
2番目の課題の改善策は、ログエントリーをバッファして分割送信することです。一般的な手法はlocalStorageを使ってログエントリーを保管し、次いで保留状態のエントリ数が一定の閾値に達したときやwindow.onbeforeunloadイベントを使ってユーザーがウィンドウを閉じたりアプリケーションから離脱したりするときの時間をベースに、特定の間隔でログエントリを送信するというものです。
JSアプリから、上の課題に対処できるロギング用の既存の手法を説明します。
log4javascript
log4javascriptは広く使われPHP版もリリースされているJava用ロギングフレームワーク「log4j」をベースとしているため、サーバー側のエンジニアにはすでにおなじみでしょう。
log4javascriptはアペンダー(appender)のコンセプトを使用しており、ロギングメソッドのうちの1つが呼び出されたときに発生している事象を判断します。たいていのモダンブラウザーで提供されているデベロッパーツールを使っているのなら、デフォルトのPopUpAppenderはおそらく不要でしょう。
より使い勝手がいいのはおそらくAjaxAppenderで、サーバーにログエントリーを返せます。setTimed()メソッドで設定した時間間隔で、setBatchSize()メソッドで指定した一定数ごと、またはsetSendAllOnUnload()を使ってウィンドウがアンロードされたタイミングでまとめてエントリーを送信するようにAjaxAppenderを構成できます。
log4javascriptはSourceforgeからダウンロードでき、類似のLog4jsはGithubで入手できます。クイックスタートガイドを参考にすれば、すぐに起動、運用できます。
以下に例を示します。
var log = log4javascript.getLogger();
var ajaxAppender = new log4javascript.AjaxAppender('http://example.com/api/logger');
ajaxAppender.setThreshold(log4javascript.Level.ERROR);
ajaxAppender.setBatchSize(10); // send in batches of 10
ajaxAppender.setSendAllOnUnload(); // send all remaining messages on window.beforeunload()
log.addAppender(ajaxAppender);
指定した間隔でメッセージを送信するコードは次のとおりです。
ajaxAppender.setTimed(true);
ajaxAppender.setTimerInterval(10000); // send every 10 seconds (unit is milliseconds)
その他のライブラリー
プロジェクトで jQueryを使っているなら、Ajax経由でロギングが可能な jquery loggerがおすすめですが、分割送信には対応していません。とはいえバックエンドとしてAirbrakeとの高い親和性があります。
loglevelは軽量かつ拡張可能なJSベースのロギングフレームワークで、独立したserverSendプラグインを経由するとAjaxに対応できます。
分割対応のロガーを作って動かしてみる
以下はメッセージを分割送信するロガーのコンセプト検証用の、シンプルなコードです。ES6仕様のJavaScriptで書かれています。
"use strict";
class Logger {
// Log levels as per https://tools.ietf.org/html/rfc5424
static get ERROR() { return 3; }
static get WARN() { return 4; }
static get INFO() { return 6; }
static get DEBUG() { return 7; }
constructor(options) {
if ( !options || typeof options !== 'object' ) {
throw new Error('options are required, and must be an object');
}
if (!options.url) {
throw new Error('options must include a url property');
}
this.url = options.url;
this.headers = options.headers || [ { 'Content-Type' : 'application/json' } ];
this.level = options.level || Logger.ERROR;
this.batch_size = options.batch_size || 10;
this.messages = [];
}
send(messages) {
var xhr = new XMLHttpRequest();
xhr.open('POST', this.url, true);
this.headers.forEach(function(header){
xhr.setRequestHeader(
Object.keys(header)[0],
header[Object.keys(header)[0]]
);
});
var data = JSON.stringify({
context : navigator.userAgent,
messages : messages
});
xhr.send(data);
}
log(level, message) {
if (level <= this.level) {
this.messages.push({
level : level,
message : message
});
if (this.messages.length >= this.batch_size) {
this.send(this.messages.splice(0, this.batch_size));
}
}
}
error(message) {
this.log(Logger.ERROR, message);
}
warn(message) {
this.log(Logger.WARN, message);
}
info(message) {
this.log(Logger.INFO, message);
}
debug(message) {
this.log(Logger.DEBUG, message);
}
}
使い方はシンプルです。
var logger = new Logger({
url : 'http://example.com/api/batch-logger',
batch_size : 5,
level : Logger.INFO
});
logger.debug('This is a debug message'); // No effect
logger.info('This is an info message');
logger.warn('This is a warning');
logger.error('This is an error message');
logger.log(Logger.WARN, 'This is a warning');
サーバー側をセルフホストする場合
Errbit
Errbitはエラー捕捉用のオープンソースのセルフホストソリューションです。Rubyで作成されていて、ストレージにMongoDBを使用します。
Chef cookbookやDockerfileを使って手軽にErrbitを導入できます。オンラインデモも試用できます。
オンラインデモにサインインするには、eメールアドレスに「demo@errbit-demo.herokuapp.com」パスワードに「password」と入力してください。
サーバー側にSaaSを使う場合
ロギング用のSaaSソリューションはLoggly、{track.js}、{errorception}、Airbrake、New Relicなどたくさんあります。
Logglyと{track.js}を簡単に紹介します。
Loggly
数多いSaaSソリューションの1つにLogglyがあります。Logglyは分かりやすく無料で始められるので、例として使います。無料プランでは1日200MBまでロギングでき、データは7日間保持されます。
クライアント側アプリケーションからLogglyを使う場合、以下のスニペットを組み込む必要があります。
<script type="text/javascript" src="http://cloudfront.loggly.com/js/loggly.tracker.js" async></script>
<script>
var _LTracker = _LTracker || [];
_LTracker.push({'logglyKey': 'YOUR-LOGGING-KEY',
'sendConsoleErrors' : true });
</script>
注:YOUR-LOGGING-KEYはアプリケーションに固有の値に置き換える必要があり、サインアップ・ログイン後「Source Setup」画面で取得します。
このコードをよく見ると、_LTrackerオブジェクトがデフォルトで配列としてインスタンス化されていることが分かります。多くの分析ライブラリーで使われているいわゆる「シム(shim)」テクニックで、ライブラリーのロード完了前にpush()メソッドを呼び出せることを意味します。配列にプッシュしたエラーやメッセージはライブラリーが利用可能になるときに備えてキューアップされます。
使い方はシンプルです。
_LTracker.push(data);
これを使ってテキストのスニペットを出力できます。
_LTracker.push( 'An error occured: ' + e.message );
または、次の例のように、もっと便利にJSONを使えます。
try {
// some operation
} catch (e) {
_LTracker.push({
level : 'error',
message : e.message,
trace : e.trace,
context : navigator.userAgent
});
}
かなり基本的な方法とはいえ、シンプルに次のコードを使ってエラーを捕捉できます。
window.onerror = function(message, file, line) {
_LTracker.push({
context: navigator.userAgent,
error: message,
file: file,
line: line
});
};
この方法にはいくつかの制限があります。ビルドの内容に微妙な違いがあったりJSコードが圧縮されていたりすると、行番号は事実上役に立ちません。
また先に紹介したLogglyのスニペットでsendConsoleErrorsがTRUEに設定されているので、ある種のエラーは手動で送信しなくても自動的にロギングされます。たとえば以下はRequireJSでタイムアウトが発生するとLogglyに送信するコードです。
{
"category": "BrowserJsException",
"exception": {
"url": "http://example.com/js/require.js",
"message": "Uncaught Error: Load timeout for modules: main\nhttp://requirejs.org/docs/errors.html#timeout",
"lineno": 141,
"colno": 15
},
"sessionId": "xyz-123-xyz-123"
}
{track.js}
{track.js}もロギング用のSaaSソリューションです。
無料プランが提供されていますがエラーは1分10個、ヒットは月1万個、データ保持は24時間に限定されています。もっとも基本的なプランは月額29.99ドルです。詳しくは価格設定のページを参照してください。
注:「ヒット」はライブラリーが初期化されるたびに記録されます。
設定は簡単です。
<!-- BEGIN TRACKJS -->
<script type="text/javascript">window._trackJs = { token: 'YOUR-TOKEN-HERE' };</script>
<script type="text/javascript" src="//d2zah9y47r7bi2.cloudfront.net/releases/current/tracker.js" crossorigin="anonymous"></script>
<!-- END TRACKJS -->
引き込みライブラリーの初期化を完了した適切なファイルは、track()などのメソッドが使えるようになります。
/**
* Directly invokes an error to be sent to TrackJS.
*
* @method track
* @param {Error|String} error The error to be tracked. If error does not have a stacktrace, will attempt to generate one.
*/
trackJs.track("Logical error: state should not be null");
try {
// do something
} catch (e) {
trackJs.track(e);
}
コンソールを使ってメッセージをWebサービスにも送信できます。
trackJs.console.debug("a message"); // debug severity
trackJs.console.log("another message"); // log severity
{track.js}でできることは、まだまだたくさんあります。詳しくはドキュメントを参照してください。
最後に
クライアント側のロギングは見過ごされがちですが、間違いなくサーバー側のエラーロギングと同じく重要です。クライアント側のロギングの設定の難度が比較的高いことは明らかですが、この記事で説明してきたようなたくさんの方法があります。
※本記事はPanayiotis «pvgr» Velisarakos、James Wright、Stephan Maxが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。
(原文:Logging Errors in Client-Side Applications)
[翻訳:新岡祐佳子/編集:Livit]