AL.
🇺🇸 EN
Volver al blog
JavaScript y React · 8 min de lectura

Logging en TypeScript desde Cero: Isomorfico, Performante y Extensible

Como construir un logger rapido, liviano e isomorfico en TypeScript con transports personalizados, optimizacion de rendimiento y diseno extensible.


Logging es una de esas cosas que todo proyecto necesita, pero frecuentemente se toma a la ligera hasta que es demasiado tarde. Alcanzas una biblioteca popular como Winston o Pino, la conectas y sigues adelante. Pero, ¿qué pasa si necesitas algo más liviano? ¿Qué pasa si quieres entender exactamente cómo funciona? ¿O si necesitas logging isomórfico que funcione tanto en Node.js como en el navegador?

En este artículo, construiremos un sistema de logging desde cero en TypeScript. Cubriremos niveles de log, logging estructurado, transports personalizados, diseño isomórfico y optimizaciones de rendimiento. Al final, tendrás un logger completo y extensible que puedes adaptar a tus necesidades.

Por Qué Construir Tu Propio Logger

Antes de comenzar, ¿por qué molestarse? Las bibliotecas existentes son excelentes, pero:

  • Tamaño del bundle: Winston y Pino son poderosos pero pesados. Para aplicaciones frontend o serverless, cada KB cuenta.
  • Control: Sabes exactamente qué está pasando. Sin magia, sin sorpresas.
  • Aprendizaje: Construir un logger te enseña sobre diseño de APIs, patrones de rendimiento y arquitectura TypeScript.
  • Personalización: Necesitas características específicas que las bibliotecas existentes no ofrecen fácilmente.

Dicho esto, para aplicaciones de producción a gran escala, usar una biblioteca probada en batalla suele ser la elección correcta. Este ejercicio es tanto educativo como práctico.

Diseñando la API

Un buen logger debería ser simple de usar pero flexible en su implementación. Comenzamos con la interfaz.

Niveles de Log

Los loggers estándar tienen múltiples niveles de severidad:

export enum LogLevel {
  DEBUG = 0,
  INFO = 1,
  WARN = 2,
  ERROR = 3,
  FATAL = 4,
}

Niveles más bajos = más verbose. Puedes establecer un nivel mínimo y filtrar todo lo que esté por debajo.

Logging Estructurado

En lugar de solo cadenas, queremos logging estructurado: objetos que pueden serializarse a JSON. Esto facilita el parsing, búsqueda y análisis.

export interface LogEntry {
  level: LogLevel;
  message: string;
  timestamp: number;
  metadata?: Record<string, any>;
}

La Interfaz del Logger

export interface ILogger {
  debug(message: string, metadata?: Record<string, any>): void;
  info(message: string, metadata?: Record<string, any>): void;
  warn(message: string, metadata?: Record<string, any>): void;
  error(message: string, metadata?: Record<string, any>): void;
  fatal(message: string, metadata?: Record<string, any>): void;
}

Uso simple:

logger.info("User logged in", { userId: 123 });
logger.error("Payment failed", { orderId: 456, reason: "insufficient funds" });

Implementando el Logger Core

Ahora construyamos la clase Logger principal.

export class Logger implements ILogger {
  private minLevel: LogLevel;
  private transports: Transport[] = [];

  constructor(minLevel: LogLevel = LogLevel.INFO) {
    this.minLevel = minLevel;
  }

  public addTransport(transport: Transport): void {
    this.transports.push(transport);
  }

  public debug(message: string, metadata?: Record<string, any>): void {
    this.log(LogLevel.DEBUG, message, metadata);
  }

  public info(message: string, metadata?: Record<string, any>): void {
    this.log(LogLevel.INFO, message, metadata);
  }

  public warn(message: string, metadata?: Record<string, any>): void {
    this.log(LogLevel.WARN, message, metadata);
  }

  public error(message: string, metadata?: Record<string, any>): void {
    this.log(LogLevel.ERROR, message, metadata);
  }

