◆ 書き込み完了前に process.exit(1) が実行されてるみたい
◆ setTimeout で終了を遅延させるとちゃんとログされた
◆ uncaughtException はハンドルできても rejectionHandled はハンドルしてくれない
  ◆ 自分でハンドルしたほうがいいかも

winston 使ってみた

PM2 のログでは高度なことできなかったのでロガーを探してみると winston というのを見つけました
winston ではロガーインスタンスを作り インスタンスに対して transport というのを複数設定できます
ログは logger.info や logger.error メソッドなどで書き込めるのですが 書き込み先が transport です
コンソールとファイルとデータベースとメール みたいに複数設定したら logger.error メソッドでエラーをログすると全てに書き込まれる というものです

エラー時のログ

基本的な使い方は本題じゃないので省略します
winston の機能の一つにエラーが起きたときにログするというものがあります

デフォルトで ON なので transport のオプションに

{handleExceptions: true}

を設定するか logger の exceptionHandlers にエラー時用の transport を設定します
エラー時も普段と同じファイルに書き込みたいなら 同じ設定の transport を作り直すより handleExceptions を設定したほうが楽です

exitOnError でログされない

ここからが本題なのですが上の設定をしてもファイルに書き込まれないです
exitOnError を false にしてみると書き込まれます
ファイル書き込みに設定していたので 別 transport でファイルに書き込む DailyRotateFile にしてみましたが同じく書き込まれません
こういう挙動だとファイルへの書き込み処理が終わる前に exit してるからだろうと想像がつくのでソースを見てみました
結果はやっぱりファイル書き込み前に exit してることが原因でした

処理は lib/winston/exception-handler.js の _uncaughtException メソッドです
https://github.com/winstonjs/winston/blob/3.0.0/lib/winston/exception-handler.js#L166

メソッド内の gracefulExit 関数内で process.exit(1) が実行されます

    function gracefulExit() {
debug('doExit', doExit);
debug('process._exiting', process._exiting);

if (doExit && !process._exiting) {
// Remark: Currently ignoring any exceptions from transports when
// catching uncaught exceptions.
if (timeout) {
clearTimeout(timeout);
}
// eslint-disable-next-line no-process-exit
process.exit(1);
}
}

この関数が呼び出されるのは

    if (!handlers || handlers.length === 0) {
return process.nextTick(gracefulExit);
}

// Log to all transports attempting to listen for when they are completed.
asyncForEach(handlers, (handler, next) => {
// TODO: Change these to the correct WritableStream events so that we
// wait until exit.
const done = once(next);
const transport = handler.transport || handler;

// Debug wrapping so that we can inspect what's going on under the covers.
function onDone(event) {
return () => {
debug(event);
done();
};
}

transport.once('logged', onDone('logged'));
transport.once('error', onDone('error'));
}, gracefulExit);

this.logger.log(info);

// If exitOnError is true, then only allow the logging of exceptions to
// take up to `3000ms`.
if (doExit) {
timeout = setTimeout(gracefulExit, 3000);
}

この 3 箇所です
handlers は handleExceptions を true にして設定しているので 1 つ以上あります
ない場合は nextTick で exit されますがログする場合はあるはずなのでこれは無視でいいです

残りは asyncForEach で非同期処理を実行して終わった場合と 3 秒たったあとです
デバッガーでみてみると logged イベントが起きて gracefulExit が呼び出されてました
File や DailyRotateFile の transport では書き込み完了を待たずに logged イベントを emit してるようです

DailyRotateFile だと emit する周辺のコードはこうなってました

        this.logStream.write(info[MESSAGE] + this.options.eol);
this.emit('logged', info);
callback(null, true);

logged イベントですし transport 側でちゃんと書き込み確認して emit すべきだと思いますが そういうちゃんとした修正はライブラリ管理してる人に任せて使う側で修正するなら単純に gracefulExit で終了を少し遅延させるくらいで十分かと思います

process.exit(1) のところを

setTimeout(() => {
process.exit(1);
}, 1000);

に変更します
1 秒もあれば書き込みは終わってると思うので 1 秒にしてます
とりあえずこれでちゃんとログされるようになりました

transport の種類によらず遅延させるので console.log みたいな同期的なものならムダですが File も DailyRotateFile も みたいに複数あるなら一箇所で対処できるほうが楽ですからね

自力でログしたほうがいいかも?

ただ 直して使っていて思ったのですが uncaughtException はハンドルしても rejectionHandled はハンドルしてくれません
こっちを自力でハンドルするなら uncaughtException も一緒にやってしまえばいいと思うので handleExceptions の設定は不要かもしれません

const errorHandler = error => {
logger.error(error)
setTimeout(() => process.exit(1), 1000)
}

process
.on("rejectionHandled", errorHandler)
.on("uncaughtException", errorHandler)