@hemia/trace-manager 0.0.2 → 0.0.4
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/README.md
CHANGED
|
@@ -1,116 +1,517 @@
|
|
|
1
1
|
# @hemia/trace-manager
|
|
2
2
|
|
|
3
|
-
Sistema de
|
|
3
|
+
Sistema de trazabilidad distribuida basado en **OpenTelemetry** para aplicaciones Node.js. Proporciona decoradores automáticos, logging contextual correlacionado con trazas y compatibilidad con ClickHouse para análisis de observabilidad.
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## 🚀 Características
|
|
8
|
+
|
|
9
|
+
- ✅ **Decorador `@Trace`** - Instrumentación automática de métodos con captura de inputs/outputs
|
|
10
|
+
- ✅ **AsyncLocalStorage** - Propagación automática de contexto sin pasar parámetros manualmente
|
|
11
|
+
- ✅ **Logger Contextual** - Logs automáticamente correlacionados con `traceId` y `spanId`
|
|
12
|
+
- ✅ **Formato OpenTelemetry** - Compatible con estándares de observabilidad
|
|
13
|
+
- ✅ **ClickHouse Ready** - Esquema optimizado para análisis en ClickHouse
|
|
14
|
+
- ✅ **Captura de Errores** - Excepciones registradas como eventos en spans
|
|
15
|
+
- ✅ **Jerarquía de Spans** - Parent-Child spans automáticos por nivel de invocación
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 📦 Instalación
|
|
8
20
|
|
|
9
21
|
```bash
|
|
10
|
-
npm install @hemia/trace-manager
|
|
22
|
+
npm install @hemia/trace-manager @hemia/app-context
|
|
11
23
|
```
|
|
12
24
|
|
|
13
25
|
---
|
|
14
26
|
|
|
15
|
-
## Componentes principales
|
|
27
|
+
## 🎯 Componentes principales
|
|
28
|
+
|
|
29
|
+
### ✅ Decorador `@Trace()`
|
|
30
|
+
|
|
31
|
+
Instrumenta automáticamente métodos creando spans de OpenTelemetry con captura de inputs, outputs y errores.
|
|
32
|
+
|
|
33
|
+
#### **Características:**
|
|
16
34
|
|
|
17
|
-
|
|
35
|
+
- 🔹 Crea span automáticamente con `SpanId` único
|
|
36
|
+
- 🔹 Captura **metadata** de argumentos (tipos, cantidad) - no valores completos
|
|
37
|
+
- 🔹 Captura **metadata** de resultados (tipo, isArray, length) - no valores completos
|
|
38
|
+
- 🔹 Registra excepciones como eventos (`EventsNested`)
|
|
39
|
+
- 🔹 Calcula duración en nanosegundos (`DurationUInt64`)
|
|
40
|
+
- 🔹 Propaga contexto automáticamente a métodos hijos
|
|
41
|
+
- 🔹 **Privacy-first**: No serializa valores sensibles, solo metadata
|
|
18
42
|
|
|
19
|
-
|
|
43
|
+
#### **Uso básico:**
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { Trace } from '@hemia/trace-manager';
|
|
47
|
+
|
|
48
|
+
class UserService {
|
|
49
|
+
@Trace()
|
|
50
|
+
async createUser(userData: any) {
|
|
51
|
+
// El decorador captura automáticamente:
|
|
52
|
+
// - Metadata de input: tipo, cantidad de args (NO valores completos)
|
|
53
|
+
// - Metadata de output: tipo, isArray, length (NO valores completos)
|
|
54
|
+
// - Duración de ejecución en nanosegundos
|
|
55
|
+
// - Excepciones si ocurren (con stacktrace)
|
|
56
|
+
|
|
57
|
+
const user = await this.repository.save(userData);
|
|
58
|
+
return user;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@Trace({ name: 'validate-user-email' })
|
|
62
|
+
private async validateEmail(email: string) {
|
|
63
|
+
// Span hijo automático (hereda ParentSpanId)
|
|
64
|
+
return await this.emailValidator.check(email);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
20
68
|
|
|
21
|
-
####
|
|
69
|
+
#### **Opciones de configuración:**
|
|
22
70
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
71
|
+
```ts
|
|
72
|
+
interface TraceOptions {
|
|
73
|
+
name?: string; // Nombre personalizado del span (default: ClassName.methodName)
|
|
74
|
+
kind?: 'SPAN_KIND_INTERNAL' // Tipo de span: INTERNAL, SERVER, CLIENT, PRODUCER, CONSUMER
|
|
75
|
+
| 'SPAN_KIND_SERVER'
|
|
76
|
+
| 'SPAN_KIND_CLIENT'
|
|
77
|
+
| 'SPAN_KIND_PRODUCER'
|
|
78
|
+
| 'SPAN_KIND_CONSUMER';
|
|
79
|
+
attributes?: Record<string, string>; // Atributos personalizados
|
|
80
|
+
}
|
|
81
|
+
```
|
|
30
82
|
|
|
31
|
-
#### Ejemplo
|
|
83
|
+
#### **Ejemplo avanzado:**
|
|
32
84
|
|
|
33
85
|
```ts
|
|
34
|
-
|
|
35
|
-
|
|
86
|
+
class PaymentService {
|
|
87
|
+
@Trace({
|
|
88
|
+
name: 'process-payment-stripe',
|
|
89
|
+
kind: 'SPAN_KIND_CLIENT',
|
|
90
|
+
attributes: { 'payment.provider': 'stripe' }
|
|
91
|
+
})
|
|
92
|
+
async processPayment(amount: number, currency: string) {
|
|
93
|
+
// Span con atributos personalizados
|
|
94
|
+
return await this.stripeClient.charge(amount, currency);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
36
97
|
```
|
|
37
98
|
|
|
38
99
|
---
|
|
39
100
|
|
|
40
|
-
### ✅
|
|
101
|
+
### ✅ Logger Contextual
|
|
41
102
|
|
|
42
|
-
|
|
103
|
+
Logger que automáticamente correlaciona logs con el `traceId` y `spanId` activo, permitiendo rastrear logs específicos dentro de una traza distribuida.
|
|
43
104
|
|
|
44
|
-
####
|
|
105
|
+
#### **Características:**
|
|
45
106
|
|
|
46
|
-
|
|
47
|
-
|
|
107
|
+
- 🔹 Correlación automática con `TraceId` y `SpanId`
|
|
108
|
+
- 🔹 Niveles de severidad: `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`
|
|
109
|
+
- 🔹 Atributos personalizados en cada log
|
|
110
|
+
- 🔹 Compatible con formato OpenTelemetry para ClickHouse
|
|
48
111
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
112
|
+
#### **Uso:**
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { logger } from '@hemia/trace-manager';
|
|
116
|
+
|
|
117
|
+
class OrderService {
|
|
118
|
+
@Trace()
|
|
119
|
+
async placeOrder(order: Order) {
|
|
120
|
+
logger.info('Processing order', { orderId: order.id, amount: order.total });
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const result = await this.payment.charge(order.total);
|
|
124
|
+
logger.info('Payment successful', { transactionId: result.id });
|
|
125
|
+
return result;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
logger.error('Payment failed', { error: error.message });
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
54
130
|
}
|
|
55
131
|
}
|
|
56
132
|
```
|
|
57
133
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
134
|
+
#### **Métodos disponibles:**
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
logger.debug(message: string, attributes?: Record<string, any>): void
|
|
138
|
+
logger.info(message: string, attributes?: Record<string, any>): void
|
|
139
|
+
logger.warn(message: string, attributes?: Record<string, any>): void
|
|
140
|
+
logger.error(message: string, attributes?: Record<string, any>): void
|
|
141
|
+
logger.fatal(message: string, attributes?: Record<string, any>): void
|
|
142
|
+
```
|
|
61
143
|
|
|
62
144
|
---
|
|
63
145
|
|
|
64
|
-
|
|
146
|
+
## 🏗️ Estructura de Datos
|
|
147
|
+
|
|
148
|
+
### **TraceSpan** (Span de OpenTelemetry)
|
|
65
149
|
|
|
66
|
-
|
|
150
|
+
Mapea directamente con la tabla `otel_traces` de ClickHouse:
|
|
67
151
|
|
|
68
152
|
```ts
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
|
|
153
|
+
interface TraceSpan {
|
|
154
|
+
Timestamp: string; // ISO 8601 - DateTime64(9)
|
|
155
|
+
TraceId: string; // ID único de la traza
|
|
156
|
+
SpanId: string; // ID único del span
|
|
157
|
+
ParentSpanId: string; // ID del span padre (jerarquía)
|
|
158
|
+
TraceState: string; // Estado de propagación W3C
|
|
159
|
+
ServiceName: string; // Nombre del servicio
|
|
160
|
+
SpanName: string; // Ej: "UserService.createUser"
|
|
161
|
+
SpanKind: 'SPAN_KIND_INTERNAL' | 'SPAN_KIND_SERVER' | 'SPAN_KIND_CLIENT' | ...;
|
|
162
|
+
DurationUInt64: bigint; // Duración en nanosegundos
|
|
163
|
+
StatusCode: 'STATUS_CODE_OK' | 'STATUS_CODE_ERROR' | 'STATUS_CODE_UNSET';
|
|
164
|
+
StatusMessage: string;
|
|
165
|
+
SpanAttributes: Record<string, string>; // app.method.args, app.method.result, etc.
|
|
166
|
+
ResourceAttributes: Record<string, string>; // host.name, service.name, etc.
|
|
167
|
+
EventsNested: SpanEvent[]; // Excepciones, logs puntuales
|
|
168
|
+
LinksNested: SpanLink[]; // Enlaces a otras trazas
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### **TraceLog** (Log de OpenTelemetry)
|
|
173
|
+
|
|
174
|
+
Mapea directamente con la tabla `otel_logs` de ClickHouse:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
interface TraceLog {
|
|
178
|
+
Timestamp: string; // ISO 8601
|
|
179
|
+
TraceId: string; // Correlación automática con span activo
|
|
180
|
+
SpanId: string; // Span donde ocurrió el log
|
|
181
|
+
TraceFlags: number; // 1 = sampled
|
|
182
|
+
SeverityText: 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL';
|
|
183
|
+
SeverityNumber: number; // 5, 9, 13, 17, 21
|
|
184
|
+
ServiceName: string;
|
|
185
|
+
Body: string; // Mensaje del log
|
|
186
|
+
LogAttributes: Record<string, string>; // Atributos custom
|
|
187
|
+
ResourceAttributes: Record<string, string>; // Metadata del servicio
|
|
188
|
+
}
|
|
72
189
|
```
|
|
73
190
|
|
|
74
191
|
---
|
|
75
192
|
|
|
76
|
-
##
|
|
193
|
+
## 🔗 Integración con `@hemia/app-context`
|
|
194
|
+
|
|
195
|
+
Este paquete utiliza `AsyncLocalStorage` a través de `@hemia/app-context` para mantener el contexto de trazabilidad sin necesidad de pasarlo explícitamente como parámetro.
|
|
196
|
+
|
|
197
|
+
### **Requisitos:**
|
|
198
|
+
|
|
199
|
+
El contexto debe ser inicializado previamente usando `@hemia/app-context` (generalmente en un middleware HTTP):
|
|
77
200
|
|
|
78
201
|
```ts
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
202
|
+
import { asyncContext } from '@hemia/app-context';
|
|
203
|
+
|
|
204
|
+
// En tu middleware o inicialización
|
|
205
|
+
const context = {
|
|
206
|
+
traceId: generateTraceId(),
|
|
207
|
+
spanId: generateSpanId(),
|
|
208
|
+
serviceName: 'user-service',
|
|
209
|
+
traceContext: {
|
|
210
|
+
resourceAttributes: {
|
|
211
|
+
'service.name': 'user-service',
|
|
212
|
+
'host.name': os.hostname(),
|
|
213
|
+
},
|
|
214
|
+
spans: [],
|
|
215
|
+
logs: []
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
await asyncContext.run(context, async () => {
|
|
220
|
+
// Todo el código dentro hereda este contexto automáticamente
|
|
221
|
+
await handleRequest(req, res);
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 📊 Esquema ClickHouse
|
|
228
|
+
|
|
229
|
+
### Tabla: `otel_traces`
|
|
230
|
+
|
|
231
|
+
```sql
|
|
232
|
+
CREATE TABLE otel_traces (
|
|
233
|
+
Timestamp DateTime64(9) CODEC(Delta, ZSTD(1)),
|
|
234
|
+
TraceId String CODEC(ZSTD(1)),
|
|
235
|
+
SpanId String CODEC(ZSTD(1)),
|
|
236
|
+
ParentSpanId String CODEC(ZSTD(1)),
|
|
237
|
+
ServiceName LowCardinality(String) CODEC(ZSTD(1)),
|
|
238
|
+
SpanName LowCardinality(String) CODEC(ZSTD(1)),
|
|
239
|
+
SpanKind LowCardinality(String) CODEC(ZSTD(1)),
|
|
240
|
+
DurationUInt64 UInt64 CODEC(ZSTD(1)),
|
|
241
|
+
StatusCode LowCardinality(String) CODEC(ZSTD(1)),
|
|
242
|
+
SpanAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
|
|
243
|
+
ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
|
|
244
|
+
EventsNested Nested (
|
|
245
|
+
Timestamp DateTime64(9),
|
|
246
|
+
Name LowCardinality(String),
|
|
247
|
+
Attributes Map(LowCardinality(String), String)
|
|
248
|
+
) CODEC(ZSTD(1))
|
|
249
|
+
) ENGINE = MergeTree()
|
|
250
|
+
PARTITION BY toDate(Timestamp)
|
|
251
|
+
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Tabla: `otel_logs`
|
|
255
|
+
|
|
256
|
+
```sql
|
|
257
|
+
CREATE TABLE otel_logs (
|
|
258
|
+
Timestamp DateTime64(9) CODEC(Delta, ZSTD(1)),
|
|
259
|
+
TraceId String CODEC(ZSTD(1)),
|
|
260
|
+
SpanId String CODEC(ZSTD(1)),
|
|
261
|
+
SeverityText LowCardinality(String) CODEC(ZSTD(1)),
|
|
262
|
+
SeverityNumber Int32 CODEC(ZSTD(1)),
|
|
263
|
+
ServiceName LowCardinality(String) CODEC(ZSTD(1)),
|
|
264
|
+
Body String CODEC(ZSTD(1)),
|
|
265
|
+
LogAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
|
|
266
|
+
ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1))
|
|
267
|
+
) ENGINE = MergeTree()
|
|
268
|
+
PARTITION BY toDate(Timestamp)
|
|
269
|
+
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId);
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## 🔍 Ejemplo completo
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import { Trace, logger } from '@hemia/trace-manager';
|
|
278
|
+
import { asyncContext } from '@hemia/app-context';
|
|
279
|
+
|
|
280
|
+
class OrderController {
|
|
281
|
+
@Trace({ name: 'http-create-order', kind: 'SPAN_KIND_SERVER' })
|
|
282
|
+
async createOrder(req: Request) {
|
|
283
|
+
logger.info('Order request received', { userId: req.userId });
|
|
284
|
+
|
|
285
|
+
const order = await this.orderService.create(req.body);
|
|
286
|
+
|
|
287
|
+
logger.info('Order created successfully', { orderId: order.id });
|
|
288
|
+
return order;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
class OrderService {
|
|
293
|
+
@Trace()
|
|
294
|
+
async create(orderData: any) {
|
|
295
|
+
// Span hijo automático (ParentSpanId = span del controller)
|
|
296
|
+
logger.debug('Validating order data');
|
|
297
|
+
|
|
298
|
+
await this.validate(orderData);
|
|
299
|
+
const order = await this.repository.save(orderData);
|
|
300
|
+
|
|
301
|
+
logger.info('Order persisted', { orderId: order.id });
|
|
302
|
+
return order;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@Trace({ name: 'validate-order-rules' })
|
|
306
|
+
private async validate(data: any) {
|
|
307
|
+
// Span nieto (hijo del método create)
|
|
308
|
+
if (!data.items?.length) {
|
|
309
|
+
logger.error('Validation failed: no items');
|
|
310
|
+
throw new Error('Order must have items');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
97
313
|
}
|
|
98
314
|
```
|
|
99
315
|
|
|
316
|
+
**Resultado en ClickHouse:**
|
|
317
|
+
|
|
318
|
+
3 spans jerárquicos:
|
|
319
|
+
1. `http-create-order` (root, SPAN_KIND_SERVER)
|
|
320
|
+
2. `OrderService.create` (child, SPAN_KIND_INTERNAL)
|
|
321
|
+
3. `validate-order-rules` (grandchild, SPAN_KIND_INTERNAL)
|
|
322
|
+
|
|
323
|
+
Y múltiples logs correlacionados con el mismo `TraceId`.
|
|
324
|
+
|
|
100
325
|
---
|
|
101
326
|
|
|
102
|
-
##
|
|
327
|
+
## 📝 Buenas Prácticas
|
|
103
328
|
|
|
104
|
-
|
|
329
|
+
1. **Usar `@Trace()` en capas de negocio críticas**: Controllers, Services, Repositories
|
|
330
|
+
2. **Logger en puntos de decisión**: Validaciones, llamadas externas, errores
|
|
331
|
+
3. **Atributos significativos**: Agregar IDs de entidades, estados, providers
|
|
332
|
+
4. **Nombres descriptivos**: Usar `name` en `@Trace()` para operaciones complejas
|
|
333
|
+
5. **Metadata approach**: El decorador captura metadata (tipos, cantidad) en lugar de valores completos
|
|
334
|
+
6. **Privacy by default**: No se serializan valores sensibles como passwords o tokens
|
|
105
335
|
|
|
106
336
|
---
|
|
107
337
|
|
|
108
|
-
##
|
|
338
|
+
## 🛠️ Utilidades
|
|
339
|
+
|
|
340
|
+
### Helpers para SpanAttributes (Metadata Approach)
|
|
341
|
+
|
|
342
|
+
El paquete incluye utilidades para agregar metadata en lugar de serializar datos completos, siguiendo las mejores prácticas de observabilidad:
|
|
343
|
+
|
|
344
|
+
#### **`addArgsMetadata(attributes, args)`**
|
|
345
|
+
Agrega metadata de argumentos de función:
|
|
346
|
+
```ts
|
|
347
|
+
import { addArgsMetadata } from '@hemia/trace-manager';
|
|
348
|
+
|
|
349
|
+
const args = ['user@test.com', 123, { name: 'John' }];
|
|
350
|
+
addArgsMetadata(span.SpanAttributes, args);
|
|
351
|
+
|
|
352
|
+
// Resultado:
|
|
353
|
+
// {
|
|
354
|
+
// "app.method.args.count": "3",
|
|
355
|
+
// "app.method.args.types": "string,number,object"
|
|
356
|
+
// }
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
#### **`addResultMetadata(attributes, result)`**
|
|
360
|
+
Agrega metadata de resultado de función:
|
|
361
|
+
```ts
|
|
362
|
+
import { addResultMetadata } from '@hemia/trace-manager';
|
|
363
|
+
|
|
364
|
+
const result = [{ id: 1 }, { id: 2 }];
|
|
365
|
+
addResultMetadata(span.SpanAttributes, result);
|
|
366
|
+
|
|
367
|
+
// Resultado:
|
|
368
|
+
// {
|
|
369
|
+
// "app.method.result.type": "object",
|
|
370
|
+
// "app.method.result.isArray": "true",
|
|
371
|
+
// "app.method.result.length": "2"
|
|
372
|
+
// }
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
#### **`addRequestBodyMetadata(attributes, body, prefix?)`**
|
|
376
|
+
Agrega metadata de Request body (para middlewares HTTP):
|
|
377
|
+
```ts
|
|
378
|
+
import { addRequestBodyMetadata } from '@hemia/trace-manager';
|
|
379
|
+
|
|
380
|
+
const reqBody = { email: 'user@test.com', password: '***' };
|
|
381
|
+
addRequestBodyMetadata(span.SpanAttributes, reqBody);
|
|
382
|
+
|
|
383
|
+
// Resultado:
|
|
384
|
+
// {
|
|
385
|
+
// "http.request.body.exists": "true",
|
|
386
|
+
// "http.request.body.type": "object",
|
|
387
|
+
// "http.request.body.keys": "email,password",
|
|
388
|
+
// "http.request.body.keyCount": "2"
|
|
389
|
+
// }
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
#### **`addResponseBodyMetadata(attributes, body, isError, prefix?)`**
|
|
393
|
+
Agrega metadata de Response body con captura inteligente de errores:
|
|
394
|
+
```ts
|
|
395
|
+
import { addResponseBodyMetadata } from '@hemia/trace-manager';
|
|
396
|
+
|
|
397
|
+
const errorBody = {
|
|
398
|
+
code: 'AUTH_ERROR',
|
|
399
|
+
message: 'Invalid credentials'
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
addResponseBodyMetadata(
|
|
403
|
+
span.SpanAttributes,
|
|
404
|
+
errorBody,
|
|
405
|
+
true // isError = true activa captura de mensaje
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Resultado:
|
|
409
|
+
// {
|
|
410
|
+
// "http.response.body.exists": "true",
|
|
411
|
+
// "http.response.body.type": "object",
|
|
412
|
+
// "http.response.body.keys": "code,message",
|
|
413
|
+
// "http.response.body.keyCount": "2",
|
|
414
|
+
// "http.response.error.code": "AUTH_ERROR",
|
|
415
|
+
// "http.response.error.message": "Invalid credentials"
|
|
416
|
+
// }
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
#### **`addObjectMetadata(attributes, data, prefix)`**
|
|
420
|
+
Agrega metadata genérica de cualquier objeto:
|
|
421
|
+
```ts
|
|
422
|
+
import { addObjectMetadata } from '@hemia/trace-manager';
|
|
423
|
+
|
|
424
|
+
const payload = { items: [1, 2, 3], total: 100 };
|
|
425
|
+
addObjectMetadata(span.SpanAttributes, payload, 'order.payload');
|
|
426
|
+
|
|
427
|
+
// Resultado:
|
|
428
|
+
// {
|
|
429
|
+
// "order.payload.exists": "true",
|
|
430
|
+
// "order.payload.type": "object",
|
|
431
|
+
// "order.payload.keys": "items,total",
|
|
432
|
+
// "order.payload.keyCount": "2"
|
|
433
|
+
// }
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Uso en Middleware Custom
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import {
|
|
440
|
+
addRequestBodyMetadata,
|
|
441
|
+
addResponseBodyMetadata
|
|
442
|
+
} from '@hemia/trace-manager';
|
|
443
|
+
|
|
444
|
+
export const customMiddleware = () => {
|
|
445
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
446
|
+
// ... inicializar span ...
|
|
447
|
+
|
|
448
|
+
res.on('finish', () => {
|
|
449
|
+
const isError = res.statusCode >= 400;
|
|
450
|
+
|
|
451
|
+
if (isError) {
|
|
452
|
+
// Capturar metadata en lugar de cuerpos completos
|
|
453
|
+
addRequestBodyMetadata(
|
|
454
|
+
span.SpanAttributes,
|
|
455
|
+
req.body
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
addResponseBodyMetadata(
|
|
459
|
+
span.SpanAttributes,
|
|
460
|
+
res.locals.responseBody,
|
|
461
|
+
isError // Captura mensaje de error si existe
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
next();
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### Ventajas del Metadata Approach
|
|
472
|
+
|
|
473
|
+
✅ **Menor storage**: 10-20 bytes vs potencialmente KB de JSON
|
|
474
|
+
✅ **Privacy by default**: No serializa valores sensibles (passwords, tokens)
|
|
475
|
+
✅ **Mejor cardinality**: Más eficiente para queries en ClickHouse
|
|
476
|
+
✅ **Suficiente para debugging**: Keys y tipos son suficientes para diagnosticar
|
|
477
|
+
✅ **Consistencia**: Mismo approach en decoradores y middlewares
|
|
478
|
+
|
|
479
|
+
### `sanitizeArgs()` y `toSafeJSON()`
|
|
480
|
+
|
|
481
|
+
Utilidades legacy que previenen:
|
|
482
|
+
- Serialización de objetos circulares
|
|
483
|
+
- Filtrado de Request/Response de Express
|
|
484
|
+
- Overflow de tamaño en ClickHouse
|
|
485
|
+
|
|
486
|
+
**⚠️ Nota**: El decorador `@Trace` ahora usa metadata en lugar de estas utilidades, pero siguen disponibles para compatibilidad.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## 📚 Dependencias
|
|
491
|
+
|
|
492
|
+
- `@hemia/app-context` (^0.0.6) - Manejo de contexto con AsyncLocalStorage
|
|
493
|
+
- `uuid` (^10.0.0) - Generación de IDs únicos para spans
|
|
494
|
+
|
|
495
|
+
## 🔄 Versión Actual
|
|
496
|
+
|
|
497
|
+
**v0.0.3** - Metadata approach implementado (Noviembre 2025)
|
|
498
|
+
|
|
499
|
+
---
|
|
500
|
+
|
|
501
|
+
## 📄 Licencia
|
|
109
502
|
|
|
110
503
|
MIT
|
|
111
504
|
|
|
112
505
|
---
|
|
113
506
|
|
|
114
|
-
## Autor
|
|
507
|
+
## 👨💻 Autor
|
|
508
|
+
|
|
509
|
+
**Hemia Technologies**
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
## 🔗 Recursos
|
|
115
514
|
|
|
116
|
-
|
|
515
|
+
- [OpenTelemetry Specification](https://opentelemetry.io/docs/specs/otel/)
|
|
516
|
+
- [ClickHouse Documentation](https://clickhouse.com/docs)
|
|
517
|
+
- [AsyncLocalStorage Node.js](https://nodejs.org/api/async_context.html)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{asyncContext as t}from"@hemia/app-context";var e;!function(t){t.TRACE="x-trace-id",t.CORRELATION="x-correlation-id"}(e||(e={}));const
|
|
1
|
+
import{asyncContext as t}from"@hemia/app-context";var e;!function(t){t.TRACE="x-trace-id",t.CORRELATION="x-correlation-id"}(e||(e={}));const r={DEBUG:5,INFO:9,WARN:13,ERROR:17,FATAL:21};const n=new class{constructor(t){this.defaultServiceName=t}log(e,n,o){const s=t.getStore();if(!s)return console.warn("[Logger] No active context. Log not traced."),void console.log(`[${e}] ${n}`,o);const i={Timestamp:(new Date).toISOString(),TraceId:s.traceId,SpanId:s.spanId,TraceFlags:1,SeverityText:e,SeverityNumber:r[e],ServiceName:s.serviceName,Body:n,LogAttributes:this.stringifyAttributes(o||{}),ResourceAttributes:s.traceContext.resourceAttributes};s.traceContext.logs.push(i),"production"!==process.env.NODE_ENV&&console.log(`[${e}] [TraceId:${s.traceId.substring(0,8)}...] ${n}`,o||"")}stringifyAttributes(t){const e={};for(const[r,n]of Object.entries(t))if(null==n)e[r]="";else if("string"==typeof n)e[r]=n;else try{e[r]=JSON.stringify(n)}catch{e[r]=String(n)}return e}debug(t,e){this.log("DEBUG",t,e)}info(t,e){this.log("INFO",t,e)}warn(t,e){this.log("WARN",t,e)}error(t,e){this.log("ERROR",t,e)}fatal(t,e){this.log("FATAL",t,e)}};for(var o,s=[],i=0;i<256;++i)s.push((i+256).toString(16).slice(1));var a=new Uint8Array(16);function c(){if(!o&&!(o="undefined"!=typeof crypto&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto)))throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");return o(a)}var p={randomUUID:"undefined"!=typeof crypto&&crypto.randomUUID&&crypto.randomUUID.bind(crypto)};function u(t,e,r){if(p.randomUUID&&!t)return p.randomUUID();var n=(t=t||{}).random||(t.rng||c)();return n[6]=15&n[6]|64,n[8]=63&n[8]|128,function(t,e=0){return(s[t[e+0]]+s[t[e+1]]+s[t[e+2]]+s[t[e+3]]+"-"+s[t[e+4]]+s[t[e+5]]+"-"+s[t[e+6]]+s[t[e+7]]+"-"+s[t[e+8]]+s[t[e+9]]+"-"+s[t[e+10]]+s[t[e+11]]+s[t[e+12]]+s[t[e+13]]+s[t[e+14]]+s[t[e+15]]).toLowerCase()}(n)}function g(t,e){t["app.method.args.count"]=e.length.toString(),t["app.method.args.types"]=e.map(t=>typeof t).join(",")}function y(t,e){t["app.method.result.type"]=typeof e,t["app.method.result.isArray"]=Array.isArray(e).toString(),t["app.method.result.length"]=Array.isArray(e)?e.length.toString():"1"}function d(t,e,r="http.request.body"){if(t[`${r}.exists`]=(!!e).toString(),t[`${r}.type`]=typeof e,e&&"object"==typeof e){const n=Object.keys(e);t[`${r}.keys`]=n.join(","),t[`${r}.keyCount`]=n.length.toString()}Array.isArray(e)&&(t[`${r}.isArray`]="true",t[`${r}.length`]=e.length.toString())}function l(t,e,r=!1,n="http.response.body"){if(t[`${n}.exists`]=(!!e).toString(),t[`${n}.type`]=typeof e,e&&"object"==typeof e){const r=Object.keys(e);t[`${n}.keys`]=r.join(","),t[`${n}.keyCount`]=r.length.toString()}Array.isArray(e)&&(t[`${n}.isArray`]="true",t[`${n}.length`]=e.length.toString()),r&&e&&(e.message&&(t["http.response.error.message"]=String(e.message).substring(0,500)),e.code&&(t["http.response.error.code"]=String(e.code)),e.error&&(t["http.response.error.type"]=String(e.error).substring(0,200)))}function f(t,e,r){if(e){if(t[`${r}.exists`]="true",t[`${r}.type`]=typeof e,"object"==typeof e&&!Array.isArray(e)){const n=Object.keys(e);t[`${r}.keys`]=n.join(","),t[`${r}.keyCount`]=n.length.toString()}Array.isArray(e)&&(t[`${r}.isArray`]="true",t[`${r}.length`]=e.length.toString(),e.length>0&&(t[`${r}.firstItemType`]=typeof e[0])),"string"==typeof e?t[`${r}.value`]=e.substring(0,200):"number"!=typeof e&&"boolean"!=typeof e||(t[`${r}.value`]=String(e))}else t[`${r}.exists`]="false"}function S(e){return function(r,n,o){const s=o?.value;if("function"!=typeof s)throw new Error("@Trace can only be applied to methods");o.value=async function(...o){const i=t.getStore();if(!i)return console.warn("[Trace] No active context. Skipping trace."),await s.apply(this,o);const a=u(),c=this?.constructor?.name||r.name||"UnknownClass",p=e?.name||`${c}.${n}`,d=process.hrtime.bigint(),l=(new Date).toISOString(),f=i.spanId,S={Timestamp:l,TraceId:i.traceId,SpanId:a,ParentSpanId:f,TraceState:"",ServiceName:i.serviceName,SpanName:p,SpanKind:e?.kind||"SPAN_KIND_INTERNAL",DurationUInt64:BigInt(0),StatusCode:"STATUS_CODE_UNSET",StatusMessage:"",SpanAttributes:{"code.function":n,"code.namespace":c,...e?.attributes},ResourceAttributes:i.traceContext.resourceAttributes,EventsNested:[],LinksNested:[]},m=i.spanId;i.spanId=a;try{g(S.SpanAttributes,o);const t=await s.apply(this,o);return y(S.SpanAttributes,t),S.StatusCode="STATUS_CODE_OK",t}catch(t){const e={Timestamp:(new Date).toISOString(),Name:"exception",Attributes:{"exception.type":t.constructor?.name||"Error","exception.message":t.message||"Unknown error","exception.stacktrace":String(t.stack||"").substring(0,5e3)}};throw S.EventsNested.push(e),S.StatusCode="STATUS_CODE_ERROR",S.StatusMessage=t.message||"Unhandled exception",t}finally{const t=process.hrtime.bigint();S.DurationUInt64=t-d,i.traceContext.spans.push(S),i.spanId=m}}}}export{S as Trace,e as TraceHeader,g as addArgsMetadata,f as addObjectMetadata,d as addRequestBodyMetadata,l as addResponseBodyMetadata,y as addResultMetadata,n as logger};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"use strict";var t,e=require("@hemia/app-context");exports.TraceHeader=void 0,(t=exports.TraceHeader||(exports.TraceHeader={})).TRACE="x-trace-id",t.CORRELATION="x-correlation-id";const r={DEBUG:5,INFO:9,WARN:13,ERROR:17,FATAL:21};const n=new class{constructor(t){this.defaultServiceName=t}log(t,n,o){const s=e.asyncContext.getStore();if(!s)return console.warn("[Logger] No active context. Log not traced."),void console.log(`[${t}] ${n}`,o);const a={Timestamp:(new Date).toISOString(),TraceId:s.traceId,SpanId:s.spanId,TraceFlags:1,SeverityText:t,SeverityNumber:r[t],ServiceName:s.serviceName,Body:n,LogAttributes:this.stringifyAttributes(o||{}),ResourceAttributes:s.traceContext.resourceAttributes};s.traceContext.logs.push(a),"production"!==process.env.NODE_ENV&&console.log(`[${t}] [TraceId:${s.traceId.substring(0,8)}...] ${n}`,o||"")}stringifyAttributes(t){const e={};for(const[r,n]of Object.entries(t))if(null==n)e[r]="";else if("string"==typeof n)e[r]=n;else try{e[r]=JSON.stringify(n)}catch{e[r]=String(n)}return e}debug(t,e){this.log("DEBUG",t,e)}info(t,e){this.log("INFO",t,e)}warn(t,e){this.log("WARN",t,e)}error(t,e){this.log("ERROR",t,e)}fatal(t,e){this.log("FATAL",t,e)}};
|
|
1
|
+
"use strict";var t,e=require("@hemia/app-context");exports.TraceHeader=void 0,(t=exports.TraceHeader||(exports.TraceHeader={})).TRACE="x-trace-id",t.CORRELATION="x-correlation-id";const r={DEBUG:5,INFO:9,WARN:13,ERROR:17,FATAL:21};const n=new class{constructor(t){this.defaultServiceName=t}log(t,n,o){const s=e.asyncContext.getStore();if(!s)return console.warn("[Logger] No active context. Log not traced."),void console.log(`[${t}] ${n}`,o);const a={Timestamp:(new Date).toISOString(),TraceId:s.traceId,SpanId:s.spanId,TraceFlags:1,SeverityText:t,SeverityNumber:r[t],ServiceName:s.serviceName,Body:n,LogAttributes:this.stringifyAttributes(o||{}),ResourceAttributes:s.traceContext.resourceAttributes};s.traceContext.logs.push(a),"production"!==process.env.NODE_ENV&&console.log(`[${t}] [TraceId:${s.traceId.substring(0,8)}...] ${n}`,o||"")}stringifyAttributes(t){const e={};for(const[r,n]of Object.entries(t))if(null==n)e[r]="";else if("string"==typeof n)e[r]=n;else try{e[r]=JSON.stringify(n)}catch{e[r]=String(n)}return e}debug(t,e){this.log("DEBUG",t,e)}info(t,e){this.log("INFO",t,e)}warn(t,e){this.log("WARN",t,e)}error(t,e){this.log("ERROR",t,e)}fatal(t,e){this.log("FATAL",t,e)}};for(var o,s=[],a=0;a<256;++a)s.push((a+256).toString(16).slice(1));var i=new Uint8Array(16);function c(){if(!o&&!(o="undefined"!=typeof crypto&&crypto.getRandomValues&&crypto.getRandomValues.bind(crypto)))throw new Error("crypto.getRandomValues() not supported. See https://github.com/uuidjs/uuid#getrandomvalues-not-supported");return o(i)}var p={randomUUID:"undefined"!=typeof crypto&&crypto.randomUUID&&crypto.randomUUID.bind(crypto)};function u(t,e,r){if(p.randomUUID&&!t)return p.randomUUID();var n=(t=t||{}).random||(t.rng||c)();return n[6]=15&n[6]|64,n[8]=63&n[8]|128,function(t,e=0){return(s[t[e+0]]+s[t[e+1]]+s[t[e+2]]+s[t[e+3]]+"-"+s[t[e+4]]+s[t[e+5]]+"-"+s[t[e+6]]+s[t[e+7]]+"-"+s[t[e+8]]+s[t[e+9]]+"-"+s[t[e+10]]+s[t[e+11]]+s[t[e+12]]+s[t[e+13]]+s[t[e+14]]+s[t[e+15]]).toLowerCase()}(n)}function d(t,e){t["app.method.args.count"]=e.length.toString(),t["app.method.args.types"]=e.map(t=>typeof t).join(",")}function g(t,e){t["app.method.result.type"]=typeof e,t["app.method.result.isArray"]=Array.isArray(e).toString(),t["app.method.result.length"]=Array.isArray(e)?e.length.toString():"1"}exports.Trace=function(t){return function(r,n,o){const s=o?.value;if("function"!=typeof s)throw new Error("@Trace can only be applied to methods");o.value=async function(...o){const a=e.asyncContext.getStore();if(!a)return console.warn("[Trace] No active context. Skipping trace."),await s.apply(this,o);const i=u(),c=this?.constructor?.name||r.name||"UnknownClass",p=t?.name||`${c}.${n}`,y=process.hrtime.bigint(),l=(new Date).toISOString(),S=a.spanId,f={Timestamp:l,TraceId:a.traceId,SpanId:i,ParentSpanId:S,TraceState:"",ServiceName:a.serviceName,SpanName:p,SpanKind:t?.kind||"SPAN_KIND_INTERNAL",DurationUInt64:BigInt(0),StatusCode:"STATUS_CODE_UNSET",StatusMessage:"",SpanAttributes:{"code.function":n,"code.namespace":c,...t?.attributes},ResourceAttributes:a.traceContext.resourceAttributes,EventsNested:[],LinksNested:[]},h=a.spanId;a.spanId=i;try{d(f.SpanAttributes,o);const t=await s.apply(this,o);return g(f.SpanAttributes,t),f.StatusCode="STATUS_CODE_OK",t}catch(t){const e={Timestamp:(new Date).toISOString(),Name:"exception",Attributes:{"exception.type":t.constructor?.name||"Error","exception.message":t.message||"Unknown error","exception.stacktrace":String(t.stack||"").substring(0,5e3)}};throw f.EventsNested.push(e),f.StatusCode="STATUS_CODE_ERROR",f.StatusMessage=t.message||"Unhandled exception",t}finally{const t=process.hrtime.bigint();f.DurationUInt64=t-y,a.traceContext.spans.push(f),a.spanId=h}}}},exports.addArgsMetadata=d,exports.addObjectMetadata=function(t,e,r){if(e){if(t[`${r}.exists`]="true",t[`${r}.type`]=typeof e,"object"==typeof e&&!Array.isArray(e)){const n=Object.keys(e);t[`${r}.keys`]=n.join(","),t[`${r}.keyCount`]=n.length.toString()}Array.isArray(e)&&(t[`${r}.isArray`]="true",t[`${r}.length`]=e.length.toString(),e.length>0&&(t[`${r}.firstItemType`]=typeof e[0])),"string"==typeof e?t[`${r}.value`]=e.substring(0,200):"number"!=typeof e&&"boolean"!=typeof e||(t[`${r}.value`]=String(e))}else t[`${r}.exists`]="false"},exports.addRequestBodyMetadata=function(t,e,r="http.request.body"){if(t[`${r}.exists`]=(!!e).toString(),t[`${r}.type`]=typeof e,e&&"object"==typeof e){const n=Object.keys(e);t[`${r}.keys`]=n.join(","),t[`${r}.keyCount`]=n.length.toString()}Array.isArray(e)&&(t[`${r}.isArray`]="true",t[`${r}.length`]=e.length.toString())},exports.addResponseBodyMetadata=function(t,e,r=!1,n="http.response.body"){if(t[`${n}.exists`]=(!!e).toString(),t[`${n}.type`]=typeof e,e&&"object"==typeof e){const r=Object.keys(e);t[`${n}.keys`]=r.join(","),t[`${n}.keyCount`]=r.length.toString()}Array.isArray(e)&&(t[`${n}.isArray`]="true",t[`${n}.length`]=e.length.toString()),r&&e&&(e.message&&(t["http.response.error.message"]=String(e.message).substring(0,500)),e.code&&(t["http.response.error.code"]=String(e.code)),e.error&&(t["http.response.error.type"]=String(e.error).substring(0,200)))},exports.addResultMetadata=g,exports.logger=n;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -3,3 +3,4 @@ export { TraceContext } from "./types/traceContext";
|
|
|
3
3
|
export { TraceOptions } from "./types/traceOptions";
|
|
4
4
|
export { logger } from "./log/Logger";
|
|
5
5
|
export { Trace } from "./decorator/Traceable";
|
|
6
|
+
export { addArgsMetadata, addResultMetadata, addRequestBodyMetadata, addResponseBodyMetadata, addObjectMetadata } from "./utils/SpanAttributesHelper";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilidades para agregar metadata de objetos a SpanAttributes
|
|
3
|
+
* siguiendo el principio de metadata sobre serialización completa
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Agrega metadata de argumentos de entrada a los SpanAttributes
|
|
7
|
+
* @param attributes - Objeto de atributos donde agregar metadata
|
|
8
|
+
* @param args - Array de argumentos a analizar
|
|
9
|
+
*/
|
|
10
|
+
export declare function addArgsMetadata(attributes: Record<string, string>, args: any[]): void;
|
|
11
|
+
/**
|
|
12
|
+
* Agrega metadata de resultado/output a los SpanAttributes
|
|
13
|
+
* @param attributes - Objeto de atributos donde agregar metadata
|
|
14
|
+
* @param result - Resultado a analizar
|
|
15
|
+
*/
|
|
16
|
+
export declare function addResultMetadata(attributes: Record<string, string>, result: any): void;
|
|
17
|
+
/**
|
|
18
|
+
* Agrega metadata de Request body a SpanAttributes (para middlewares HTTP)
|
|
19
|
+
* @param attributes - Objeto de atributos donde agregar metadata
|
|
20
|
+
* @param body - Body del request
|
|
21
|
+
* @param prefix - Prefijo para las keys (default: 'http.request.body')
|
|
22
|
+
*/
|
|
23
|
+
export declare function addRequestBodyMetadata(attributes: Record<string, string>, body: any, prefix?: string): void;
|
|
24
|
+
/**
|
|
25
|
+
* Agrega metadata de Response body a SpanAttributes (para middlewares HTTP)
|
|
26
|
+
* Solo agrega información extendida en caso de errores
|
|
27
|
+
* @param attributes - Objeto de atributos donde agregar metadata
|
|
28
|
+
* @param body - Body del response
|
|
29
|
+
* @param isError - Si es una respuesta de error (statusCode >= 400)
|
|
30
|
+
* @param prefix - Prefijo para las keys (default: 'http.response.body')
|
|
31
|
+
*/
|
|
32
|
+
export declare function addResponseBodyMetadata(attributes: Record<string, string>, body: any, isError?: boolean, prefix?: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Agrega metadata genérica de un objeto a SpanAttributes
|
|
35
|
+
* Útil para analizar cualquier tipo de payload
|
|
36
|
+
* @param attributes - Objeto de atributos donde agregar metadata
|
|
37
|
+
* @param data - Objeto a analizar
|
|
38
|
+
* @param prefix - Prefijo para las keys
|
|
39
|
+
*/
|
|
40
|
+
export declare function addObjectMetadata(attributes: Record<string, string>, data: any, prefix: string): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hemia/trace-manager",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Gestor de trazas para registrar logs, errores y evento",
|
|
5
5
|
"main": "dist/hemia-trace-manager.js",
|
|
6
6
|
"module": "dist/hemia-trace-manager.esm.js",
|
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
"test": "jest --passWithNoTests --detectOpenHandles"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@hemia/app-context": "^0.0.
|
|
17
|
-
"@hemia/db-manager": "^0.0.6",
|
|
16
|
+
"@hemia/app-context": "^0.0.6",
|
|
18
17
|
"@rollup/plugin-commonjs": "^26.0.1",
|
|
19
18
|
"@rollup/plugin-json": "^6.1.0",
|
|
20
19
|
"@rollup/plugin-node-resolve": "^15.2.3",
|