Легкий JSONL логер з форматерами для VictoriaLogs, Google Cloud Logging та інших.
bun add jsonl-logger Структуровані логи — правильна відповідь для продакшену і неправильна для мого ноутбука. Коли логи читає конвеєр, я хочу один JSON-об’єкт на рядок; коли читаю я — кольоровий рядок, зрозумілий людині. Зазвичай тримати обидва варіанти означає або два налаштування логування, або обгортку, яку ніхто не хоче підтримувати.
jsonl-logger робить це однією змінною середовища, без жодних змін у коді.
Приклади
import { logger } from 'jsonl-logger'
logger.info('Server started', { port: 3000 })
logger.log('Neutral message', { note: 'no level icon' })
logger.error('Request failed', { path: '/api' }, new Error('timeout'))
Локально це виглядає як текст — кольоровий у TTY і звичайний, щойно ви перенаправите вивід у файл чи CI:
18:42:05 ● Server started {"port":3000}
18:42:05 Neutral message {"note":"no level icon"}
18:42:05 ✖ Request failed {"path":"/api"}
Error: timeout
at handler (/app/server.ts:12:9)
Виставте LOG_FORMAT — і ті самі виклики дадуть JSONL у формі, яку очікує ваша платформа логів:
LOG_FORMAT=google-cloud-logging bun run server.ts
# {"message":"Server started","timestamp":"…","severity":"INFO",…}
Навіщо це
Кожен логер, який я брав, змушував вибирати. Гарний вивід для людини — це окрема залежність, яку треба не забути не потягнути в продакшен. Продакшен-логери сиплять JSON усюди, і локальна розробка перетворюється на стіну з дужок. Проблема не у функціях: змінюється призначення виводу між середовищами, а місця викликів змінюватися не повинні.
Тож форма виводу стала питанням запуску, а не коду. LOG_LEVEL навіть має різні типові значення: debug для тексту й info для JSON — бо саме цього й хочеться в кожному з випадків.
Друга причина суто побутова: фреймворки з жорстко зашитим текстовим логуванням незручні там, де приймають лише JSON. Пропустити їх через форматер простіше, ніж патчити фреймворк.
Чим відрізняється
| Пакет | Про що він |
|---|---|
jsonl-logger | одні місця викликів, текст локально і JSONL у продакшені, без залежностей, лише ESM |
| pino | пропускна здатність і велика екосистема транспортів та процесорів |
| winston | максимальна налаштовуваність — транспорти, власні рівні, формати |
| consola | вивід для CLI та гарні репортери для розробника |
Беріть pino, якщо потрібна його екосистема транспортів або цілеспрямована ставка на пропускну здатність — а цей проєкт навіть уміє видавати форму рядка pino, щоб можна було передати вивід у pino-pretty та подібні. Беріть winston, якщо вам справді потрібні довільні транспорти. Цей — зовсім маленький, але для багатьох типових проєктів його цілком досить: зручний локальний вивід, чистий JSON у продакшені й жодного стека логування, який треба збирати.
Що всередині
Готові форматери під типові призначення
LOG_FORMAT | Що видає |
|---|---|
google-cloud-logging | severity, timestamp, payload у формі GCP |
victoria-logs | _msg, _time, level |
ecs | @timestamp, log.level, ecs.version для Elastic/Filebeat |
datadog | status, dd.trace_id, dd.span_id |
pino | числовий level, epoch-time, msg, err |
Trace context лягає туди, куди очікує кожне призначення — trace.id/span.id для ECS, trace_id/span_id/trace_flags для pino.
Мітки та колір, коли це текст
import { Logger } from 'jsonl-logger'
const logger = new Logger({}, { labels: 'text', colors: false })
LOG_LABELS перемикає між іконками (◆ ● ▲ ✖ ‼), текстовими мітками або нічим. Колір спершу дивиться на FORCE_COLOR і NO_COLOR, а вже потім на TTY, тож у CI поводиться правильно без окремих вказівок.
Власні форматери, бо готовий список — це старт, а не межа
Форматер — це невеликий об’єкт з назвами ключів і способом рендеру запису, тож внутрішня схема логів — це кілька рядків, а не форк:
import type { Formatter } from 'jsonl-logger'
const myFormatter: Formatter = {
messageKey: 'msg',
format: (record) => ({
msg: record.message,
ts: record.timestamp,
lvl: record.level,
...record.context,
}),
}
Нотатки про рішення
Перемикач — це змінна середовища, а не API. Якби це був параметр конструктора, кожен сервіс переписував би ту саму перевірку process.env, і рано чи пізно хтось помилився б у Dockerfile. Коли рішення живе в бібліотеці, воно там само, де й середовище.
Жодного імпорту node:os. Прив’язок pid і hostname, як у pino, свідомо немає: імпорт node:os прив’язав би пакет до Node-подібних середовищ. Ви можете додати їх самі як базовий контекст — натомість логер лишається переносним між Bun, Node і Deno.
import os from 'node:os'
import { Logger } from 'jsonl-logger'
const logger = new Logger({ pid: process.pid, hostname: os.hostname() })
Trace ID для Datadog передаються без змін. Кореляція в Datadog APM очікує ідентифікатори у власному форматі, і dd-trace-js уже їх такими й видає. Тихо конвертувати 128-бітні hex-ідентифікатори OpenTelemetry виглядало б турботливо, але давало б неправильну кореляцію — тож бібліотека пише рівно те, що повернув ваш traceContext, і документує розбіжність.
Статус
Живий і в роботі. Лише ESM і Bun-first за задумом — працює на Node і Deno, але не намагається підтримувати збірки, старші за ESM.