[JavaScript] consoleでログファイルを出力する

# はじめに

JavaScriptでconsoleのファイル出力機能を作成しました。

通常、JavaScriptのconsoleオブジェクトを使用すると、PCのWEBブラウザ開発モードからコンソール画面にログが表示されます。

ここでは、consoleオブジェクト使用すると、サーバー側でログファイルを出力するようにしました。処理の流れは下記の通りです。

![url.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50563/4973d806-1180-902e-9300-7bd7b1f725e1.png)

consoleオブジェクトの関数（例えば、console.log()）を実行すると、ログメッセージがキューイングされます。10秒間隔て定期的にサーバー（php）にログメッセージがPOSTされます。そして、サーバー側でメッセージをファイルに出力しています。

この仕組みのメリットは、開発モードが使えないブラウザで有効であると思います。例えば、IOTデバイスにブラウザが組み込まれているような場合は、開発モードが有効になっていないことが多いです。

機能としては下記の通りです。

- ログレベル
- ログローテート
- 出力先指定

# 実行環境

今回実行した環境は下記の通りです。

```Markdown:実行環境
JavaScript: ECMAScript6
```

# 使い方

使い方は下記の通りです。

```javaScript
console.error('log: error.');
console.warn('log: warn.');
console.info('log: info.');
console.debug('log: debug.');
console.log({a:"aaa",b:1,c:{d:2},d:[3,4,5]});
```

出力されるログは下記の通りです。

```log
[2020-03-27 14:41:45.283][ERROR][::1]  log: error.
[2020-03-27 14:41:45.283][WARN][::1]  log: warn.
[2020-03-27 14:41:45.283][INFO][::1]  log: info.
[2020-03-27 14:41:45.283][DEBUG][::1]  log: debug.
[2020-03-27 14:41:45.284][DEBUG][::1]  {a:"aaa",b:1,c:{d:2},d:[3,4,5]}
```

# ログファイル出力の実装

## JavaScriptの実装

configオブジェクトでログに関する設定を行います。

```JavaScript:config.js
/**
 * 設定
 */
var config = {
    IS_LOGFILE: true, // ログフラグ true=ログファイル出力あり/false=なし
    LOG_LEVEL: 3, // ログレベル 0=ERROR/1=WARN/2=INFO/3=DEBUG
    API_LOGOUT: 'http://localhost/console_api.php', // ログ出力API 
};
``` 

logオブジェクトの実装は下記の通りです。

```JavaScript:log.js
var log = log || {}; {
    // ログレベル
    let logLevel = {
        ERROR: 0,
        WARN: 1,
        INFO: 2,
        DEBUG: 3,
    };

    let msgList = [];

    /**
     * 初期化
     */
    log.init = () => {
        // console関数の上書き
        overrideConsole();

        // 定期的にログをPOSTする
        let run = () => {
            if (0 < msgList.length) {
                let msg = msgList.join('\r\n');
                msgList = [];
                postMsg({
                    msg: msg
                });
            }
            setTimeout(run, 10000);
        }
        run();
    };

    /**
     * console関数を上書きする
     */
    let overrideConsole = () => {
        console = {};
        console.error = (...args) => {
            if (logLevel.ERROR <= config.LOG_LEVEL) {
                out('ERROR', args);
            }
        }
        console.warn = (...args) => {
            if (logLevel.WARN <= config.LOG_LEVEL) {
                out('WARN', args);
            }
        }
        console.info = (...args) => {
            if (logLevel.INFO <= config.LOG_LEVEL) {
                out('INFO', args);
            }
        }
        console.debug = (...args) => {
            if (logLevel.DEBUG <= config.LOG_LEVEL) {
                out('DEBUG', args);
            }
        }
        console.log = (...args) => {
            if (logLevel.DEBUG <= config.LOG_LEVEL) {
                out('DEBUG', args);
            }
        }
        console.ws = null;
    }

    /**
     * ログを出力する
     * @param {String} level ログレベル
     * @param {String} msg メッセージ
     */
    let out = (level, msg) => {
        msg = `[${getTimestamp(new Date())}][${level}][{ipAddress}] ${convertMsg(msg)}`;
        msgList.push(msg);
    }

    /**
     * タイムスタンプを取得する
     * @param {Date} dt 
     */
    let getTimestamp = (dt) => {
        let yyyy = dt.getFullYear();
        let MM = ('00' + (dt.getMonth() + 1)).slice(-2);
        let DD = ('00' + dt.getDate()).slice(-2);
        let hh = ('00' + dt.getHours()).slice(-2);
        let mm = ('00' + dt.getMinutes()).slice(-2);
        let ss = ('00' + dt.getSeconds()).slice(-2);
        let dd = ('000' + dt.getMilliseconds()).slice(-3)
        return `${yyyy}-${MM}-${DD} ${hh}:${mm}:${ss}.${dd}`;
    }

    /**
     * ログメッセージを変換する
     * @param {Array} arg メッセージ
     */
    let convertMsg = (arg) => {
        let msg = '';
        for (let i = 0; i < arg.length; i++) {
            msg += ' ' + dumpObject(arg[i]);
        }
        return msg;
    }

    /**
     * オブジェクトを出力する
     * @param {Object} obj 
     */
    let dumpObject = (obj) => {
        let v = '';
        let t = typeof obj;
        switch (t) {
            case 'number':
            case 'boolean':
                v = obj;
                break;
            case 'string':
                v = '"' + obj + '"';
                break;
            case 'object':
                if (isArray(obj)) {
                    v = '[';
                    let count = 0;
                    for (let value of obj) {
                        v += dumpObject(value);
                        count++;
                        if (count < obj.length) {
                            v += ',';
                        }
                    }
                    v += ']';
                } else if (isObject(obj) || isError(obj)) {
                    v = '{';
                    let count = 0;
                    let nameList = Object.getOwnPropertyNames(obj);
                    for (let key of nameList) {
                        let ret = dumpObject(obj[key]);
                        if (ret) {
                            v += key + ':' + ret;
                            count++;
                            if (count < nameList.length) {
                                v += ',';
                            }
                        }
                    }
                    v += '}';
                } else if (isString(obj) || isDate(obj)) {
                    v = '"' + obj.toString() + '"';
                } else if (isNumber(obj) || isBoolean(obj)) {
                    v = obj.valueOf();
                } else if (isFunction(obj)) {
                    v = '<fuction>';
                } else if (isNull(obj)) {
                    v = '<null>';
                } else if (isUndefined(obj)) {
                    v = '<undefined>';
                }
                break;
            case 'function':
                v = '<function>';
                break;
            case 'symbol':
                v = '<symbol>';
                break;
            case 'undefined':
                v = '<undefined>';
                break;
        }
        return v;
    }

    let isArray = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Array]';
    }

    let isBoolean = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Boolean]';
    }

    let isDate = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Date]';
    }

    let isError = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Error]';
    }

    let isNumber = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Number]';
    }

    let isObject = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Object]';
    }

    let isString = (obj) => {
        return Object.prototype.toString.call(obj) === '[object String]';
    }

    let isFunction = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Function]';
    }

    let isNull = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Null]';
    }

    let isUndefined = (obj) => {
        return Object.prototype.toString.call(obj) === '[object Undefined]';
    }

    /**
     * ログメッセージをPOSTする
     * @param {Object} data リクエストパラメータ
     */
    let postMsg = (data) => {
        return new Promise((resolve, reject) => {

            let req = new XMLHttpRequest();
            req.open('POST', config.API_LOGOUT, true);
            req.onload = () => {
                if (req.responseText && req.status === 200) {
                    resolve(req.responseText);
                } else {
                    reject({
                        message: `API response invalid (http status:${req.status})`
                    });
                }
            };
            req.onerror = () => {
                reject({
                    message: `API request error.`
                });
            };
            req.ontimeout = () => {
                reject({
                    message: 'API request timeout.'
                });
            };
            req.onabort = () => {
                reject({
                    message: 'API request abort.'
                });
            };
            req.timeout = 60 * 1000;
            req.setRequestHeader('Content-Type', 'application/json');
            req.send(JSON.stringify(data));
        });
    };
}

if (config.IS_LOGFILE) {
    log.init();
}
```

