better-logger a working catalog of structured log output

🧬 Serializers

Qué son

Los serializers son transformadores tipados que convierten instancias de clases (Error, Date, Map, Set, Buffer, o tus propias clases) a representaciones seguras antes de que el valor llegue al TransportRecord. Sin serializers, un Map se loguea como {} al pasar por JSON.stringify, y un Error pierde el stack.

El registry hace matching por constructor mediante instanceof, ordenado por priority (mayor gana primero). Esto permite registrar un serializer para TypeError que matchee antes que el de Error.

Cuando usar serializers:

  • Una clase de dominio (User, Session) que necesita redactar campos sensibles.
  • Un tipo nativo (Map/Set/Buffer) que se rompe al serializar a JSON.
  • Errores con stack traza enorme que quieres compactar a las N líneas más relevantes.
  • Cualquier valor cuyo toString() no refleja su estado real.

Defaults registrados

El SerializerRegistry arranca con serializers builtin cubriendo los tipos que tradicionalmente se rompen al loguearlos:

Tipo Priority Salida
Error 100 { name, message, stack[], cause? } (top-10)
Date 90 { iso, timestamp }
RegExp 90 { pattern, flags }
Map 80 { __type:'Map', entries }
Set 80 { __type:'Set', values[] }
Buffer 70 { __type:'Buffer', length, preview } (Node)

Buffer sólo se registra cuando está disponible (runtime Node). Los defaults cubren la mayoría de casos; registra los tuyos para tipos de dominio.

addSerializer

Registra (o reemplaza) el serializer para una clase constructora. El priority sólo ordena el lookup por instanceof; útil cuando una subclass debe matchear antes que su superclass (TypeError antes que Error).

Vía la instancia del Logger (default priority: 50):

import logger from '@mks2508/better-logger';

// Compactar Error stacks a las 5 primeras líneas
logger.addSerializer(Error, (err) => ({
  name: err.name,
  message: err.message,
  stack: err.stack?.split('\n').slice(0, 5),
}));

// Redactar PII: User sin password
class User {
  constructor(public id: string, public email: string, public password: string) {}
}

logger.addSerializer(User, (user) => ({
  id: user.id,
  email: user.email,
  // password omitida
}));

logger.error('login failed:', new Error('timeout'));
// → stack compacto a 5 líneas en vez de la traza completa

Hay también exports funcionales que operan sobre el logger singleton:

import { addSerializer } from '@mks2508/better-logger';
addSerializer(User, (u) => ({ id: u.id, email: u.email }));

Ejemplo con prioridad explícita para que una subclass gane sobre la superclass:

class ApiError extends Error {
  constructor(public code: string, message: string) {
    super(message);
    this.name = 'ApiError';
  }
}

// priority 110 > 100 del default de Error → ApiError matchea primero
logger.addSerializer(ApiError, (err) => ({
  code: err.code,
  message: err.message,
}), 110);

removeSerializer / getSerializerRegistry

// Elimina el serializer de un tipo
logger.removeSerializer(Error); // → true (estaba registrado por defecto)
logger.removeSerializer(Error); // → false (ya no está)

// Accede al registry subyacente para operaciones avanzadas
const registry = logger.getSerializerRegistry();
console.log(registry.getAll().map(e => [e.type.name, e.priority]));
// [['Error', 100], ['Date', 90], ['RegExp', 90], ['Map', 80], ...]

registry.has(User);     // → true / false
registry.serialize(someMaybeCyclicObject);  // serialización manual

Config

El SerializerConfig controla los tres cortes de seguridad del pipeline:

interface SerializerConfig {
  maxDepth: number;          // default 5 — profundidad máxima antes de '[Max Depth]'
  circular: 'placeholder' | 'error' | 'skip';  // default 'placeholder'
  preserveUndefined: boolean;  // default false
}
Opción Default Comportamiento
maxDepth 5 Corta la recursión y emite '[Max Depth]'
circular 'placeholder' '[Circular]' | omitir key | lanzar Error
preserveUndefined false false'[undefined]' (más seguro para JSON); true → deja undefined

