Skip to content

Observabilidad ​

Con muchos tenants, "algo falló" no alcanza. Necesitas saber en qué tenant, en qué operación y por qué. Por eso la observabilidad está en el núcleo, no en un plugin: todo lo que hace el paquete pasa por un mismo Observer que escribe logs con campos fijos, guarda los errores por tenant y avisa a la telemetría (trazas y métricas).

Decisiones en ADR 0005 y ADR 0014.

Logs con campos fijos ​

Cada línea que escribe el paquete usa los mismos nombres:

CampoContenido
tenantIdId del tenant o null (contexto central). Siempre presente
operationtenants.create, http.request, queue.job... (ver la tabla)
outcomesuccess o error
durationMsDuración de la operación o de la petición
codeCódigo estable del error: TENANCY_*, el code del error (ECONNREFUSED) o el nombre de la clase
errorIdId del error en el registro de errores (para cruzar el log con el panel)
errEl error completo, con stack

Las peticiones HTTP agregan requestId, method, path, host, route (la plantilla, por ejemplo /pedidos/:id) y statusCode. Con @tenancy-node/otel se agregan traceId y spanId. Los logs del paquete llevan además component: 'tenancy'.

Un error del paquete se asigna al tenant del que habla: si TenantSuspendedError trae details.tenantId, se registra bajo ese tenant aunque la petición nunca haya entrado a su contexto.

Niveles ​

Qué pasóNivel
Escritura correcta (tenants.create, domains.add...)info
Lectura correcta (tenants.find, tenants.list, domains.list)debug
Error del cliente (4xx: no encontrado, ya existe, datos inválidos)warn
Error del servidor (5xx) o inesperadoerror
Intento fallido con reintentos pendientes (trabajo, outbox, webhook, transporte)warn
Último intento fallido (dead-letter)error

Para las peticiones HTTP el nivel sale del código de respuesta: >= 500 es error, >= 400 es warn y el resto info.

Usar pino ​

El logger por defecto es ConsoleLogger (JSON por consola, nivel info). En producción pasa pino(): la interfaz Logger es compatible.

ts
import pino from 'pino';
import { createTenancy } from '@tenancy-node/core';

const tenancy = createTenancy({
  centralDomains: ['tuapp.com'],
  logger: pino({ level: 'info' }), // cualquier logger compatible con pino
});

Cualquier objeto con debug, info, warn, error y child sirve. Para ver las lecturas (debug) sube el nivel del logger que pases.

tenancy.observability ​

ts
// Tu log, con el tenantId del contexto agregado solo
tenancy.observability.logger.info({ pedidoId }, 'pedido confirmado');

// Registrar un error de tu aplicación (queda en el log y en el registro de errores)
try {
  await cobrar(pedidoId);
} catch (error) {
  const tracked = tenancy.observability.report('pagos.cobrar', error, { pedidoId });
  console.log(tracked.id); // el mismo valor que `errorId` en el log
}

tenancy.observability.errors({ tenantId: 'bolivar', limit: 20 }); // recientes de bolivar
tenancy.observability.errors({ tenantId: null }); // del contexto central
tenancy.observability.summary(); // [{ tenantId, total, byCode, lastErrorAt }], más errores primero
tenancy.observability.clear('bolivar'); // olvida los de bolivar
MiembroQué hace
loggerLogger que agrega tenantId del contexto a cada línea (si no lo pasas tú)
report(operation, error, context?)Registra el error (nivel error) con el tenant actual o context.tenantId. Devuelve el TrackedError
errors({ tenantId?, limit? })Errores más nuevos primero. Sin tenantId, de todos; null, del central. limit por defecto 50
summary()Totales por tenant y por código
clear(tenantId?)Borra los de un tenant, o todos

Cada TrackedError trae id, tenantId, operation, code, name, message, stack, time y context (método, ruta, tipo de evento...).

El panel usa lo mismo: GET /admin/api/errors?tenant=bolivar y la pestaña Errores del tenant (Panel).

Registro de errores (ErrorTracker) ​

Por defecto es MemoryErrorTracker: un ring buffer por tenant en la memoria del proceso.

OpciónPor defectoQué hace
perTenant100Errores guardados por tenant (los más viejos se descartan)
maxTenants10000Tenants distintos que se siguen; se descarta el que lleva más tiempo sin errores

Puedes pasar las opciones o tu propia implementación del puerto:

ts
import { createTenancy, MemoryErrorTracker, type ErrorTracker, type TrackedError } from '@tenancy-node/core';

