parent
5465379e93
commit
14a1a68aed
@ -0,0 +1,14 @@
|
||||
import { createLogger} from "./logr.engine.ts";
|
||||
|
||||
|
||||
|
||||
export * from './logr.types.ts';
|
||||
export * from './logr.engine.ts';
|
||||
|
||||
// ============================================================================
|
||||
// TYPE HELPER
|
||||
// ============================================================================
|
||||
|
||||
|
||||
export type Logger = ReturnType<typeof createLogger>;
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
import type { I18n, I18nString } from '@/libs/i18n';
|
||||
import type { LoggerOptions, LogEntry, MessageCategory} from "./logr.types.ts";
|
||||
import { LogLevel } from "./logr.types";
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// FACTORY
|
||||
// ============================================================================
|
||||
|
||||
export function createLogger(i18n: I18n, options: LoggerOptions = {}) {
|
||||
let level : LogLevel = options.level ?? LogLevel.WARN;
|
||||
let maxLogs : number = options.maxLogs ?? 1000;
|
||||
let entries : LogEntry[] = [];
|
||||
|
||||
function setLevel(l: LogLevel): void {
|
||||
level = l;
|
||||
}
|
||||
|
||||
function setMaxLogs(max: number): void {
|
||||
maxLogs = max;
|
||||
}
|
||||
|
||||
function log(
|
||||
lvl : LogLevel,
|
||||
category: MessageCategory,
|
||||
message : I18nString,
|
||||
context?: Record<string, unknown>
|
||||
): void {
|
||||
if (lvl < level) return;
|
||||
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date(),
|
||||
level : lvl,
|
||||
category,
|
||||
message,
|
||||
context,
|
||||
};
|
||||
|
||||
entries.push(entry);
|
||||
|
||||
if (entries.length > maxLogs) {
|
||||
entries.shift();
|
||||
}
|
||||
|
||||
output(entry);
|
||||
}
|
||||
|
||||
function output(entry: LogEntry): void {
|
||||
const message = i18n.ts(entry.message);
|
||||
const prefix = `[${entry.category}]`;
|
||||
const timestamp = entry.timestamp.toISOString();
|
||||
const ctx = entry.context ?? '';
|
||||
|
||||
switch (entry.level) {
|
||||
case LogLevel.DEBUG: console.debug(timestamp, prefix, message, ctx); break;
|
||||
case LogLevel.INFO: console.info (timestamp, prefix, message, ctx); break;
|
||||
case LogLevel.WARN: console.warn (timestamp, prefix, message, ctx); break;
|
||||
case LogLevel.ERROR: console.error(timestamp, prefix, message, ctx); break;
|
||||
}
|
||||
}
|
||||
|
||||
function debug(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.DEBUG, category, message, context);
|
||||
}
|
||||
|
||||
function info(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.INFO, category, message, context);
|
||||
}
|
||||
|
||||
function warn(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.WARN, category, message, context);
|
||||
}
|
||||
|
||||
function error(category: MessageCategory, message: I18nString, context?: Record<string, unknown>): void {
|
||||
log(LogLevel.ERROR, category, message, context);
|
||||
}
|
||||
|
||||
function getLogs(filters?: {
|
||||
level? : LogLevel;
|
||||
category?: MessageCategory;
|
||||
since? : Date;
|
||||
}): LogEntry[] {
|
||||
let result = entries;
|
||||
|
||||
if (filters?.level !== undefined) {
|
||||
result = result.filter(e => e.level === filters.level);
|
||||
}
|
||||
if (filters?.category !== undefined) {
|
||||
result = result.filter(e => e.category === filters.category);
|
||||
}
|
||||
if (filters?.since !== undefined) {
|
||||
result = result.filter(e => e.timestamp >= filters.since!);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
function serialize(): string {
|
||||
return JSON.stringify(
|
||||
entries.map(e => ({
|
||||
...e,
|
||||
message : i18n.ts(e.message),
|
||||
locale : i18n.getLocale(),
|
||||
timestamp: e.timestamp.toISOString(),
|
||||
})),
|
||||
null,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
return { debug, info, warn, error, getLogs, clear, serialize, setLevel, setMaxLogs };
|
||||
}
|
||||
|
||||
@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createLogger, LogLevel } from './index.ts';
|
||||
import { createI18n } from '@/libs/i18n';
|
||||
import type { TranslationNode, SupportedLocale } from '@/libs/i18n';
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// SCHEMA Y I18N DE TEST
|
||||
// ============================================================================
|
||||
|
||||
const schema = {
|
||||
auth: {
|
||||
loginFailed : { es: 'Login fallido', en: 'Login failed' },
|
||||
sessionExpired: { es: 'Sesión expirada', en: 'Session expired' },
|
||||
},
|
||||
db: {
|
||||
connectionError: { es: 'Error de conexión', en: 'Connection error' },
|
||||
},
|
||||
} satisfies TranslationNode;
|
||||
|
||||
function makeI18n(locale: SupportedLocale = 'es') {
|
||||
const i18n = createI18n(schema, 'es');
|
||||
i18n.setLocale(locale);
|
||||
return i18n;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('createLogger', () => {
|
||||
|
||||
describe('instanciación', () => {
|
||||
it('crea una instancia con nivel WARN por defecto', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogger(i18n);
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.warn('auth', { es: 'aviso' });
|
||||
logger.debug('auth', { es: 'debug' });
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(debug).not.toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
it('respeta el nivel configurado en options', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
const debug = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', { es: 'debug msg' });
|
||||
expect(debug).toHaveBeenCalledTimes(1);
|
||||
|
||||
debug.mockRestore();
|
||||
});
|
||||
|
||||
it('instancias son independientes entre sí', () => {
|
||||
const i18n = makeI18n();
|
||||
const l1 = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
const l2 = createLogger(i18n, { level: LogLevel.ERROR });
|
||||
|
||||
l1.setLevel(LogLevel.NONE);
|
||||
expect(l2.getLogs()).toHaveLength(0); // no se contaminan
|
||||
});
|
||||
});
|
||||
|
||||
describe('niveles de log', () => {
|
||||
let i18n: ReturnType<typeof makeI18n>;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = makeI18n();
|
||||
logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
});
|
||||
|
||||
it('debug llama a console.debug', () => {
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
logger.debug('auth', { es: 'msg debug' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('info llama a console.info', () => {
|
||||
const spy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
logger.info('auth', { es: 'msg info' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('warn llama a console.warn', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
logger.warn('auth', { es: 'msg warn' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('error llama a console.error', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
logger.error('auth', { es: 'msg error' });
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('no loguea si el nivel es inferior al configurado', () => {
|
||||
const logger = createLogger(i18n, { level: LogLevel.ERROR });
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
logger.warn('auth', { es: 'esto no debe salir' });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolución i18n', () => {
|
||||
it('resuelve el mensaje con el locale actual del i18n', () => {
|
||||
const i18n = makeI18n('en');
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'[auth]',
|
||||
'Login failed',
|
||||
''
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('resuelve en español cuando el locale es es', () => {
|
||||
const i18n = makeI18n('es');
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'[auth]',
|
||||
'Login fallido',
|
||||
''
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('refleja el cambio de locale automáticamente sin reconfigurar el logger', () => {
|
||||
const i18n = makeI18n('es');
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
expect(spy).toHaveBeenLastCalledWith(expect.any(String), '[auth]', 'Login fallido', '');
|
||||
|
||||
// Cambiamos locale en i18n — el logger lo recoge automáticamente
|
||||
i18n.setLocale('en');
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
expect(spy).toHaveBeenLastCalledWith(expect.any(String), '[auth]', 'Login failed', '');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('acepta I18nString como string simple (pass-through)', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
const spy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', 'mensaje fijo');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'[auth]',
|
||||
'mensaje fijo',
|
||||
''
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogs()', () => {
|
||||
let i18n: ReturnType<typeof makeI18n>;
|
||||
let logger: ReturnType<typeof createLogger>;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n = makeI18n();
|
||||
logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('retorna todos los logs sin filtros', () => {
|
||||
logger.debug('auth', { es: 'a' });
|
||||
logger.warn ('auth', { es: 'b' });
|
||||
logger.error('db', { es: 'c' });
|
||||
expect(logger.getLogs()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('filtra por nivel', () => {
|
||||
logger.debug('auth', { es: 'a' });
|
||||
logger.warn ('auth', { es: 'b' });
|
||||
logger.error('db', { es: 'c' });
|
||||
expect(logger.getLogs({ level: LogLevel.WARN })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('filtra por categoría', () => {
|
||||
logger.debug('auth', { es: 'a' });
|
||||
logger.warn ('auth', { es: 'b' });
|
||||
logger.error('db', { es: 'c' });
|
||||
expect(logger.getLogs({ category: 'auth' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filtra por fecha', async () => {
|
||||
logger.debug('auth', { es: 'antes' });
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
const since = new Date();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
logger.warn('auth', { es: 'después' });
|
||||
|
||||
expect(logger.getLogs({ since })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxLogs', () => {
|
||||
it('respeta el límite de entradas', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG, maxLogs: 3 });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
logger.debug('auth', { es: `msg ${i}` });
|
||||
}
|
||||
|
||||
expect(logger.getLogs()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('descarta los más antiguos cuando supera el límite', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG, maxLogs: 2 });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', { es: 'primero' });
|
||||
logger.debug('auth', { es: 'segundo' });
|
||||
logger.debug('auth', { es: 'tercero' });
|
||||
|
||||
const logs = logger.getLogs();
|
||||
expect(logs[0].message).toEqual({ es: 'segundo' });
|
||||
expect(logs[1].message).toEqual({ es: 'tercero' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear()', () => {
|
||||
it('vacía el historial', () => {
|
||||
const i18n = makeI18n();
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', { es: 'msg' });
|
||||
logger.clear();
|
||||
|
||||
expect(logger.getLogs()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize()', () => {
|
||||
it('exporta los logs como JSON con mensajes resueltos', () => {
|
||||
const i18n = makeI18n('en');
|
||||
const logger = createLogger(i18n, { level: LogLevel.DEBUG });
|
||||
vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
|
||||
logger.debug('auth', schema.auth.loginFailed);
|
||||
|
||||
const parsed = JSON.parse(logger.serialize());
|
||||
expect(parsed[0].message).toBe('Login failed');
|
||||
expect(parsed[0].locale).toBe('en');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,29 @@
|
||||
|
||||
import type { I18nString, SupportedLocale } from '@/libs/i18n';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
NONE = 4
|
||||
}
|
||||
|
||||
export type MessageCategory = string;
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp : Date;
|
||||
level : LogLevel;
|
||||
category : MessageCategory;
|
||||
message : I18nString;
|
||||
context? : Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface LoggerOptions {
|
||||
level? : LogLevel;
|
||||
maxLogs? : number;
|
||||
}
|
||||
Loading…
Reference in new issue