Sobre el registry directo (configura al construir):

import { SerializerRegistry } from '@mks2508/better-logger/serializers';

// Estricto: lanza ante ciclo, conservar undefined
const strict = new SerializerRegistry({
  maxDepth: 10,
  circular: 'error',
  preserveUndefined: true,
});

Override puntual en una sola llamada (no muta la config del registry):

registry.serialize(deepObj, { maxDepth: 2 });

Pipeline interno

El pipeline de serialize() aplica guardas en orden; cada una corta la recursión:

  1. null / undefined → directo (respeta preserveUndefined).
  2. function → placeholder '[Function: name]'.
  3. Primitiva no-objeto → se devuelve tal cual.
  4. Depth guard: si depth >= maxDepth'[Max Depth]'.
  5. Circular ref: si el valor ya está en el WeakSet seen del path actual → aplica circular ('[Circular]' por defecto).
  6. Lookup por instanceof en orden de priority descendente; si matchea, delega (el serializer puede recursar vía ctx).
  7. Arrays → mapea cada item incrementando depth y extendiendo path.
  8. Plain objects → itera keys recursivamente.

Ejemplo visible de los placeholders:

const o: any = {};
o.self = o;
registry.serialize(o);
// → { self: '[Circular]' }

registry.serialize([[[[[[[[]]]]]]]]);  // con maxDepth 5
// → [ [ [ [ [ '[Max Depth]' ] ] ] ] ]

Subpath ./serializers

El subpath ./serializers expone la API de registry/bridge para uso avanzado (crear tu propio registry independiente en vez de usar el singleton del Logger):

import {
  SerializerRegistry,
  getDefaultSerializerRegistry,
  createSerializerBridge,
} from '@mks2508/better-logger/serializers';

// Registry singleton compartido por el Logger
const reg = getDefaultSerializerRegistry();
if (!reg.has(CustomError)) {
  reg.add(CustomError, (e) => ({ code: e.code, message: e.message }), 95);
}

// Bridge aislado (registry nuevo, no afecta al Logger)
const bridge = createSerializerBridge();
bridge.addSerializer(User, (u) => ({ id: u.id, email: u.email }));
bridge.getSerializerRegistry().serialize(someValue);

Casos de uso

Redactar PII antes de loguear

El caso típico: una entidad de dominio con password, token, ssn que nunca debe llegar al log ni al transporte remoto.

class Session {
  constructor(
    public userId: string,
    public token: string,        // sensible
    public ipAddress: string,
  ) {}
}

logger.addSerializer(Session, (s) => ({
  userId: s.userId,
  token: '<redacted>',
  ip: s.ipAddress,
}));

logger.info('session started', session);
// → { userId: 'u_123', token: '<redacted>', ip: '10.0.0.1' }

Compactar Error stacks en producción

El default recorta a 10 líneas. En producción muchas veces quieres menos:

logger.addSerializer(Error, (err) => ({
  name: err.name,
  message: err.message,
  stack: err.stack?.split('\n').slice(0, 3),
  ...(err.cause ? { cause: err.cause } : {}),
}), 100);

Serializar Map / Set a JSON-compatible

Los defaults ya cubren esto, pero si necesitas otra shape (p.ej. solo valores de un Map<string, V> sin las keys stringificadas):

logger.addSerializer(Map, (map: Map<string, unknown>) => ({
  __type: 'Map',
  values: Array.from(map.values()),
}), 85);  // gana sobre el default priority 80

Logging seguro de objetos potencialmente circulares

Si logueas árboles DOM, grafo de reactivo, o estructuras mutuamente referenciadas, el circular-ref guard evita el stack overflow sin que tengas que sanitizar a mano:

const a: any = { name: 'A' };
const b: any = { name: 'B', parent: a };
a.child = b;
logger.info('graph', a);  // → { name: 'A', child: { name: 'B', parent: '[Circular]' } }

Referencia API

createSerializerBridge y la interface SerializerBridge están marcados @internal: se exportan desde el subpath ./serializers para uso avanzado pero TypeDoc no los documenta. Si los usas, consulta src/serializers/SerializerBridge.ts.