@jmlq/logger 0.1.0-alpha.5 → 0.1.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +637 -169
- package/package.json +2 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2025 mlahuasi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -34,153 +34,355 @@ npm i pg@^8.16.3
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## 🧩 Configuración
|
|
38
38
|
|
|
39
|
-
###
|
|
39
|
+
### 🔐 Variables de Entorno (.env)
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
>
|
|
51
|
-
>
|
|
41
|
+
```ini
|
|
42
|
+
# @jmlq/logger
|
|
43
|
+
# Nivel mínimo por entorno (dev: debug, prod: warn)
|
|
44
|
+
LOGGER_LEVEL=debug
|
|
45
|
+
# PII (Personally Identifiable Information)
|
|
46
|
+
LOGGER_PII_ENABLED=true
|
|
47
|
+
LOGGER_PII_INCLUDE_DEFAULTS=true
|
|
48
|
+
|
|
49
|
+
# MONGO DB @jmlq/logger-plugin-mongo
|
|
50
|
+
MONGO_URL=mongodb://<user>:<password>@localhost:<port>
|
|
51
|
+
MONGO_DB_NAME=<db-name>
|
|
52
|
+
MONGO_COLLECTION=<collection-name>
|
|
53
|
+
LOGGER_MONGO_RETENTION_DAYS=30
|
|
54
|
+
|
|
55
|
+
# POSTGRESQL @jmlq/logger-plugin-postgresql
|
|
56
|
+
POSTGRES_URL="postgresql://mlahuasi:123456@localhost:5432/NOC"
|
|
57
|
+
POSTGRES_DB=<db-name>
|
|
58
|
+
POSTGRES_SCHEMA=public
|
|
59
|
+
POSTGRES_TABLE=<table-name>
|
|
60
|
+
LOGGER_PG_RETENTION_DAYS=30
|
|
61
|
+
|
|
62
|
+
# FILESYSTEM @jmlq/logger-plugin-fs
|
|
63
|
+
LOGGER_FS_PATH=./logs
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
> > - **`use-cases/`**
|
|
55
|
-
> > > - `save-log.ts` — Aplica `minLevel` + PII y delega a `datasource.save()`.
|
|
56
|
-
> > > - `get-logs.ts` — Recupera logs con filtros/paginación si el datasource lo soporta.
|
|
57
|
-
> > > - `flush-buffers.ts` — Ejecuta `flush()` en el datasource cuando exista.
|
|
65
|
+
```
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
> > - **`adapters/`**
|
|
61
|
-
> > > - `composite.datasource.ts` — Fan-out: envía el log a varios datasources y no falla el todo si uno cae (avisa con `console.warn`).
|
|
67
|
+
---
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
> > - **`factory/`**
|
|
65
|
-
> > > - `create-logger.ts` — Crea el logger listo para usar (`trace…fatal`, `flush`, `dispose`) conectando casos de uso + PII.
|
|
69
|
+
### 🚀 Uso del paquete
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
> > - Re-exporta lo público: `createLogger`, `LogLevel`, tipos/contratos y `CompositeDatasource`.
|
|
71
|
+
#### 1) Estructura recomendada
|
|
69
72
|
|
|
70
|
-
|
|
73
|
+
```
|
|
74
|
+
src/
|
|
75
|
+
├─ infrastructure/
|
|
76
|
+
│ ├─ logger/
|
|
77
|
+
│ │ ├─ adapters/
|
|
78
|
+
│ │ │ ├─ fs.adapter.ts
|
|
79
|
+
│ │ │ ├─ mongo.adapter.ts
|
|
80
|
+
│ │ │ └─ postgresql.adapter.ts
|
|
81
|
+
│ │ ├─ settings/
|
|
82
|
+
│ │ │ ├─ pii.settings.ts
|
|
83
|
+
│ │ │ └─ loglevel.settings.ts
|
|
84
|
+
│ │ └─ bootstrap.ts # LoggerBootstrap (orquesta adapters + PII)
|
|
85
|
+
│ └─ plugins/
|
|
86
|
+
│ ├─ env.plugin.ts
|
|
87
|
+
│ └─ index.ts
|
|
88
|
+
├─ config/
|
|
89
|
+
│ └─ logger/
|
|
90
|
+
│ └─ index.ts # singleton global: loggerReady / flush / dispose
|
|
91
|
+
└─ presentation/
|
|
92
|
+
└─ server.ts # uso del logger en Express
|
|
71
93
|
|
|
72
|
-
|
|
94
|
+
```
|
|
73
95
|
|
|
74
|
-
|
|
96
|
+
#### 2) Settings
|
|
75
97
|
|
|
76
|
-
|
|
98
|
+
#### 2.1) pii.settings.ts (patrones PII + merge)
|
|
77
99
|
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
```ts
|
|
101
|
+
//Se definine reglas de redacción PII (buscar-y-reemplazar con regex) que el logger aplica antes de persistir/emitir un log.
|
|
102
|
+
|
|
103
|
+
export type PiiReplacement = {
|
|
104
|
+
pattern: string;
|
|
105
|
+
replaceWith: string;
|
|
106
|
+
flags?: string; // regex (g, i, m, s, u, y)
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export interface PiiConfig {
|
|
110
|
+
enabled: boolean;
|
|
111
|
+
whitelistKeys?: string[];
|
|
112
|
+
blacklistKeys?: string[];
|
|
113
|
+
patterns?: PiiReplacement[];
|
|
114
|
+
deep?: boolean;
|
|
115
|
+
includeDefaults?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// patrones “de fábrica” (del sistema):
|
|
119
|
+
export const DEFAULT_PII_PATTERNS: PiiReplacement[] = [
|
|
120
|
+
{
|
|
121
|
+
pattern: String.raw`[^@\n\r ]+@[^@\n\r ]+`,
|
|
122
|
+
replaceWith: "[EMAIL]",
|
|
123
|
+
flags: "g",
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
// Ver abajo explicación flags
|
|
127
|
+
|
|
128
|
+
// patrones “del cliente/proyecto”:
|
|
129
|
+
export const clientPiiPatterns: PiiReplacement[] = [
|
|
130
|
+
{ pattern: String.raw`\b\d{10}\b`, flags: "g", replaceWith: "[EC_DNI]" },
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// lista negra de nombres de clave que se siempre se ocultan
|
|
134
|
+
export const redactKeys: string[] = ["password", "secret"];
|
|
135
|
+
// lista blanca de nombres de clave que no se deben ocultar por clave
|
|
136
|
+
export const preserveKeys: string[] = ["city"];
|
|
137
|
+
|
|
138
|
+
// Helpers
|
|
139
|
+
// 1. Filtra valores no válidos: elimina null, undefined y "" (cadena vacía).
|
|
140
|
+
// 2. Elimina duplicados usando Set.
|
|
141
|
+
// 3. Conserva el primero de cada valor repetido (porque Set guarda la primera aparición).
|
|
142
|
+
export function dedupeStrings(arr: Array<string | undefined | null>): string[] {
|
|
143
|
+
return [...new Set(arr.filter((x): x is string => !!x && x.length > 0))];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 1. Construye una clave de identidad por patrón: pattern + "__" + replaceWith + "__" + (flags || "").
|
|
147
|
+
// 2. Inserta cada elemento en un `Map` usando esa clave. Si la clave ya existe, sobrescribe el anterior (es decir, gana el último).
|
|
148
|
+
// 3. Devuelve los valores únicos del `Map`.
|
|
149
|
+
export function dedupePatterns(arr: PiiReplacement[]): PiiReplacement[] {
|
|
150
|
+
const m = new Map<string, PiiReplacement>();
|
|
151
|
+
for (const p of arr)
|
|
152
|
+
m.set(`${p.pattern}__${p.replaceWith}__${p.flags ?? ""}`, p);
|
|
153
|
+
return [...m.values()];
|
|
154
|
+
}
|
|
84
155
|
|
|
156
|
+
// Builder unificado
|
|
157
|
+
export function buildPiiConfig(
|
|
158
|
+
opts?: PiiConfig
|
|
159
|
+
): Required<Omit<PiiConfig, "includeDefaults">> {
|
|
160
|
+
const includeDefaults = opts?.includeDefaults ?? true;
|
|
161
|
+
|
|
162
|
+
const patterns = dedupePatterns([
|
|
163
|
+
...(includeDefaults ? DEFAULT_PII_PATTERNS : []),
|
|
164
|
+
...clientPiiPatterns,
|
|
165
|
+
...(opts?.patterns ?? []),
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const whitelistKeys = dedupeStrings([
|
|
169
|
+
...(opts?.whitelistKeys ?? []),
|
|
170
|
+
...preserveKeys,
|
|
171
|
+
]);
|
|
172
|
+
const blacklistKeys = dedupeStrings([
|
|
173
|
+
...(opts?.blacklistKeys ?? []),
|
|
174
|
+
...redactKeys,
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
enabled: !!opts?.enabled,
|
|
179
|
+
whitelistKeys,
|
|
180
|
+
blacklistKeys,
|
|
181
|
+
patterns,
|
|
182
|
+
deep: opts?.deep ?? true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
```
|
|
85
186
|
|
|
86
|
-
|
|
87
|
-
POSTGRES_URL=postgresql://<usuario>:<password>@localhost:5432/my_database
|
|
88
|
-
# URL de conexión a la base de datos principal
|
|
89
|
-
POSTGRES_DB=my_database
|
|
90
|
-
# Credenciales y nombre de la base de datos
|
|
187
|
+
> - **REGEX FLAGS**
|
|
91
188
|
|
|
92
|
-
|
|
93
|
-
LOGGER_FS_PATH=./logs/app.log
|
|
94
|
-
# Ruta local donde se almacenarán los logs en formato JSONL
|
|
189
|
+
> > - `g` (**global**): Encuentra `todas las coincidencias` en el texto, no solo la primera.
|
|
95
190
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
191
|
+
```ts
|
|
192
|
+
const regex = /\d{2}/g;
|
|
193
|
+
"123456".match(regex); // ["12", "34", "56"]
|
|
99
194
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
195
|
+
// /\d{2}/ busca pares de dígitos.
|
|
196
|
+
// "12" en índices 0-1
|
|
197
|
+
// "34" en índices 2-3
|
|
198
|
+
// "56" en índices 4-5
|
|
199
|
+
```
|
|
103
200
|
|
|
104
|
-
|
|
105
|
-
# Incluye los patrones de PII por defecto además de los definidos por el cliente
|
|
201
|
+
> > - `i` (**ignore case**): Ignora mayúsculas/minúsculas.
|
|
106
202
|
|
|
203
|
+
```ts
|
|
204
|
+
// Sin "i" (sensible a mayúsculas/minúsculas)
|
|
205
|
+
const regexCaseSensitive = /secret/;
|
|
206
|
+
"SECRET".match(regexCaseSensitive); // null
|
|
207
|
+
"secret".match(regexCaseSensitive); // ["secret"]
|
|
208
|
+
|
|
209
|
+
// Con "i" (ignora mayúsculas/minúsculas)
|
|
210
|
+
const regexIgnoreCase = /secret/i;
|
|
211
|
+
"SECRET".match(regexIgnoreCase); // ["SECRET"]
|
|
212
|
+
"Secret".match(regexIgnoreCase); // ["Secret"]
|
|
213
|
+
"sEcReT".match(regexIgnoreCase); // ["sEcReT"]
|
|
107
214
|
```
|
|
108
215
|
|
|
109
|
-
|
|
216
|
+
> > - `m` (**multiline**): Permite que `^` y `$` funcionen en cada línea, no solo al inicio/fin del string completo.
|
|
110
217
|
|
|
111
|
-
|
|
218
|
+
```ts
|
|
219
|
+
const texto = `uno
|
|
220
|
+
FOO
|
|
221
|
+
tres`;
|
|
112
222
|
|
|
113
|
-
|
|
223
|
+
// Sin "m": ^ solo reconoce el inicio de *todo* el string
|
|
224
|
+
const regexNormal = /^FOO/;
|
|
225
|
+
console.log(texto.match(regexNormal)); // null
|
|
114
226
|
|
|
115
|
-
|
|
227
|
+
// Con "m": ^ reconoce también el inicio de cada línea
|
|
228
|
+
const regexMultiline = /^FOO/m;
|
|
229
|
+
console.log(texto.match(regexMultiline)); // ["FOO"]
|
|
230
|
+
```
|
|
116
231
|
|
|
117
|
-
|
|
118
|
-
Estos se combinan con los patrones por defecto del core (`LOGGER_PII_INCLUDE_DEFAULTS=true`).
|
|
232
|
+
> > - `s` (**dotAll**): Permite que `.` coincida también con saltos de línea (`\n`).
|
|
119
233
|
|
|
120
234
|
```ts
|
|
121
|
-
|
|
235
|
+
const texto = "a\nb";
|
|
122
236
|
|
|
123
|
-
|
|
237
|
+
// Sin "s": el punto (.) no captura saltos de línea
|
|
238
|
+
const regexNormal = /a.b/;
|
|
239
|
+
texto.match(regexNormal); // null
|
|
124
240
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
regex: /\b\d{10}\b/g,
|
|
130
|
-
replacement: "[REDACTED_CEDULA]",
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
// Ejemplo: token JWT simulado
|
|
134
|
-
regex: /\beyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\b/g,
|
|
135
|
-
replacement: "[REDACTED_JWT]",
|
|
136
|
-
},
|
|
137
|
-
];
|
|
241
|
+
// Con "s": el punto (.) sí captura saltos de línea
|
|
242
|
+
const regexDotAll = /a.b/s;
|
|
243
|
+
texto.match(regexDotAll); // ["a\nb"]
|
|
244
|
+
```
|
|
138
245
|
|
|
139
|
-
|
|
140
|
-
export const redactKeys = ["password", "secret", "token"];
|
|
246
|
+
> > - `u` (**unicode**): Habilita soporte Unicode completo en regex (ejemplo, emojis o caracteres fuera del BMP).
|
|
141
247
|
|
|
142
|
-
|
|
143
|
-
|
|
248
|
+
```ts
|
|
249
|
+
const regex = /\u{1F600}/u; // 😀
|
|
250
|
+
"😀".match(regex); // ["😀"]
|
|
144
251
|
```
|
|
145
252
|
|
|
146
|
-
|
|
253
|
+
> > - `y` (**sticky**): Solo encuentra coincidencias en la posición exacta del índice actual (lastIndex).
|
|
147
254
|
|
|
148
|
-
|
|
255
|
+
```ts
|
|
256
|
+
const regexSticky = /\d{2}/y;
|
|
149
257
|
|
|
150
|
-
|
|
151
|
-
|
|
258
|
+
regexSticky.lastIndex = 0;
|
|
259
|
+
regexSticky.exec("123456");
|
|
260
|
+
// ["12"]
|
|
152
261
|
|
|
153
|
-
|
|
154
|
-
|
|
262
|
+
regexSticky.lastIndex = 2;
|
|
263
|
+
regexSticky.exec("123456");
|
|
264
|
+
// ["34"]
|
|
155
265
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
266
|
+
regexSticky.lastIndex = 4;
|
|
267
|
+
regexSticky.exec("123456");
|
|
268
|
+
// ["56"]
|
|
269
|
+
|
|
270
|
+
regexSticky.lastIndex = 1;
|
|
271
|
+
regexSticky.exec("123456");
|
|
272
|
+
// null (porque en índice 1 hay "2", pero necesita empezar justo ahí y no hay 2 dígitos completos desde esa posición)
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Ejemplos:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const text = "Usuario: 12345, Otro: 67890";
|
|
279
|
+
const rules: PiiReplacement[] = [
|
|
280
|
+
{ pattern: "\\d{5}", replaceWith: "[ID]", flags: "g" },
|
|
281
|
+
];
|
|
282
|
+
console.log(redact(text, rules));
|
|
283
|
+
// Usuario: [ID], Otro: [ID]
|
|
284
|
+
|
|
285
|
+
// Donde:
|
|
286
|
+
// \d → significa un dígito (0–9).
|
|
287
|
+
// {5} → significa exactamente 5 repeticiones seguidas.
|
|
288
|
+
```
|
|
164
289
|
|
|
165
|
-
|
|
166
|
-
|
|
290
|
+
```ts
|
|
291
|
+
const text = "Password=1234; PASSWORD=5678; password=9999";
|
|
292
|
+
const rules: PiiReplacement[] = [
|
|
293
|
+
{ pattern: "password=\\d+", replaceWith: "password=[REDACTED]", flags: "gi" },
|
|
294
|
+
];
|
|
295
|
+
console.log(redact(text, rules));
|
|
296
|
+
// password=[REDACTED]; password=[REDACTED]; password=[REDACTED]
|
|
297
|
+
|
|
298
|
+
// Donde:
|
|
299
|
+
// \d → significa un dígito (0–9).
|
|
300
|
+
// + → uno o más dígitos consecutivos.
|
|
301
|
+
```
|
|
167
302
|
|
|
168
|
-
|
|
169
|
-
|
|
303
|
+
```ts
|
|
304
|
+
const text = `
|
|
305
|
+
linea1: ok
|
|
306
|
+
secret=12345
|
|
307
|
+
linea3: done
|
|
308
|
+
`;
|
|
309
|
+
|
|
310
|
+
const rules: PiiReplacement[] = [
|
|
311
|
+
{ pattern: "^secret=.*$", replaceWith: "secret=[REDACTED]", flags: "m" },
|
|
312
|
+
];
|
|
313
|
+
console.log(redact(text, rules));
|
|
314
|
+
/*
|
|
315
|
+
linea1: ok
|
|
316
|
+
secret=[REDACTED]
|
|
317
|
+
linea3: done
|
|
318
|
+
*/
|
|
319
|
+
|
|
320
|
+
// Donde
|
|
321
|
+
// . → cualquier carácter (excepto salto de línea, a menos que uses el flag s).
|
|
322
|
+
// * → cero o más repeticiones del carácter anterior (.).
|
|
323
|
+
// $ → final de la línea o final de la cadena (dependiendo si usas flag m).
|
|
324
|
+
```
|
|
170
325
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
326
|
+
```ts
|
|
327
|
+
const text = "BEGIN\n12345\nEND";
|
|
328
|
+
const rules: PiiReplacement[] = [
|
|
329
|
+
{ pattern: "BEGIN.*END", replaceWith: "[BLOCK REDACTED]", flags: "s" },
|
|
330
|
+
];
|
|
331
|
+
console.log(redact(text, rules));
|
|
332
|
+
// [BLOCK REDACTED]
|
|
174
333
|
|
|
175
|
-
//
|
|
176
|
-
|
|
334
|
+
// Donde
|
|
335
|
+
// . → cualquier carácter (excepto salto de línea, a menos que uses el flag s).
|
|
336
|
+
// * → cero o más repeticiones del carácter anterior (.).
|
|
337
|
+
```
|
|
177
338
|
|
|
178
|
-
|
|
179
|
-
|
|
339
|
+
```ts
|
|
340
|
+
const text = "Cliente: 😀 secreto=123";
|
|
341
|
+
const rules: PiiReplacement[] = [
|
|
342
|
+
{ pattern: "\\p{Emoji}", replaceWith: "[EMOJI]", flags: "gu" },
|
|
343
|
+
];
|
|
344
|
+
console.log(redact(text, rules));
|
|
345
|
+
// Cliente: [EMOJI] secreto=123
|
|
180
346
|
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
347
|
+
// Donde
|
|
348
|
+
// \p{...} → en regex con flag u (unicode), permite usar propiedades Unicode.
|
|
349
|
+
// \p{Emoji} → coincide con cualquier carácter que esté clasificado en Unicode como un emoji.
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
const text = "ID=1234 ID=5678";
|
|
354
|
+
const regexRule: PiiReplacement = {
|
|
355
|
+
pattern: "ID=\\d{4}",
|
|
356
|
+
replaceWith: "ID=[REDACTED]",
|
|
357
|
+
flags: "y",
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const regex = new RegExp(regexRule.pattern, regexRule.flags);
|
|
361
|
+
regex.lastIndex = 0;
|
|
362
|
+
console.log(regex.exec(text)); // ["ID=1234"]
|
|
363
|
+
|
|
364
|
+
regex.lastIndex = 7;
|
|
365
|
+
console.log(regex.exec(text)); // ["ID=5678"]
|
|
366
|
+
|
|
367
|
+
regex.lastIndex = 3;
|
|
368
|
+
console.log(regex.exec(text)); // null (porque no empieza justo ahí)
|
|
369
|
+
|
|
370
|
+
// Donde:
|
|
371
|
+
// \d → significa un dígito (0–9).
|
|
372
|
+
// {4} → significa exactamente 4 repeticiones consecutivas.
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
##### 2.2) loglevel.settings.ts (normalización de nivel)
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
// src/infrastructure/logger/settings/loglevel.settings.ts
|
|
379
|
+
import { LogLevel } from "@jmlq/logger";
|
|
380
|
+
|
|
381
|
+
export function toMinLevel(
|
|
382
|
+
level: LogLevel | keyof typeof LogLevel | string
|
|
383
|
+
): LogLevel {
|
|
384
|
+
if (typeof level === "number") return level as LogLevel;
|
|
385
|
+
switch (String(level || "debug").toLowerCase()) {
|
|
184
386
|
case "trace":
|
|
185
387
|
return LogLevel.TRACE;
|
|
186
388
|
case "debug":
|
|
@@ -194,103 +396,365 @@ function toMinLevel(level: string): LogLevel {
|
|
|
194
396
|
case "fatal":
|
|
195
397
|
return LogLevel.FATAL;
|
|
196
398
|
default:
|
|
197
|
-
return LogLevel.
|
|
399
|
+
return LogLevel.DEBUG;
|
|
198
400
|
}
|
|
199
401
|
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
#### 3) Adapters (capa infrastructure)
|
|
405
|
+
|
|
406
|
+
Todos `devuelven`/`encapsulan` un `ILogDatasource` para evitar acoplar la app a clases concretas.
|
|
407
|
+
|
|
408
|
+
**NOTA**: Solo se implementan los que se necesiten, por ejemplo:
|
|
409
|
+
|
|
410
|
+
> - Si se necesita generar logs en archivos se crea adapter para `FS`.
|
|
411
|
+
> - Si se necesita guardar logs en una base de datos no relacional se crea adapter para `mongo`.
|
|
412
|
+
> - Si se necesita guardar logs en una base de datos relacional se crea adapter para `postgresql`.
|
|
413
|
+
> - También se pueden combinar.
|
|
414
|
+
> - Se debe implementar al menos un adapter.
|
|
415
|
+
|
|
416
|
+
##### 3.1) FS (`fs.adapter.ts`)
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
import { createFsDatasource } from "@jmlq/logger-plugin-fs";
|
|
420
|
+
import type { ILogDatasource } from "@jmlq/logger";
|
|
421
|
+
|
|
422
|
+
export interface IFsProps {
|
|
423
|
+
basePath: string;
|
|
424
|
+
fileNamePattern: string;
|
|
425
|
+
rotationPolicy: { by: "none" | "day" | "size"; maxSizeMB?: number };
|
|
426
|
+
}
|
|
200
427
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
428
|
+
export class FsAdapter {
|
|
429
|
+
private constructor(private readonly ds: ILogDatasource) {}
|
|
430
|
+
static create(opts: IFsProps): FsAdapter | undefined {
|
|
431
|
+
try {
|
|
432
|
+
const ds = createFsDatasource({
|
|
433
|
+
basePath: opts.basePath,
|
|
434
|
+
mkdir: true,
|
|
435
|
+
fileNamePattern: opts.fileNamePattern,
|
|
436
|
+
rotation: opts.rotationPolicy,
|
|
437
|
+
onRotate: (oldP, newP) =>
|
|
438
|
+
console.log("[fs] rotated:", oldP, "->", newP),
|
|
439
|
+
onError: (e) => console.error("[fs] error:", e),
|
|
440
|
+
});
|
|
441
|
+
console.log("[logger] Conectado a FS para logs");
|
|
442
|
+
return new FsAdapter(ds);
|
|
443
|
+
} catch (e: any) {
|
|
444
|
+
console.warn("[logger] FS deshabilitado:", e?.message ?? e);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
get datasource(): ILogDatasource {
|
|
448
|
+
return this.ds;
|
|
449
|
+
}
|
|
205
450
|
}
|
|
451
|
+
```
|
|
206
452
|
|
|
207
|
-
|
|
208
|
-
async function initLogger() {
|
|
209
|
-
const datasources = [];
|
|
453
|
+
##### 3.2) Mongo (`mongo.adapter.ts`)
|
|
210
454
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
455
|
+
```ts
|
|
456
|
+
import type { ILogDatasource } from "@jmlq/logger";
|
|
457
|
+
import { createMongoInfra, MongoDatasource } from "@jmlq/logger-plugin-mongo";
|
|
458
|
+
|
|
459
|
+
export interface IMongoProps {
|
|
460
|
+
url: string;
|
|
461
|
+
dbName: string;
|
|
462
|
+
collectionName?: string;
|
|
463
|
+
retentionDays?: number | null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export class MongoAdapter {
|
|
467
|
+
private constructor(private readonly ds: ILogDatasource) {}
|
|
468
|
+
static async create(opts: IMongoProps): Promise<MongoAdapter | undefined> {
|
|
469
|
+
try {
|
|
470
|
+
const infra = await createMongoInfra({
|
|
471
|
+
url: opts.url,
|
|
472
|
+
dbName: opts.dbName,
|
|
473
|
+
collectionName: opts.collectionName ?? "logs",
|
|
474
|
+
createIfMissing: true,
|
|
475
|
+
ensureIndexes: true,
|
|
476
|
+
retentionDays: opts.retentionDays ?? 0,
|
|
477
|
+
extraIndexes: [{ key: { "meta.userId": 1 } }],
|
|
478
|
+
});
|
|
479
|
+
return new MongoAdapter(new MongoDatasource(infra.collection));
|
|
480
|
+
} catch (e: any) {
|
|
481
|
+
console.warn("[logger] Mongo deshabilitado:", e?.message ?? e);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
get datasource(): ILogDatasource {
|
|
485
|
+
return this.ds;
|
|
218
486
|
}
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
##### 3.2) Postgresql (`postgresql.adapter.ts`)
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
import type { ILogDatasource } from "@jmlq/logger";
|
|
494
|
+
import { createPostgresDatasource } from "@jmlq/logger-plugin-postgresql";
|
|
495
|
+
|
|
496
|
+
export interface IPostgresqlProps {
|
|
497
|
+
url: string;
|
|
498
|
+
schema: string;
|
|
499
|
+
table?: string;
|
|
500
|
+
retentionDays?: number | null;
|
|
501
|
+
}
|
|
219
502
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
503
|
+
export class PostgresqlAdapter {
|
|
504
|
+
private constructor(private readonly ds: ILogDatasource) {}
|
|
505
|
+
static async create(
|
|
506
|
+
opts: IPostgresqlProps
|
|
507
|
+
): Promise<PostgresqlAdapter | undefined> {
|
|
508
|
+
try {
|
|
509
|
+
const ps = await createPostgresDatasource({
|
|
510
|
+
connectionString: opts.url,
|
|
511
|
+
schema: opts.schema,
|
|
512
|
+
table: opts.table ?? "logs",
|
|
513
|
+
createIfMissing: true,
|
|
514
|
+
retentionDays: opts.retentionDays ?? 0,
|
|
515
|
+
});
|
|
516
|
+
console.log("[logger] Conectado a PostgreSQL para logs");
|
|
517
|
+
return new PostgresqlAdapter(ps);
|
|
518
|
+
} catch (e: any) {
|
|
519
|
+
console.warn("[logger] Postgres deshabilitado:", e?.message ?? e);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
get datasource(): ILogDatasource {
|
|
523
|
+
return this.ds;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
#### 4) `LoggerBootstrap` (orquestador de adapters + PII)
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
// src/infrastructure/logger/bootstrap.ts
|
|
532
|
+
import {
|
|
533
|
+
createLogger,
|
|
534
|
+
CompositeDatasource,
|
|
535
|
+
type ILogDatasource,
|
|
536
|
+
} from "@jmlq/logger";
|
|
537
|
+
import { buildPiiConfig } from "./settings/pii.settings";
|
|
538
|
+
import { toMinLevel } from "./settings/loglevel.settings";
|
|
539
|
+
// NOTA: Opcionales depende de las necesidades del cliente
|
|
540
|
+
import { FsAdapter, type IFsProps } from "./adapters/fs.adapter";
|
|
541
|
+
import { MongoAdapter, type IMongoProps } from "./adapters/mongo.adapter";
|
|
542
|
+
import {
|
|
543
|
+
PostgresqlAdapter,
|
|
544
|
+
type IPostgresqlProps,
|
|
545
|
+
} from "./adapters/postgresql.adapter";
|
|
546
|
+
|
|
547
|
+
export interface LoggerBootstrapOptions {
|
|
548
|
+
minLevel: string | number;
|
|
549
|
+
pii?: {
|
|
550
|
+
enabled?: boolean;
|
|
551
|
+
whitelistKeys?: string[];
|
|
552
|
+
blacklistKeys?: string[];
|
|
553
|
+
patterns?: any[];
|
|
554
|
+
deep?: boolean;
|
|
555
|
+
includeDefaults?: boolean;
|
|
556
|
+
};
|
|
557
|
+
adapters?: {
|
|
558
|
+
// NOTA: Opcionales depende de las necesidades del cliente
|
|
559
|
+
fs?: IFsProps;
|
|
560
|
+
mongo?: IMongoProps;
|
|
561
|
+
postgres?: IPostgresqlProps;
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export class LoggerBootstrap {
|
|
566
|
+
private constructor(
|
|
567
|
+
private readonly _logger: ReturnType<typeof createLogger>,
|
|
568
|
+
private readonly _ds: ILogDatasource
|
|
569
|
+
) {}
|
|
570
|
+
|
|
571
|
+
static async create(opts: LoggerBootstrapOptions): Promise<LoggerBootstrap> {
|
|
572
|
+
const dsList: ILogDatasource[] = [];
|
|
573
|
+
|
|
574
|
+
// NOTA: Opcionales depende de las necesidades del cliente
|
|
575
|
+
if (opts.adapters?.fs) {
|
|
576
|
+
const fs = FsAdapter.create(opts.adapters.fs);
|
|
577
|
+
if (fs) dsList.push(fs.datasource);
|
|
578
|
+
}
|
|
579
|
+
if (opts.adapters?.mongo) {
|
|
580
|
+
const mg = await MongoAdapter.create(opts.adapters.mongo);
|
|
581
|
+
if (mg) dsList.push(mg.datasource);
|
|
582
|
+
}
|
|
583
|
+
if (opts.adapters?.postgres) {
|
|
584
|
+
const pg = await PostgresqlAdapter.create(opts.adapters.postgres);
|
|
585
|
+
if (pg) dsList.push(pg.datasource);
|
|
586
|
+
}
|
|
587
|
+
//----
|
|
588
|
+
|
|
589
|
+
if (dsList.length === 0)
|
|
590
|
+
throw new Error("[logger] No hay datasources válidos.");
|
|
591
|
+
const datasource =
|
|
592
|
+
dsList.length === 1 ? dsList[0] : new CompositeDatasource(dsList);
|
|
593
|
+
|
|
594
|
+
const pii = buildPiiConfig({
|
|
595
|
+
enabled: opts.pii?.enabled ?? false,
|
|
596
|
+
includeDefaults: opts.pii?.includeDefaults ?? true,
|
|
597
|
+
whitelistKeys: opts.pii?.whitelistKeys,
|
|
598
|
+
blacklistKeys: opts.pii?.blacklistKeys,
|
|
599
|
+
patterns: opts.pii?.patterns,
|
|
600
|
+
deep: opts.pii?.deep ?? true,
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
const logger = createLogger(datasource, {
|
|
604
|
+
minLevel: toMinLevel(opts.minLevel),
|
|
605
|
+
pii,
|
|
606
|
+
});
|
|
607
|
+
return new LoggerBootstrap(logger, datasource);
|
|
227
608
|
}
|
|
228
609
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
610
|
+
get logger() {
|
|
611
|
+
return this._logger;
|
|
612
|
+
}
|
|
613
|
+
async flush() {
|
|
614
|
+
const any = this._logger as any;
|
|
615
|
+
if (typeof any.flush === "function") await any.flush();
|
|
235
616
|
}
|
|
617
|
+
async dispose() {
|
|
618
|
+
const any = this._logger as any;
|
|
619
|
+
if (typeof any.dispose === "function") await any.dispose();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
```
|
|
236
623
|
|
|
237
|
-
|
|
238
|
-
const datasource =
|
|
239
|
-
datasources.length === 1
|
|
240
|
-
? datasources[0]
|
|
241
|
-
: new CompositeDatasource(datasources);
|
|
624
|
+
#### 5) Configuración global del logger (singleton)
|
|
242
625
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
626
|
+
```ts
|
|
627
|
+
// src/config/logger/index.ts
|
|
628
|
+
import { LoggerBootstrap } from "../../infrastructure/logger/bootstrap";
|
|
629
|
+
import { envs } from "../../infrastructure/plugins";
|
|
630
|
+
|
|
631
|
+
declare global {
|
|
632
|
+
var __LOGGER_BOOT__: Promise<LoggerBootstrap> | undefined;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function init() {
|
|
636
|
+
return LoggerBootstrap.create({
|
|
637
|
+
minLevel: envs.logger.LOGGER_LEVEL ?? "debug",
|
|
246
638
|
pii: {
|
|
247
639
|
enabled: envs.logger.LOGGER_PII_ENABLED,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
640
|
+
includeDefaults: envs.logger.LOGGER_PII_INCLUDE_DEFAULTS,
|
|
641
|
+
deep: true,
|
|
642
|
+
},
|
|
643
|
+
adapters: {
|
|
644
|
+
// NOTA: Opcionales depende de las necesidades del cliente
|
|
645
|
+
fs: envs.logger.LOGGER_FS_PATH
|
|
646
|
+
? {
|
|
647
|
+
basePath: envs.logger.LOGGER_FS_PATH,
|
|
648
|
+
fileNamePattern: "app-{yyyy}{MM}{dd}.log",
|
|
649
|
+
rotationPolicy: { by: "day" },
|
|
650
|
+
}
|
|
651
|
+
: undefined,
|
|
652
|
+
mongo: envs.logger.MONGO_URL
|
|
653
|
+
? {
|
|
654
|
+
url: envs.logger.MONGO_URL!,
|
|
655
|
+
dbName: envs.logger.MONGO_DB_NAME!,
|
|
656
|
+
collectionName: envs.logger.MONGO_COLLECTION ?? "logs",
|
|
657
|
+
retentionDays: Number(envs.logger.LOGGER_MONGO_RETENTION_DAYS) || 0,
|
|
658
|
+
}
|
|
659
|
+
: undefined,
|
|
660
|
+
postgres: envs.logger.POSTGRES_URL
|
|
661
|
+
? {
|
|
662
|
+
url: envs.logger.POSTGRES_URL!,
|
|
663
|
+
schema: envs.logger.POSTGRES_SCHEMA ?? "public",
|
|
664
|
+
table: envs.logger.POSTGRES_TABLE ?? "logs",
|
|
665
|
+
retentionDays: Number(envs.logger.LOGGER_PG_RETENTION_DAYS) || 0,
|
|
666
|
+
}
|
|
667
|
+
: undefined,
|
|
668
|
+
// ---
|
|
252
669
|
},
|
|
253
670
|
});
|
|
254
671
|
}
|
|
255
672
|
|
|
256
|
-
//
|
|
257
|
-
|
|
673
|
+
// 1. Es una promesa singleton de LoggerBootstrap
|
|
674
|
+
// 2. usa el operador nullish-coalescing (??) para: Reusar globalThis.__LOGGER_BOOT__ si ya existe. En caso contrario crea y memoriza (= init()) la promesa si no existe aún.
|
|
675
|
+
// 3. Garantiza una sola inicialización global del sistema de logging (adapters, datasources, PII, etc.) aunque el módulo se importe múltiples veces
|
|
676
|
+
export const bootReady: Promise<LoggerBootstrap> =
|
|
677
|
+
globalThis.__LOGGER_BOOT__ ?? (globalThis.__LOGGER_BOOT__ = init());
|
|
258
678
|
|
|
259
|
-
//
|
|
679
|
+
// 1. Es una promesa que resuelve directamente al logger
|
|
680
|
+
// 2. Hace un map de la promesa anterior: bootReady.then(b => b.logger).
|
|
681
|
+
export const loggerReady = bootReady.then((b) => b.logger);
|
|
260
682
|
|
|
261
|
-
//
|
|
683
|
+
// 1. Espera a bootReady y llama boot.flush(), que a su vez pide al logger/datasources que vacíen buffers pendientes (útil antes de apagar el proceso o en tests).
|
|
262
684
|
export async function flushLogs() {
|
|
263
|
-
const
|
|
264
|
-
|
|
685
|
+
const boot = await bootReady;
|
|
686
|
+
await boot.flush();
|
|
265
687
|
}
|
|
266
688
|
|
|
267
|
-
//
|
|
689
|
+
// 1. Espera a bootReady y llama boot.dispose(), que cierra recursos (conexiones a Mongo/Postgres, file handles, etc.)
|
|
268
690
|
export async function disposeLogs() {
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
if (mongoClient) await mongoClient.close();
|
|
691
|
+
const boot = await bootReady;
|
|
692
|
+
await boot.dispose();
|
|
272
693
|
}
|
|
273
694
|
```
|
|
274
695
|
|
|
275
|
-
####
|
|
276
|
-
|
|
277
|
-
En cualquier parte de la app se puede usar el logger así:
|
|
696
|
+
#### 6) Uso en la aplicación (Express)
|
|
278
697
|
|
|
279
698
|
```ts
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
699
|
+
// src/presentation/server.ts
|
|
700
|
+
import express, { Request, Response, NextFunction } from "express";
|
|
701
|
+
import { loggerReady } from "../config/logger";
|
|
702
|
+
|
|
703
|
+
function attachLogger() {
|
|
704
|
+
const boot = loggerReady;
|
|
705
|
+
return async (req: Request, _res: Response, next: NextFunction) => {
|
|
706
|
+
// @ts-expect-error: extensión ad-hoc
|
|
707
|
+
req.logger = await boot;
|
|
708
|
+
// @ts-expect-error: idem
|
|
709
|
+
req.requestId = (req.headers["x-request-id"] as string) ?? randomUUID();
|
|
710
|
+
next();
|
|
711
|
+
};
|
|
712
|
+
}
|
|
286
713
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
714
|
+
export function createServer() {
|
|
715
|
+
const app = express();
|
|
716
|
+
app.use(express.json());
|
|
717
|
+
app.use(attachLogger());
|
|
718
|
+
|
|
719
|
+
app.get("/health", (_req, res) =>
|
|
720
|
+
res
|
|
721
|
+
.status(200)
|
|
722
|
+
.json({ ok: true, service: "ml-dev-test", timestamp: Date.now() })
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
app.get("/debug/log-demo", async (req, res) => {
|
|
726
|
+
// @ts-expect-error: logger agregado por attachLogger
|
|
727
|
+
const logger = req.logger;
|
|
728
|
+
await logger.info(
|
|
729
|
+
"Pago con tarjeta 4111 1111 1111 1111 del email demo@correo.com",
|
|
730
|
+
{
|
|
731
|
+
password: "abc123",
|
|
732
|
+
phone: "0987654321",
|
|
733
|
+
city: "Quito",
|
|
734
|
+
}
|
|
735
|
+
);
|
|
736
|
+
res.json({ ok: true });
|
|
290
737
|
});
|
|
291
|
-
}
|
|
292
738
|
|
|
293
|
-
|
|
739
|
+
// Error handler con logging
|
|
740
|
+
app.use(
|
|
741
|
+
async (err: any, req: Request, res: Response, _next: NextFunction) => {
|
|
742
|
+
const status = err?.statusCode ?? 500;
|
|
743
|
+
// @ts-expect-error
|
|
744
|
+
const logger = req.logger ?? (await loggerReady);
|
|
745
|
+
await logger.error("http_error", {
|
|
746
|
+
message: err?.message,
|
|
747
|
+
stack: err?.stack,
|
|
748
|
+
status,
|
|
749
|
+
});
|
|
750
|
+
res
|
|
751
|
+
.status(status)
|
|
752
|
+
.json({ error: err?.message ?? "Internal Server Error" });
|
|
753
|
+
}
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
return app;
|
|
757
|
+
}
|
|
294
758
|
```
|
|
295
759
|
|
|
296
760
|
### 🔎 Notas importantes
|
|
@@ -342,6 +806,10 @@ test("logger redacta PII en FS", async () => {
|
|
|
342
806
|
|
|
343
807
|
---
|
|
344
808
|
|
|
809
|
+
### [LEER MAS...](./ARQUITECTURA.md)
|
|
810
|
+
|
|
811
|
+
---
|
|
812
|
+
|
|
345
813
|
## 📄 Licencia
|
|
346
814
|
|
|
347
815
|
MIT © Mauricio Lahuasi
|