// Opciones del tracker en memoria
createTenancy({ errorTracker: { perTenant: 200, maxTenants: 5_000 } });

// O tu propia implementación del puerto: aquí se reenvía cada error a otro sistema
class ReenviarErrores implements ErrorTracker {
  private readonly local = new MemoryErrorTracker();
  constructor(private readonly enviar: (error: TrackedError) => void) {}
  record(error: TrackedError): void {
    this.local.record(error);
    this.enviar(error);
  }
  recent(options?: { tenantId?: string | null; limit?: number }): TrackedError[] {
    return this.local.recent(options);
  }
  summary() {
    return this.local.summary();
  }
  clear(tenantId?: string | null): void {
    this.local.clear(tenantId);
  }
}

createTenancy({ errorTracker: new ReenviarErrores((e) => console.error(e.id, e.code)) });

Es por proceso

Con varias réplicas, cada una tiene su propio registro en memoria: errors() y summary() solo muestran lo de ese proceso. Para una vista global usa los logs (todos llevan errorId) o un ErrorTracker propio que guarde en un lugar compartido. El paquete no trae uno persistente.

Health checks ​

tenancy.health() corre en paralelo un chequeo por cada pieza configurada que lo soporte: database (base central, con @tenancy-node/db), cache, invalidation, queue, storage y transport:<nombre>. Cada chequeo tiene 3 s como máximo.

ts
const tenancy = createTenancy({
  centralDomains: ['tuapp.com'],
  http: { health: true }, // GET /tenancy/health en cualquier host
});

const report = await tenancy.health();
// { status: 'ok' | 'error', checks: { cache: { ok, durationMs, error? }, queue: {...}, ... } }
if (report.status === 'error') console.error(report.checks);

Con http: { health: true } los adaptadores responden GET /tenancy/health (o <prefix>/health si cambias http.prefix) en cualquier host, antes de identificar al tenant: sirve para el balanceador. Responde 200 si todo está bien y 503 si algo falla, con Cache-Control: no-store. Cada chequeo que falla se registra como error con operation: 'health.check'.

El panel tiene su propia ruta pública: GET /admin/api/health.

Operaciones ​

Estos son los valores de operation que emite el paquete. Las marcadas como traza pasan por observer.trace: dejan log al terminar (bien o mal), abren un span y alimentan las métricas operation_duration. Las demás solo escriben logs o registran errores.

operationPaqueteTipoNotas
tenants.create, .update, .suspend, .activate, .maintenance, .delete, .retryProvisioningcoretrazainfo; 4xx como warn
tenants.find, tenants.findOrFail, tenants.listcoretrazadebug
domains.add, domains.remove, domains.setPrimary, domains.listcoretrazadomains.list en debug
theme.update, theme.resetcoretraza
tenancy.runForEachcoretrazaTodo el recorrido
tenancy.runForEach.itemcoreerrorUn tenant que falló dentro del recorrido
tenancy.resolvecoreerrorNo se pudo identificar el tenant (warn), o está suspendido/en mantenimiento
http.requestcore + adaptadoreslogUna línea por petición; nivel según statusCode
events.listenercoreerrorUn listener async que lanzó
events.transportcorelogEnvío directo a un transporte (sin outbox), por intento
queue.jobcoretrazaCada intento; warn si quedan reintentos
queue.dispatch, queue.workercore, clilogTrabajo encolado (debug), worker iniciado
health.checkcoreerrorUn chequeo que falló
storage.delete_tenantcoreerrorNo se pudieron borrar los archivos de un tenant borrado
cache.invalidation.publish, .subscribe, .receivedcorelogBus de invalidación entre réplicas
provisioning.stepdbtrazaCada paso (createDatabase, createUser, migrate, seed) con step, runId, attempt
database.install, database.migrate, database.rollback, database.seeddblogUna línea por tenant
database.migrations.load, database.rotateKeydblog
database.move, database.move.stepdbtrazatenancy move
database.move.table, .cleanup, .restoredblog
db.pool.open, db.pool.close, db.pool.limit, db.pool.error, db.servers.loaddblogPools por tenant
knex.open, knex.close (y el nombre de cada ORM)orm-*logInstancias del ORM por tenant
outbox.relayoutboxtrazaCada lote con al menos un evento, en debug (un sondeo vacío no deja span ni métrica); también el arranque del relay y sus errores
outbox.deliver, outbox.dead_letteroutboxlogdeliver en warn si hay reintento
webhook.register, webhook.deliver, webhook.dead, webhook.circuit_open, webhook.dispatchertransport-webhooklog
admin.request, admin.serve, admin.impersonation_usedadmin-apilog
cli.<comando>, cli.runclierrorErrores de un comando