## PHPの実装

configクラスでログに関する設定を行います。

```php:config.php
<?php
/**
 * 設定クラス
 */
class Config {
    const LOGDIR_PATH = './logs/'; // ログファイル出力ディレクトリ
    const LOGFILE_NAME = 'console'; // ログファイル名
    const LOGFILE_MAXSIZE = 10485760; // ログファイル最大サイズ（Byte）
    const LOGFILE_PERIOD = 30; // ログ保存期間（日）
}

?>
``` 

APIの実装は下記の通りです。

```php:console_api.php
<?php
/**
 * ログ出力API
 */
if($_SERVER['REQUEST_METHOD'] === 'POST') {

    require_once("./config.php");

    $json = file_get_contents('php://input');
    $data = json_decode($json, true);
    $msg = $data['msg'];

    if(isset($msg)) {

        if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
            $ipAddress = $_SERVER['HTTP_X_FORWARDED_FOR'];
        } else {
            $ipAddress = $_SERVER['REMOTE_ADDR'];
        }

        $logMessage = str_replace('{ipAddress}', $ipAddress, $msg) . "\n";
        $logFilePath = Config::LOGDIR_PATH . Config::LOGFILE_NAME . '.log';

        $result = file_put_contents($logFilePath, $logMessage, FILE_APPEND | LOCK_EX);
        if(!$result) {
            error_log('LogUtil::out error_log ERROR', 0);
        }
    
        if(Config::LOGFILE_MAXSIZE < filesize($logFilePath)) {
            // ファイルサイズを超えた場合、リネームしてgz圧縮する
            $oldPath = Config::LOGDIR_PATH . Config::LOGFILE_NAME . '_' . date('YmdHis');
            $oldLogFilePath = $oldPath . '.log';
            rename($logFilePath, $oldLogFilePath);
            $gz = gzopen($oldPath . '.gz', 'w9');
            if($gz) {
                gzwrite($gz, file_get_contents($oldLogFilePath));
                $isClose = gzclose($gz);
                if($isClose) {
                    unlink($oldLogFilePath);
                } else {
                    error_log("gzclose ERROR.", 0);
                }
            } else {
                error_log("gzopen ERROR.", 0);
            }
    
            // 古いログファイルを削除する
            $retentionDate = new DateTime();
            $retentionDate->modify('-' . Config::LOGFILE_PERIOD . ' day');
            if ($dh = opendir(Config::LOGDIR_PATH)) {
                while (($fileName = readdir($dh)) !== false) {
                    $pm = preg_match("/" . preg_quote(Config::LOGFILE_NAME) . "_(\d{14}).*\.gz/", $fileName, $matches);
                    if($pm === 1) {
                        $logCreatedDate = DateTime::createFromFormat('YmdHis', $matches[1]);
                        if($logCreatedDate < $retentionDate) {
                            unlink(Config::LOGDIR_PATH . '/' . $fileName);
                        }
                    }
                }
                closedir($dh);
            }
        }
        echo 'OK.';
    }
}

?>
```

# さいごに

ソースコードをGitHubに公開しています。

[ソースファイルはこちら](https://github.com/yunbow/JavaScript-logger)

以上です。