  public fatal(message: string, metadata?: Record<string, any>): void {
    this.log(LogLevel.FATAL, message, metadata);
  }

  private log(level: LogLevel, message: string, metadata?: Record<string, any>): void {
    if (level < this.minLevel) {
      return; // Filtrar niveles por debajo del mínimo
    }

    const entry: LogEntry = {
      level,
      message,
      timestamp: Date.now(),
      metadata,
    };

    for (const transport of this.transports) {
      transport.write(entry);
    }
  }
}

Esto es simple pero efectivo. El logger:

  • Filtra mensajes por nivel.
  • Crea una LogEntry estructurada.
  • Pasa la entrada a todos los transports registrados.

Creando Transports

Los transports manejan dónde van los logs: consola, archivos, APIs remotas, etc.

Interfaz Transport

export interface Transport {
  write(entry: LogEntry): void;
}

Console Transport

El transport más simple escribe a console:

export class ConsoleTransport implements Transport {
  public write(entry: LogEntry): void {
    const levelName = LogLevel[entry.level];
    const timestamp = new Date(entry.timestamp).toISOString();
    const metadata = entry.metadata ? ` ${JSON.stringify(entry.metadata)}` : "";

    console.log(`[${timestamp}] ${levelName}: ${entry.message}${metadata}`);
  }
}

Uso:

const logger = new Logger(LogLevel.DEBUG);
logger.addTransport(new ConsoleTransport());

logger.info("Server started", { port: 3000 });
// Output: [2025-08-27T10:30:00.000Z] INFO: Server started {"port":3000}

File Transport (Node.js)

Para ambientes Node.js, podemos escribir a un archivo:

import { appendFileSync } from "fs";

export class FileTransport implements Transport {
  constructor(private filePath: string) {}

  public write(entry: LogEntry): void {
    const line = JSON.stringify({
      level: LogLevel[entry.level],
      message: entry.message,
      timestamp: entry.timestamp,
      metadata: entry.metadata,
    }) + "\n";

    appendFileSync(this.filePath, line, "utf-8");
  }
}

Uso:

logger.addTransport(new FileTransport("./logs/app.log"));

Esto escribe logs estructurados en formato JSON, una línea por entrada.

HTTP Transport

Para servicios centralizados de logging (como Logtail, Datadog, etc.), podemos enviar logs vía HTTP:

export class HttpTransport implements Transport {
  constructor(
    private endpoint: string,
    private headers: Record<string, string> = {}
  ) {}

  public write(entry: LogEntry): void {
    const payload = JSON.stringify({
      level: LogLevel[entry.level],
      message: entry.message,
      timestamp: entry.timestamp,
      metadata: entry.metadata,
    });

    fetch(this.endpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        ...this.headers,
      },
      body: payload,
    }).catch((err) => {
      console.error("Failed to send log to HTTP endpoint:", err);
    });
  }
}

Uso:

logger.addTransport(
  new HttpTransport("https://logs.myapp.com/ingest", {
    Authorization: "Bearer YOUR_TOKEN",
  })
);

Diseño Isomórfico

Para hacer que nuestro logger funcione tanto en Node.js como en el navegador, necesitamos manejar diferencias de plataforma con gracia.

Detectar el Entorno

export const isNode = typeof process !== "undefined" && process.versions?.node;
export const isBrowser = typeof window !== "undefined";

Transport Condicional

const logger = new Logger(LogLevel.INFO);

if (isNode) {
  logger.addTransport(new FileTransport("./logs/app.log"));
} else if (isBrowser) {
  logger.addTransport(new ConsoleTransport());
  logger.addTransport(new HttpTransport("https://logs.myapp.com/ingest"));
}

Ahora tienes un logger que se adapta a su entorno.

Optimizaciones de Rendimiento

El logging puede convertirse en un cuello de botella si no tienes cuidado. Aquí hay algunas optimizaciones:

1. Lazy Serialization