Los nombres que registras tú con observability.report('pagos.cobrar', ...) conviven con estos.

Seguir los errores de un tenant ​

Todo lo de un tenant se filtra por tenantId. Con los logs en un archivo (o tenancy --log-file):

bash
# todo lo de bolivar
jq -c 'select(.tenantId == "bolivar")' app.log

# solo sus errores, con lo mínimo para diagnosticar
jq -c 'select(.tenantId == "bolivar" and .outcome == "error")
       | {time, operation, code, errorId, msg}' app.log

# errores por código en todos los tenants
jq -r 'select(.outcome == "error") | "\(.tenantId)\t\(.code)"' app.log | sort | uniq -c | sort -rn

Con un errorId del panel encuentras la línea completa (con stack): jq 'select(.errorId == "01J...")' app.log. Si usas OpenTelemetry, esa línea trae el traceId para abrir la traza.

TIP

En Loki, Datadog o ELK es la misma idea: indexa tenantId, operation, code y outcome. Con pino, level es un número (40 = warn, 50 = error); con ConsoleLogger, un texto.

Telemetría ​

El núcleo no depende de OpenTelemetry ni de prom-client. Define un puerto, Telemetry, con todo opcional:

MiembroCuándo se llama
nameObligatorio
span(operation, attributes, fn)Envuelve cada operación de tipo traza; las internas quedan como hijas
tenantResolved(tenantId)Se identificó el tenant de la petición
recordOperation(sample)Terminó una operación: { operation, tenantId, outcome, durationMs, code? }
recordRequest(sample)Terminó una petición: { tenantId, method, route, statusCode, durationMs }
recordError(error)Se registró un TrackedError
logFields()Campos extra para los logs del observer (por ejemplo traceId)

Los atributos del span son tenancy.operation, tenant.id (si hay tenant) y tenancy.<campo> por cada dato simple del contexto. Si una telemetría lanza, el núcleo la ignora: medir nunca tumba la app. Puedes pasar una o varias (telemetry: [a, b]).

ts
import { createTenancy, type Telemetry } from '@tenancy-node/core';

// Todo es opcional: implementa solo lo que te sirve. Si algo lanza, el núcleo lo ignora.
const lentas: Telemetry = {
  name: 'operaciones-lentas',
  recordOperation(sample) {
    if (sample.durationMs > 1_000)
      console.warn(`${sample.operation} tardó ${sample.durationMs} ms (tenant ${sample.tenantId})`);
  },
  recordError(error) {
    console.warn(`error ${error.code} en ${error.operation}: ${error.id}`);
  },
};

createTenancy({ telemetry: lentas });

OpenTelemetry (@tenancy-node/otel) ​

openTelemetry() solo usa @opentelemetry/api (peer dependency). El SDK y el exportador los configuras tú al arrancar. Sin SDK, la API es no-op y no cuesta nada.

Qué hace:

  • Un span INTERNAL por cada operación de tipo traza, con tenant.id y tenancy.operation. Si falla: estado ERROR, la excepción registrada, el atributo tenancy.error_code y un evento tenancy.error con el tenancy.error_id del registro de errores.
  • tenant.id en el span HTTP activo (el de @opentelemetry/instrumentation-http) cuando se identifica el tenant (central si no hay).
  • traceId y spanId en los logs de operaciones, errores y peticiones.
  • Métricas (ver la tabla).
OpciónPor defectoQué hace
tracertrace.getTracer('tenancy-node')Tracer a usar
metermetrics.getMeter('tenancy-node')Meter a usar; false desactiva las métricas
tenantMetricsfalsetenant.id también en las métricas (en las trazas va siempre)
correlateLogstruetraceId/spanId en los logs

Un arranque con el SDK base, como en las pruebas del paquete (cambia los exportadores de consola por los tuyos, por ejemplo OTLP):

ts
// telemetria.ts: se importa antes que cualquier otro módulo de la app
import { context, metrics, trace } from '@opentelemetry/api';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
import { BasicTracerProvider, BatchSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
import {
  ConsoleMetricExporter,
  MeterProvider,
  PeriodicExportingMetricReader,
} from '@opentelemetry/sdk-metrics';
import { createTenancy } from '@tenancy-node/core';
import { openTelemetry } from '@tenancy-node/otel';

// Sin un context manager, los spans no se anidan entre llamadas async.
context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable());
trace.setGlobalTracerProvider(
  new BasicTracerProvider({ spanProcessors: [new BatchSpanProcessor(new ConsoleSpanExporter())] }),
);
metrics.setGlobalMeterProvider(
  new MeterProvider({
    readers: [new PeriodicExportingMetricReader({ exporter: new ConsoleMetricExporter(), exportIntervalMillis: 60_000 })],
  }),
);

