better-logger a working catalog of structured log output

Migración a 0.18.x

0.18.x es un reset completo del package: el refactor F1–F6 (rolldown subpath layout, ScopedLogger por delegación, sistema de transports, hooks awaited, log context MDC, OTel en TransportRecord) reemplaza el núcleo legacy. Las versiones 1.x–5.x publicadas en npm están deprecated y muestran Legacy — superseded by 0.18.x refactor al cargarse. La línea 0.18.x (último: 0.18.2-alpha.1) es el nuevo latest.

Esta página lista los breaking changes que necesitas migrar. Para el detalle exhaustivo por fase, ver CHANGELOG.md y los commits bajo el topic F1..F6.

Por qué el reset

El antiguo Logger monolítico (~970 líneas, single file) acumulaba deuda: handlers síncronos sin buffer, sin scopes ALS, sin OTel, MDC frágil con race conditions en scoped loggers. El refactor 0.18.x reorganiza el código en subpaths (./core, ./transports, ./context, ./hooks, ./serializers, ./styles, ./cli, ./playground, ./node) y reemplaza cada superficie legacy por una equivalente moderna.

Beneficios prácticos:

  • Transports async con bounded buffer, retry con backoff, status check (sin OOM en outages largos).
  • MDC correcto vía AsyncLocalStorage (Node) y child() inmutable (universal).
  • OTel-native: cada TransportRecord lleva severityNumber, severityText, traceId, spanId, attributes, resource.
  • Hooks awaited — el redact PII se aplica antes del emit, no después.
  • ScopedLogger por delegación (~50-100x menos memoria por scope).

Breaking changes principales

1. withContext() requiere callback

logger.withContext({ bindings }) sin callback es no-op post-refactor. Era el setter global que mutaba el contexto del singleton; rompía scoped loggers con race conditions y se eliminó. Las dos formas correctas:

Forma Firma Cuándo
Scoped (transitorio) logger.withContext({ ... }, fn) o logger.withContextAsync({ ... }, async () => ...) Bindings vivos solo dentro del callback
Persistente (inmutable) logger.child({ ... }) Bindings atados al child logger, sin tocar al padre
// ❌ Ya no funciona (no-op, sin error, sin bindings aplicados)
logger.withContext({ requestId: 'r-42' });
logger.info('processing');  // attributes vacío

// ✅ Forma scoped (ALS — Node 14+)
logger.withContext({ requestId: 'r-42' }, () => {
  logger.info('processing');  // attributes: { requestId: 'r-42' }
});

// ✅ Forma persistente (inmutable, browser-safe)
const reqLog = logger.child({ requestId: 'r-42' });
reqLog.info('processing');   // attributes: { requestId: 'r-42' }
logger.info('unrelated');    // sin requestId (padre intacto)

Si necesitas bindings por request (Express/Hono/Fastify handler), el patrón canónico es:

app.use((req, _res, next) => {
  req.log = logger.child({ requestId: req.headers['x-request-id'] });
  next();
});

Más detalle en docs/context.md (MDC, ALS, browser fallback) y la referencia API.

2. Handlers → Transports

El sistema legacy basado en ILogHandler se reemplaza por el sistema de transports (ITransport con write / flush / close async).

Legacy (eliminado) Reemplazo
FileLogHandler FileTransport (./transports)
RemoteLogHandler / HttpLogHandler HttpTransport (./transports)
AnalyticsLogHandler custom ITransport
ExportLogHandler custom ITransport
addHandler(handler) addTransport({ target }) (vía registry)
// ❌ 5.x
logger.addHandler(new FileLogHandler({ destination: '/var/log/app.log' }));

// ✅ 0.18.x
import { FileTransport } from '@mks2508/better-logger/transports';
logger.addTransport({
  target: new FileTransport({
    destination: 'logs/app.log',     // relativa a process.cwd()
    batchSize: 100,
    flushInterval: 5000,
    maxBufferSize: 10_000            // bounded buffer
  }),
  level: 'info'
});

