@hemia/app-context
v0.0.6
Published
Manejo centralizado de contexto y sesión para apps Hemia.
Readme
@hemia/app-context
Middleware y utilidades para gestionar contexto distribuido compatible con OpenTelemetry en aplicaciones backend Node.js/Express. Proporciona trazabilidad automática, observabilidad profunda y correlación de logs con esquema optimizado para ClickHouse.
🚀 Características principales
✅ Compatible con OpenTelemetry - Spans y Logs según estándar OTEL
✅ Esquema ClickHouse nativo - Diseñado para otel_traces y otel_logs
✅ Correlación automática - TraceId propagado via AsyncLocalStorage
✅ Decorador @Trace - Captura automática de métodos como spans
✅ Logger contextual - Logs correlacionados automáticamente
✅ Observabilidad profunda - Memory, CPU, Host, User tracking
✅ Zero-config para servicios - Contexto propagado automáticamente
📦 Instalación
npm install @hemia/app-context uuid
npm install --save-dev @types/uuid🎯 Uso básico
1. Setup del Middleware (Express)
import express from 'express';
import { appContextMiddleware } from '@hemia/app-context';
const app = express();
app.use(express.json());
// Middleware de contexto OpenTelemetry
app.use('/api',
appContextMiddleware(
'API', // checkpoint
'Gateway', // functionCode
(context, res) => {
// Callback al finalizar: enviar trazas a ClickHouse/Collector
console.log(`✅ Trace ${context.traceId} completado`);
console.log(` Spans: ${context.traceContext.spans.length}`);
console.log(` Logs: ${context.traceContext.logs.length}`);
// Aquí envías a tu backend de observabilidad
// sendToClickHouse(context.traceContext);
},
undefined, // traceId (opcional, para continuar trace)
['api', 'v1'] // tags adicionales
)
);
app.listen(3000);2. Acceder al contexto en cualquier parte
import { asyncContext } from '@hemia/app-context';
export class UserService {
async getUser(id: string) {
const context = asyncContext.getStore();
console.log(`TraceId: ${context?.traceId}`);
console.log(`User: ${context?.user?.email}`);
// Tu lógica aquí
}
}3. Usar decorador @Trace (Recomendado)
import { Trace } from './decorators/Trace';
import { logger } from './logger/ContextualLogger';
export class UserService {
@Trace({
name: 'UserService.findById',
kind: 'SPAN_KIND_CLIENT',
attributes: { 'db.system': 'postgresql' }
})
async findById(id: string): Promise<User> {
logger.info('Buscando usuario', { userId: id });
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!user) {
logger.warn('Usuario no encontrado', { userId: id });
throw new Error('User not found');
}
return user;
}
}4. Logger contextual (auto-correlacionado)
import { logger } from './logger/ContextualLogger';
export class PaymentService {
async processPayment(amount: number) {
logger.info('Procesando pago', { amount });
try {
const result = await stripe.charge(amount);
logger.info('Pago exitoso', { transactionId: result.id });
return result;
} catch (error: any) {
logger.error('Error en pago', { error: error.message });
throw error;
}
}
}🏗️ Arquitectura OpenTelemetry
Estructura de datos
IAppContext {
// Identificadores
traceId: string // UUID compartido por toda la transacción
spanId: string // UUID del span actual
parentSpanId: string // UUID del span padre
serviceName: string // Nombre del servicio
// Contexto OpenTelemetry completo
traceContext: {
spans: TraceSpan[] // Array de spans capturados
logs: TraceLog[] // Array de logs correlacionados
resourceAttributes: {...} // Metadatos del servicio/host
}
// Datos del request
user?: IUserData // Usuario autenticado
userId?: string
language: string
// Telemetría del host
host: IHostInfo // hostname, ip, platform, cpuUsage
memory: IMemoryInfo // rss, heapTotal, heapUsed
application: IApplicationInfo
environment: IEnvironmentInfo
}TraceSpan (compatible con ClickHouse)
interface TraceSpan {
Timestamp: string // ISO 8601
TraceId: string
SpanId: string
ParentSpanId: string
ServiceName: string // ej: "user-service"
SpanName: string // ej: "UserService.findById"
SpanKind: SpanKindType // SERVER | CLIENT | INTERNAL
DurationUInt64: bigint // Nanosegundos
StatusCode: StatusCodeType // OK | ERROR | UNSET
StatusMessage: string
SpanAttributes: { // Datos específicos del span
'http.method': 'GET',
'http.status_code': '200',
'user.id': '123',
'db.system': 'postgresql',
...
}
ResourceAttributes: { // Metadatos del servicio
'service.name': 'api',
'host.name': 'server-01',
'process.runtime.memory.rss': '52428800',
...
}
EventsNested: SpanEvent[] // Excepciones, logs anidados
LinksNested: SpanLink[] // Links a otros traces
}🎨 Decorador @Trace (Implementación sugerida)
Nota: Este paquete proporciona el contexto (
asyncContext) y las interfaces (TraceSpan,SpanEvent), pero el decorador@Tracedebe implementarse en tu proyecto.
Implementación recomendada del decorador
Crea en tu proyecto src/decorators/Trace.ts:
import { asyncContext, TraceSpan, SpanEvent, SpanKindType } from '@hemia/app-context';
import { v4 as getUUID } from 'uuid';
export interface TraceOptions {
name?: string;
kind?: SpanKindType;
attributes?: Record<string, string>;
}
export function Trace(options?: TraceOptions) {
return function (target: any, key: string, descriptor: PropertyDescriptor): void {
const originalMethod = descriptor.value;
if (typeof originalMethod !== 'function') {
throw new Error(`@Trace can only be applied to methods`);
}
descriptor.value = async function (...args: any[]) {
const context = asyncContext.getStore();
if (!context) {
console.warn('[Trace] No active context. Skipping trace.');
return await originalMethod.apply(this, args);
}
const spanId = getUUID();
const className = this?.constructor?.name || target.name || 'UnknownClass';
const spanName = options?.name || `${className}.${key}`;
const startTime = process.hrtime.bigint();
const span: TraceSpan = {
Timestamp: new Date().toISOString(),
TraceId: context.traceId,
SpanId: spanId,
ParentSpanId: context.spanId,
TraceState: '',
ServiceName: context.serviceName,
SpanName: spanName,
SpanKind: options?.kind || 'SPAN_KIND_INTERNAL',
DurationUInt64: BigInt(0),
StatusCode: 'STATUS_CODE_UNSET',
StatusMessage: '',
SpanAttributes: {
'code.function': key,
'code.namespace': className,
...options?.attributes,
},
ResourceAttributes: context.traceContext.resourceAttributes,
EventsNested: [],
LinksNested: [],
};
const previousSpanId = context.spanId;
context.spanId = spanId;
try {
const result = await originalMethod.apply(this, args);
span.StatusCode = 'STATUS_CODE_OK';
return result;
} catch (error: any) {
const exceptionEvent: SpanEvent = {
Timestamp: new Date().toISOString(),
Name: 'exception',
Attributes: {
'exception.type': error.constructor?.name || 'Error',
'exception.message': error.message || 'Unknown error',
'exception.stacktrace': (error.stack || '').substring(0, 2000),
},
};
span.EventsNested.push(exceptionEvent);
span.StatusCode = 'STATUS_CODE_ERROR';
span.StatusMessage = error.message || 'Unhandled exception';
throw error;
} finally {
const endTime = process.hrtime.bigint();
span.DurationUInt64 = endTime - startTime;
context.traceContext.spans.push(span);
context.spanId = previousSpanId;
}
};
};
}Ejemplos de uso del decorador
export class UserService {
// Sin opciones (usa defaults)
@Trace()
async findAll(): Promise<User[]> {
return await db.query('SELECT * FROM users');
}
// Con nombre personalizado
@Trace({ name: 'DB:GetUserById' })
async findById(id: string): Promise<User> {
return await db.findOne(id);
}
// Span de cliente (llamada externa)
@Trace({
kind: 'SPAN_KIND_CLIENT',
attributes: { 'db.system': 'postgresql' }
})
async saveUser(user: User): Promise<void> {
await db.save(user);
}
}📝 Logger Contextual (Implementación sugerida)
Nota: Este paquete proporciona
addLogToContext()como función helper. El logger de clase es una implementación sugerida para tu proyecto.
Opción 1: Usar addLogToContext() directamente (incluido en el paquete)
import { asyncContext, addLogToContext } from '@hemia/app-context';
const context = asyncContext.getStore();
if (context) {
addLogToContext(context, 'INFO', 'Usuario encontrado', { userId: '123' });
addLogToContext(context, 'ERROR', 'Falló la conexión', { error: 'timeout' });
}Opción 2: Implementar logger de clase (recomendado)
Crea en tu proyecto src/logger/ContextualLogger.ts:
import { asyncContext, TraceLog, SeverityTextType } from '@hemia/app-context';
const SEVERITY_MAP: Record<SeverityTextType, number> = {
DEBUG: 5,
INFO: 9,
WARN: 13,
ERROR: 17,
FATAL: 21,
};
export class ContextualLogger {
private log(severity: SeverityTextType, message: string, attributes?: Record<string, any>): void {
const context = asyncContext.getStore();
if (!context) {
console.warn('[Logger] No active context. Log not traced.');
console.log(`[${severity}] ${message}`, attributes);
return;
}
const logEntry: TraceLog = {
Timestamp: new Date().toISOString(),
TraceId: context.traceId,
SpanId: context.spanId,
TraceFlags: 1,
SeverityText: severity,
SeverityNumber: SEVERITY_MAP[severity],
ServiceName: context.serviceName,
Body: message,
LogAttributes: this.stringifyAttributes(attributes || {}),
ResourceAttributes: context.traceContext.resourceAttributes,
};
context.traceContext.logs.push(logEntry);
if (process.env.NODE_ENV !== 'production') {
console.log(`[${severity}] [${context.traceId.substring(0, 8)}...] ${message}`, attributes || '');
}
}
private stringifyAttributes(attrs: Record<string, any>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null) {
result[key] = '';
} else if (typeof value === 'string') {
result[key] = value;
} else {
try {
result[key] = JSON.stringify(value);
} catch {
result[key] = String(value);
}
}
}
return result;
}
debug(message: string, attributes?: Record<string, any>): void {
this.log('DEBUG', message, attributes);
}
info(message: string, attributes?: Record<string, any>): void {
this.log('INFO', message, attributes);
}
warn(message: string, attributes?: Record<string, any>): void {
this.log('WARN', message, attributes);
}
error(message: string, attributes?: Record<string, any>): void {
this.log('ERROR', message, attributes);
}
fatal(message: string, attributes?: Record<string, any>): void {
this.log('FATAL', message, attributes);
}
}
export const logger = new ContextualLogger();🔧 API Reference
Funciones exportadas
// Middleware principal
appContextMiddleware(
checkPoint: string,
functionCode: string,
onFinishCallback?: (context: IAppContext, res: Response) => void,
traceId?: string,
tags?: string[]
): RequestHandler
// Manejo de errores
updateContextStackTrace(
context: IAppContext,
stage: string,
error: unknown,
errorCode: string
): void
// Agregar spans/logs manualmente
addSpanToContext(
context: IAppContext,
spanName: string,
durationMs: number,
options?: TraceOptions
): void
addLogToContext(
context: IAppContext,
severityText: SeverityTextType,
body: string,
logAttributes?: Record<string, string>
): void
// Helpers OpenTelemetry
userToSpanAttributes(user?: IUserData): Record<string, string>
generateResourceAttributes(host, app, env, memory?): Record<string, string>
createTraceSpan(options): TraceSpan
createTraceLog(options): TraceLogHeaders utilizados
| Header | Descripción |
|--------|-------------|
| x-trace-id | ID único del trace (propagado entre servicios) |
| x-span-id | ID del span actual (generado por cada servicio) |
📊 Ejemplo de jerarquía de Spans
TraceId: abc-123-def-456
├── API.Gateway (SPAN_KIND_SERVER) ────────────── 250ms
│ ├── UserController.getUser ────────────────── 240ms
│ │ ├── UserService.findById ──────────────── 80ms
│ │ │ └── UserRepository.query (CLIENT) ── 75ms
│ │ │
│ │ └── EmailService.sendWelcome (CLIENT) ── 150ms
│ │
│ └── API.Gateway.response ──────────────────── 250ms
│
└── [Logs correlacionados por TraceId]
• INFO: "Request received"
• DEBUG: "Querying database"
• INFO: "User found"
• INFO: "Sending welcome email"
• INFO: "Request completed"🎯 SpanKind Reference
| SpanKind | Cuándo usar | Ejemplo |
|----------|-------------|---------|
| SPAN_KIND_SERVER | Endpoint que recibe requests | Controllers HTTP |
| SPAN_KIND_CLIENT | Llamadas salientes (DB, HTTP, etc) | Repositories, HTTP clients |
| SPAN_KIND_INTERNAL | Lógica interna del servicio | Services, Validators |
| SPAN_KIND_PRODUCER | Publicar mensajes | Kafka Producer |
| SPAN_KIND_CONSUMER | Consumir mensajes | Kafka Consumer |
📦 Interfaces principales
interface IAppContext {
traceContext: TraceContext;
traceId: string;
spanId: string;
parentSpanId: string;
serviceName: string;
user?: IUserData;
userId?: string;
// ... más campos
}
interface TraceContext {
spans: TraceSpan[];
logs: TraceLog[];
resourceAttributes: Record<string, string>;
}
interface IUserData {
idUser: string;
email: string;
idRole: string;
nameRole: string;
roles: string[];
permisos: string[];
profile?: IUserProfile;
}🗄️ Schema ClickHouse
Este paquete genera spans y logs compatibles con:
CREATE TABLE otel_traces (
Timestamp DateTime64(9),
TraceId String,
SpanId String,
ParentSpanId String,
ServiceName LowCardinality(String),
SpanName LowCardinality(String),
SpanKind LowCardinality(String),
DurationUInt64 UInt64,
StatusCode LowCardinality(String),
SpanAttributes Map(LowCardinality(String), String),
ResourceAttributes Map(LowCardinality(String), String),
EventsNested Nested(...),
LinksNested Nested(...)
) ENGINE = MergeTree()
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp), TraceId);
CREATE TABLE otel_logs (
Timestamp DateTime64(9),
TraceId String,
SpanId String,
SeverityText LowCardinality(String),
SeverityNumber Int32,
ServiceName LowCardinality(String),
Body String,
LogAttributes Map(LowCardinality(String), String),
ResourceAttributes Map(LowCardinality(String), String)
) ENGINE = MergeTree()
ORDER BY (ServiceName, SeverityText, toUnixTimestamp(Timestamp), TraceId);🚀 Roadmap
- [ ] Exporters para ClickHouse directo
- [ ] Integración con OpenTelemetry Collector
- [ ] Sampler configurable
- [ ] Métricas automáticas (latency, error rate)
- [ ] Dashboard de visualización
📋 Requisitos
- Node.js >= 16
- Express >= 4.x
- TypeScript >= 4.x (recomendado)
📄 Licencia
MIT
👥 Autor
Hemia Technologies
🔗 Más información: hemia.com