export const tenancy = createTenancy({
  centralDomains: ['tuapp.com'],
  telemetry: openTelemetry(), // usa el tracer y el meter globales ('tenancy-node')
});

Si usas @opentelemetry/sdk-node, la regla es la misma: inicia el SDK antes de createTenancy y de cargar tu framework, así el span HTTP existe cuando el adaptador identifica al tenant.

Otras combinaciones:

ts
openTelemetry({
  tenantMetrics: false, // tenant.id en las métricas (por defecto false; en las trazas va siempre)
  correlateLogs: true, // traceId y spanId en los logs (por defecto true)
  meter: false, // solo trazas, sin métricas
});

Prometheus (@tenancy-node/prometheus) ​

prometheus() crea un registro de prom-client propio (no el global) y devuelve la telemetría con metrics(), contentType y registry.

ts
import { createServer } from 'node:http';
import { createTenancy } from '@tenancy-node/core';
import { prometheus } from '@tenancy-node/prometheus';

const metrics = prometheus({
  perTenant: { maxTenants: 50 }, // etiqueta `tenant` para los primeros 50; el resto va como `other`
  defaultMetrics: true, // CPU, memoria y event loop del proceso
});
const tenancy = createTenancy({ centralDomains: ['tuapp.com'], telemetry: metrics });

// /metrics en un puerto interno, fuera del tráfico público
createServer(async (req, res) => {
  if (req.url !== '/metrics') return void res.writeHead(404).end();
  res.writeHead(200, { 'content-type': metrics.contentType }).end(await metrics.metrics());
}).listen(9464);

En Express o Fastify es lo mismo: una ruta que responde await metrics.metrics() con metrics.contentType. Con varios procesos, cada uno expone lo suyo y Prometheus los suma.

OpciónPor defectoQué hace
registryuno nuevoRegistro de prom-client (pasa el tuyo para juntar métricas)
prefixtenancy_Prefijo de los nombres
perTenantfalseEtiqueta tenant (ver abajo)
defaultMetricsfalseMétricas del proceso con el mismo prefijo
buckets0.005 a 30 sLímites de los histogramas, en segundos

Cardinalidad: perTenant ​

Cada tenant multiplica las series. Con miles de tenants, Prometheus se cae. Por eso la etiqueta tenant está apagada por defecto.

ts
prometheus({ perTenant: true }); // hasta 100 tenants con etiqueta propia
prometheus({ perTenant: { allow: ['bolivar', 'tigre'] } }); // solo estos; el resto `other`
prometheus({ perTenant: { allow: (id) => id.startsWith('vip-') } });
  • true o { maxTenants }: los primeros tenants que aparecen (100 por defecto) tienen etiqueta propia; los siguientes van como other.
  • { allow }: una lista o una función. Solo esos tenants; el resto other. Con allow, maxTenants no aplica.
  • El contexto central va como central.

Las rutas HTTP usan la plantilla (/pedidos/:id), nunca la ruta real. Sin plantilla van como unmatched.

Métricas ​

PrometheusOpenTelemetryEtiquetas PrometheusAtributos OpenTelemetry
tenancy_operation_duration_secondstenancy.operation.duration (ms)operation, outcome (+ tenant)tenancy.operation, tenancy.outcome (+ tenant.id)
tenancy_http_request_duration_secondstenancy.http.server.duration (ms)method, route, status_class (+ tenant)http.request.method, http.response.status_code, http.route, tenancy.status_class (+ tenant.id)
tenancy_errors_totaltenancy.errorsoperation, code (+ tenant)tenancy.operation, tenancy.error_code (+ tenant.id)

status_class es 2xx, 4xx, 5xx... Las métricas de operaciones solo cubren las operaciones de tipo traza de la tabla; tenancy_errors_total cuenta todos los errores registrados.

Límites ​

  • El registro de errores y las métricas son por proceso.
  • Sin OpenTelemetry no hay traceId en los logs. requestId sale del request.id de Fastify; en Express, solo si la petición trae el header x-request-id.
  • @tenancy-node/prometheus no crea spans ni agrega campos a los logs.

Referencia: @tenancy-node/core, @tenancy-node/otel, @tenancy-node/prometheus.

Publicado bajo la licencia MIT.