addHandler(ILogHandler) se conserva como shim legacy para callbacks síncronos simples (métricas, fire-and-forget). Para cualquier destino con buffer, retry, backoff o shutdown ordenado, implementa ITransport. Detalle completo en docs/transports.md.

3. Nivel trace añadido

LogLevel ahora es 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'critical' (+ 'silent'). trace se alinea con la banda TRACE de OpenTelemetry (severity 1-4), pensado para verbosidad máxima / debug profundo:

logger.trace('payload crudo', { bytes: 4096 });
logger.setVerbosity('debug');  // muestra trace + debug + info + ...

severityNumber y severityText se adjuntan a cada TransportRecord automáticamente — el OtlpTransport los usa para producir el payload OTLP logRecords spec-compliant.

Más sobre la jerarquía en docs/core.md.

4. log() family retorna Promise<void>

Las llamadas info(), warn(), error(), etc. ahora retornan Promise<void> porque internamente awaitean los hooks beforeLog. Fire-and-forget sigue funcionando (la promesa queda flotando), pero se recomienda await cuando registras hooks que mutan el mensaje (redacción PII, enriquecimiento):

// Antes: mutation async se perdía en el emit
logger.on('beforeLog', async (entry) => {
  entry.message = await redactPII(entry.message);
});

// Ahora: awaita la mutación antes de despachar a transports
await logger.info('login password=s3cr3t');
// → "login password=***" (redacción aplicada)

Si el caller no puede await (sync required), los hooks deben ser síncronos o aceptar que la mutación se pierda en ese caso.

5. Default export lazy (Proxy)

import logger from '@mks2508/better-logger' devuelve un Proxy que se inicializa al primer uso. Esto permite top-level imports sin side-effects y simplifica hot-reload. Si necesitas la clase directa:

import { Logger } from '@mks2508/better-logger';
const appLogger = new Logger({ prefix: 'APP', verbosity: 'info' });

6. ScopedLogger por delegación

scope(), component(), api() ya no devuelven subclases de Logger. Internamente son ScopedLogger que delega al Logger raíz vía árbol interno — sin duplicar estado, sin capturar this. Beneficio: ~50-100x menos memoria por scope en aplicaciones con cientos de módulos.

const auth = logger.scope('Auth');           // ScopedLogger
const db = auth.scope('DB');                 // ScopedLogger anidado
db.error('pool exhausted');
// → prefix efectivo: "Auth:DB" en el record

API pública no cambia. Si tu código dependía de instanceof Logger sobre scopes, ahora es instanceof ScopedLogger.

7. Campos OTel en TransportRecord

TransportRecord ahora incluye los campos del spec OTLP LogRecord además de los legacy:

interface TransportRecord {
  // legacy
  level: LogLevel;
  message: string;
  prefix?: string;
  timestamp: number;
  attrs?: Record<string, unknown>;

  // OTel (nuevo)
  severityNumber?: number;      // 1-24 (TRACE=1-4, DEBUG=5-8, ...)
  severityText?: string;        // 'TRACE' | 'DEBUG' | 'INFO' | ...
  traceId?: string;             // 128-bit hex
  spanId?: string;              // 64-bit hex
  attributes?: Record<string, ...>;     // merged con MDC bindings
  resource?: ILogResourceRef;          // service.name / version / env
}

Si tu transport custom lee record.attrs, migrar a record.attributes (los transports built-in conservan attrs como alias deprecado por una versión; el custom deberías migrarlo).

8. Dead surface eliminada

El refactor F6 borró exports sin callers o con替代n Superior:

Eliminado Reemplazo
CSS2ANSIAdapter Interno — no usar
LogStyleBuilder StyleBuilder (subpath ./styles) vía proxy $
SemanticStyles THEME_PRESETS (./styles) o presets del Logger (logger.preset('cyberpunk'))
HistoryCommand (CLI) Sin reemplazo — usar logger.scope() o un subpath CLI explícito
// ❌ createStyle() no existe
import { createStyle } from '@mks2508/better-logger';