No serialices metadata a menos que el nivel de log esté habilitado:

private log(level: LogLevel, message: string, metadata?: Record<string, any>): void {
  if (level < this.minLevel) {
    return; // Salir temprano antes de cualquier trabajo
  }

  // Solo crea la entrada si pasó el filtro de nivel
  const entry: LogEntry = {
    level,
    message,
    timestamp: Date.now(),
    metadata,
  };

  for (const transport of this.transports) {
    transport.write(entry);
  }
}

2. Buffering para HTTP Transport

En lugar de enviar cada log inmediatamente, haz buffer y envía en lotes:

export class BufferedHttpTransport implements Transport {
  private buffer: LogEntry[] = [];
  private flushInterval: NodeJS.Timeout;

  constructor(
    private endpoint: string,
    private batchSize: number = 10,
    private flushMs: number = 5000
  ) {
    this.flushInterval = setInterval(() => this.flush(), flushMs);
  }

  public write(entry: LogEntry): void {
    this.buffer.push(entry);
    if (this.buffer.length >= this.batchSize) {
      this.flush();
    }
  }

  private flush(): void {
    if (this.buffer.length === 0) return;

    const batch = this.buffer.splice(0, this.buffer.length);
    fetch(this.endpoint, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(batch),
    }).catch((err) => {
      console.error("Failed to flush logs:", err);
    });
  }

  public destroy(): void {
    clearInterval(this.flushInterval);
    this.flush();
  }
}

3. Async File Writing (Node.js)

Usa fs.promises.appendFile para evitar bloquear el event loop:

import { appendFile } from "fs/promises";

export class AsyncFileTransport implements Transport {
  constructor(private filePath: string) {}

  public write(entry: LogEntry): void {
    const line = JSON.stringify({
      level: LogLevel[entry.level],
      message: entry.message,
      timestamp: entry.timestamp,
      metadata: entry.metadata,
    }) + "\n";

    appendFile(this.filePath, line, "utf-8").catch((err) => {
      console.error("Failed to write log to file:", err);
    });
  }
}

Extensibilidad

Nuestro logger es extensible por diseño. Quieres agregar:

  • Filtering personalizado: Modifica el método log para aplicar filtros adicionales.
  • Formatters: Añade un paso de formatting antes de que los transports escriban.
  • Contexto: Adjunta metadata global (ej. requestId, userId) a cada log.

Ejemplo: Contexto Global

export class Logger implements ILogger {
  private context: Record<string, any> = {};

  public setContext(ctx: Record<string, any>): void {
    this.context = { ...this.context, ...ctx };
  }

  private log(level: LogLevel, message: string, metadata?: Record<string, any>): void {
    if (level < this.minLevel) return;

    const entry: LogEntry = {
      level,
      message,
      timestamp: Date.now(),
      metadata: { ...this.context, ...metadata },
    };

    for (const transport of this.transports) {
      transport.write(entry);
    }
  }
}

Uso:

logger.setContext({ service: "api", version: "1.0.0" });
logger.info("Request received", { path: "/users" });
// Metadata incluirá: { service: "api", version: "1.0.0", path: "/users" }

Conclusión

Has construido un sistema de logging completo en TypeScript desde cero. Soporta:

  • Múltiples niveles de log con filtering.
  • Logging estructurado con metadata.
  • Transports personalizados (console, file, HTTP).
  • Diseño isomórfico para Node.js y browser.
  • Optimizaciones de rendimiento (lazy serialization, buffering, async I/O).
  • Extensibilidad para contexto, formatting y filtering personalizado.

Este logger es liviano, rápido y completamente tuyo. Puedes adaptarlo a cualquier caso de uso, agregar nuevos transports o integrarlo con servicios de logging externos.

Para proyectos de producción, considera usar bibliotecas establecidas como Pino o Winston que tienen años de optimización y pruebas en batalla. Pero ahora entiendes cómo funcionan internamente, lo cual te hace un mejor desarrollador sin importar cuál uses.