◆ debug とか error とかのメソッド呼び出しではイベントを送信するだけ
◆ そのモジュールを使う側で必要ならリスナをつけて console やファイルに送信

ログを出力したいときによく困ることで console.log とか Node.js ならファイル出力とかをモジュール側でやりたくないです
アプリケーション側のコードであれば別にいいのですが 機能として独立してるのでその部分だけは汎用的なモジュールに切り出してライブラリ的に扱いたいものです
似たようなことをする別のものでもそのまま使えるのが理想です

ただ そう作ったとしても結局他で使うことなんてほぼないので 無駄な苦労と面倒な実装になるほうが多く 必要になってからそうすれば良いような気もしてます
とは言え 毎回のように気持ち悪さが残る部分でどうにか簡単にできるようにしたいんですよね

ロガーに限らずアプリケーション固有なものをモジュール内では知らないものとするなら 外部から受け取るしかないです
そうなると最初の受け渡しが面倒な上 引数として渡されたものを見えるスコープ内に全部を書くことになって 見やすさが落ちます

ロガーであればやっぱり以前作ったようなグローバルだけど出力先を使う側が最初に決定するようなもののほうが良いのでしょうか
あれはあまり使いやすくなく結局使わなかったんですよね
そんな事を考えていると ログのイベントだけを起こして 使う側がリスナを設定するというイベントベースなら使いやすいような気がしました

イベントベースリスナ

とりあえず勢いで作ってみました
こういうイメージで使います

[module1.js]
import logger from "./logger.js"

export default () => {
logger.info("aa")
something()
logger.info("bb")
}

logger.info を呼び出しても リスナがなければ何もしません
console.log のような感じで インスタンスを作らずグローバルにログメソッドを呼び出せます

リスナはこういう感じで設定します

[main.js]
import logger from "./logger.js"
import fn from "./module1.js"

logger.on("info", (...a) => {
console.log(...a)
})

fn()

コンソールに出力かファイルに出力かその両方かは使う側次第で ロガー側では指定しません
グローバルなので すべてのモジュールが送信するイベントを 1 つのところで受け取ります

独立したインスタンスを作ることもできます

import global_logger from "./logger.js"

export const module_logger = global_logger.create("id")
module_logger.info("foo")

イベントは親に伝播しません
この例だと module_logger で info ログイベントを送信しても global_logger では受信できません
error だけ伝播させたいとか 子側のリスナで処理した場合は伝播させたくないとかあるので自動ではしないようにしてます

モジュール内でインスタンスを作ってもリスナをつける外部に公開しないと意味がないので export しています
このモジュールを使う側でログしたいならリスナを設定します
モジュール側で用意して公開しても モジュールを使う側が用意して引数で渡しても良いです

実際の出力も含めた例です
logger モジュールは event logger ということで evl.js という名前になってます

1

基本的な使い方です

import logger from "./evl.js"

logger.info("表示されない")

logger.on("all", values => {
const time = values.timestamp.toLocaleTimeString()
const messages = values.messages.map(m => {
if (m && (Array.isArray(m) || m.toString === Object.prototype.toString)) {
return JSON.stringify(m)
} else {
return String(m)
}
})
console.log(`${time} [${values.id}] ${values.type}: ${messages.join(" ")}`)
})

logger.info("a", 1, true, null, undefined, ["b", 3], {x: 1})

logger.error(new Error("abc"))

logger.warn("warning message")
logger.debug("debug message")

logger.addType("fatal")

logger.fatal("fatal error")
13:19:27 [default] info: a  1  true  null  undefined  ["b",3]  {"x":1}
13:19:27 [default] error: Error: abc
13:19:27 [default] warn: warning message
13:19:27 [default] debug: debug message
13:19:27 [default] fatal: fatal error

リスナを設定する前に送信されたイベントは保持されないので最初の「表示されない」は出力されません
info や error など対応するログ種別ごとにリスナを設定しますが まとめて受け取る場合は all が使えます

リスナの引数はオブジェクトで id, timestamp, type, messages プロパティがあります
id はグローバルロガーでは default です
logger.create で作ったロガーでは引数に渡した文字列が id になります
timestamp はイベント送信時の new Date() の値です
type は info や error など logger のメソッド名です
messages は info などの関数に渡した引数の配列です
これらを例のように文字列化して好きなところへ出力します
ブラウザ console なら文字列化はしないほうが良いかもしれません

また addType を使うことで logger のメソッドを追加できます
例だと fatal を追加しています

2

Node.js を使って info はコンソールに error はコンソールとファイルに出力する例です

[index.js]
import logger from "./evl.js"
import setupLogger from "./setup-logger.js"

setupLogger(logger)

logger.debug("foo")

logger.info("bar")

logger.error("baz")

[setup-logger.js]
import fs from "fs"

const sendConsole = ({ timestamp, id, type, messages }) => {
console.log(timestamp, id, type, messages)
}

let logstream = null

const sendFile = ({ timestamp, id, messages }) =>{
if (!logstream) {
logstream = fs.createWriteStream("log")
}
const time = timestamp.toLocaleTimeString()
const body = messages.map(x => String(x)).join(" ")
logstream.write(`${time} [${id}] ${body}`)
}

export default logger => {
logger.on("info", sendConsole)
logger.on("error", v => {
sendConsole(v)
sendFile(v)
})
}

console
2021-07-09T08:39:06.621Z default info [ 'bar' ]
2021-07-09T08:39:06.633Z default error [ 'baz' ]

log
17:39:06 [default] baz

メインのファイルでロガーのセットアップ処理は邪魔になるので別モジュールにまとめてます

3

サブロガーを使った例です

import logger from "./evl.js"

logger.on("all", console.log)

logger.info("aaa")

const sub = logger.create("logger1")

sub.info("bbb")

sub.on("all", console.log)

sub.info("ccc")

const subsub = sub.create("logger2")

subsub.on("all", console.log)

subsub.info("ddd")
{
id: 'default',
timestamp: 2021-07-09T08:43:46.378Z,
type: 'info',
messages: [ 'aaa' ]
}
{
id: 'logger1',
timestamp: 2021-07-09T08:43:46.391Z,
type: 'info',
messages: [ 'ccc' ]
}
{
id: 'logger1.logger2',
timestamp: 2021-07-09T08:43:46.393Z,
type: 'info',
messages: [ 'ddd' ]
}

グローバルロガーにリスナがついていてもサブロガーの info は無視されています
サブロガーにリスナをつけてからはイベントを受け取れています

create に渡した id が各リスナで受け取れています
サブロガーからさらに create で作ったサブサブロガーになると 親の ID を引き継ぎ 「.」 区切りの名前になります

コード

const createLogger = (id) => {
const listeners = {}

const on = (type, fn) => {
if (!listeners[type]) {
listeners[type] = new Set()
}
listeners[type].add(fn)
}

const off = (type, fn) => {
if (listeners[type]) {
listeners[type].delete(fn)
}
}

const call = (type, values) => {
if (listeners[type]) {
for (const fn of listeners[type]) {
fn(values)
}
}
}

const logger = {}

const log = (type) => (...messages) => {
const values = {
id: id || "default",
timestamp: new Date(),
type,
messages,
}
call(type, values)
call("all", values)
}

const addType = (type) => {
logger[type] = log(type)
}

addType("debug")
addType("info")
addType("warn")
addType("error")

const create = (sid) => {
const prefix = id ? (id + ".") : ""
sid = sid || "-"
return createLogger(prefix + sid)
}

Object.assign(logger, { on, off, addType, create })
return logger
}

export default createLogger()