// ✅ $
import { $ } from '@mks2508/better-logger/styles';
const style = $.bg('linear-gradient(45deg, #667eea, #764ba2)')
  .color('white').padding('12px 24px').rounded('8px').build();
console.log('%c🚀 Hello', style);

// ✅ o instancia directa
import { StyleBuilder } from '@mks2508/better-logger/styles';
const style = new StyleBuilder().bg(...).color(...).build();

Tabla de migración rápida

5.x (legacy) 0.18.x Notas
logger.withContext({ k: v }) logger.withContext({ k: v }, fn) o logger.child({ k: v }) Sin callback = no-op
new FileLogHandler({ ... }) + addHandler new FileTransport({ ... }) + addTransport Async, bounded buffer
new RemoteLogHandler({ ... }) new HttpTransport({ ... }) Retry + backoff
new ExportLogHandler({ ... }) new OtlpTransport({ ... }) OTLP/HTTP spec-compliant
logger.success('ok') logger.success('ok') Sin cambio (sigue emitiendo a INFO con tag: 'success')
addHandler(h) con callback custom addTransport({ target }) con ITransport Para side-effects simples, addHandler legacy sigue
Sin nivel trace logger.trace(...) (+ setVerbosity('debug') por defecto) OTel TRACE severity 1-4
log() síncrono log() retorna Promise<void> await cuando hay hooks beforeLog que mutan
TransportRecord.attrs TransportRecord.attributes (con attrs alias deprecado) OTel mapping 1:1
setVerbosity('info') igual Sin cambio
clearContext() igual Sin cambio
setResource({ 'service.name': ... }) igual Sin cambio
getContext() igual (snapshot read-only) Sin cambio, pero NO incluye overlay ALS transitorio

Antes y después: ejemplo completo

// ─────── 5.x (legacy) ───────
import logger, { FileLogHandler, RemoteLogHandler } from '@mks2508/better-logger';

logger.withContext({ requestId: getReqId() });   // setter global
logger.addHandler(new FileLogHandler({ destination: '/var/log/app.log' }));
logger.addHandler(new RemoteLogHandler({ url: 'https://logs/x', level: 'warn' }));
logger.info('login ok');                          // síncrono

// ─────── 0.18.x ───────
import logger from '@mks2508/better-logger';
import { FileTransport, HttpTransport, OtlpTransport } from '@mks2508/better-logger/transports';

const reqLog = logger.child({ requestId: getReqId() });            // inmutable

logger.addTransport({
  target: new FileTransport({ destination: 'logs/app.log', maxBufferSize: 10_000 }),
  level: 'info'
});
logger.addTransport({
  target: new HttpTransport({
    url: 'https://logs.example.com/ingest',
    maxRetries: 3,
    initialBackoffMs: 250,
    maxBufferSize: 10_000
  }),
  level: 'warn'
});
logger.addTransport({
  target: new OtlpTransport({
    endpoint: 'https://otelcollector.example.com:4318',
    serviceName: 'my-app',
    ingestKeyEnvVar: 'SIGNOZ_KEY'
  })
});

logger.setResource({ 'service.name': 'my-app', 'service.version': '1.2.3' });
await reqLog.info('login ok');                                    // awaited

Verificación post-migración

Checklist rápido:

  1. Type-check limpio: bunx tsc --noEmit (o bun run typecheck).
  2. No quedan imports de FileLogHandler, RemoteLogHandler, AnalyticsLogHandler, ExportLogHandler, CSS2ANSIAdapter, LogStyleBuilder, SemanticStyles, HistoryCommand, createStyle.
  3. No quedan withContext({ ... }) sin segundo argumento callback. Usar grep -rn "withContext(" src/.
  4. await logger.info(...) (o equivalente) en call sites donde tienes hooks beforeLog que mutan el mensaje.
  5. Transports async: await logger.closeTransports() (o await logger.cleanup()) en handlers de SIGTERM para drenar buffers pendientes antes de exit.

Recursos