Skip to content

Observability

Three pillars — traces, logs, and health — are wired through three packages: @modularityjs/telemetry (tracing contract), @modularityjs/logger (log transport pool), and @modularityjs/health (indicator pool). What makes this story framework-specific is the *-telemetry extension pattern: every contract that does I/O ships an optional extension package that instruments its methods with spans. Wire them in once at the module list, and every cache lookup, queue publish, lock acquire, etc. shows up in your traces with no decorator soup at call sites.

Tracing

TracerService from @modularityjs/telemetry is the abstract contract. The OTel driver (@modularityjs/telemetry-otel) provides the implementation and starts a NodeSDK on boot.

typescript
import { TelemetryModule } from '@modularityjs/telemetry';
import { TelemetryOtelModule } from '@modularityjs/telemetry-otel';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

const app = await createApp({
  di: inversify,
  modules: [
    ModularityModule,
    TelemetryModule.forRoot({ serviceName: 'orders-api' }),
    TelemetryOtelModule.forRoot({
      traceExporter: new OTLPTraceExporter({ url: process.env.OTLP_URL }),
    }),
    AppModule,
  ],
});

In application code, prefer withSpan over manual startSpan/end — it auto-ends the span and records exceptions:

typescript
@Injectable()
class OrdersService {
  constructor(@Inject(TracerService) private readonly tracer: TracerService) {}

  async place(orderId: string) {
    return this.tracer.withSpan(
      'orders.place',
      SpanKind.INTERNAL,
      async (span) => {
        span.setAttribute('order.id', orderId);
        // your work; nested spans pick up this one via active context
      },
    );
  }
}

For propagating context across a process boundary (outbound HTTP, queue publish), use injectContext(headers) on the producer side and extractContext(headers) + runInContext(ctx, fn) on the consumer side. See Telemetry.

Per-contract instrumentation

Every contract that crosses a process boundary ships a *-telemetry extension that auto-instruments its methods. They use the framework's @Plugin AOP, not call-site decorators — you wire the module and existing code starts emitting spans.

Extensions available: cache-telemetry, events-telemetry, http-telemetry, lock-telemetry, mail-telemetry, notification-telemetry, outbox-telemetry, queue-telemetry, rate-limit-telemetry, scheduler-telemetry, session-telemetry, sms-telemetry, storage-telemetry, webhook-telemetry.

typescript
modules: [
  ModularityModule,
  TelemetryModule.forRoot({ serviceName: 'orders-api' }),
  TelemetryOtelModule.forRoot({
    /* ... */
  }),

  CacheModule,
  CacheRedisModule,
  CacheTelemetryModule,
  LockModule,
  LockRedisModule,
  LockTelemetryModule,
  QueueModule,
  QueueRedisModule,
  QueueTelemetryModule,
  HttpModule,
  HttpFastifyModule,
  HttpTelemetryModule,
  // ...
];

Each extension sets domain-specific attributes (cache hit/miss, queue topic, lock key, HTTP route+status) and marks spans as errored when the underlying call throws. Use them in production; turn them off only if span volume becomes a budget problem.

Logging

LoggerService exposes debug / info / warn / error and dispatches to every transport in LogTransportPool. Transports self-filter by channel (string list or '*') and minLevel — so the same logger call goes to the console in dev, a file in prod, and OTel in both, without callers knowing.

typescript
modules: [
  LoggerModule,
  // Dev: human-readable to stdout / stderr
  LoggerConsoleModule,
  // Prod: structured to disk, all channels
  LoggerFileModule.forAll({ path: '/var/log/app.log', minLevel: 'info' }),
  // Optional: route a noisy channel to its own file
  LoggerFileModule.forChannel('audit', {
    path: '/var/log/audit.log',
    minLevel: 'info',
  }),
  // Always: pipe logs into spans
  LoggerTelemetryModule,
],

Correlating logs with traces

@modularityjs/logger-telemetry registers a transport that, when there is an active span, attaches each log entry as a span event with the level, channel, scope, and any scalar context fields. Logs with no active span are dropped by this transport (they still go to console / file). The practical effect: a trace viewer shows every logger.info(...) that ran inside the request as an event on the request's span — no manual correlation IDs needed.

The two rules to make this useful:

  1. Don't disable the OTel SDK in dev. Even without an exporter, the SDK keeps context alive so LoggerTelemetryModule works. Use a console exporter or a no-op exporter.
  2. Run business code inside tracer.withSpan(...) at the entry points you care about. HTTP requests are auto-wrapped by HttpTelemetryModule; queue consumers by QueueTelemetryModule; etc. Custom entry points need to wrap themselves.

See Logger.

Health probes

@modularityjs/health defines HealthIndicator (name + async check()) and a HealthIndicatorsPool. HealthService.check() walks the pool sequentially, racing each indicator's check() against indicatorTimeoutMs (default 5000 ms) before moving to the next. A failing or timed-out indicator is recorded as unhealthy and does not abort the run, but a slow indicator delays everything after it — keep individual checks fast, and budget worst-case latency as indicatorCount * indicatorTimeoutMs.

The framework does not register HTTP routes. Apps expose health themselves — typically two controllers, one for each k8s probe semantic:

typescript
@Controller('/health')
class HealthController {
  constructor(@Inject(HealthService) private readonly health: HealthService) {}

  @Get('/live')
  live() {
    return { ok: true };
  }

  @Get('/ready')
  async ready(@Response() reply: HttpResponse) {
    const result = await this.health.check();
    (reply as any).status(result.healthy ? 200 : 503);
    return result;
  }
}

Liveness should be cheap and dependency-free — if the process is up, return 200. Readiness aggregates indicators that gate traffic: DB connectivity, Redis ping, queue subscriber state. Contribute custom indicators by adding a class to HealthIndicatorsPool:

typescript
@Injectable()
class MigrationsHealthIndicator implements HealthIndicator {
  readonly name = 'migrations';
  constructor(
    @Inject(MigrationRunner) private readonly runner: MigrationRunner,
  ) {}
  async check(): Promise<HealthStatus> {
    const pending = await this.runner.pending();
    return {
      healthy: pending.length === 0,
      details: { pending: pending.length },
    };
  }
}

@modularityjs/redis-health ships a ready-made redis indicator; wire it whenever you use a *-redis driver.

See Health.

A production-shaped wiring

typescript
modules: [
  ModularityModule,

  // Tracing first — telemetry extensions read TracerService
  TelemetryModule.forRoot({ serviceName: 'orders-api' }),
  TelemetryOtelModule.forRoot({ traceExporter }),

  // Logging — console for stdout, file for retention, OTel extension for events
  LoggerModule,
  LoggerConsoleModule,
  LoggerFileModule.forAll({ path: '/var/log/app.log', minLevel: 'info' }),
  LoggerTelemetryModule,

  // Redis-backed infra + their telemetry extensions
  RedisModule.forRoot({ host: process.env.REDIS_HOST! }),
  CacheModule, CacheRedisModule, CacheTelemetryModule,
  LockModule,  LockRedisModule,  LockTelemetryModule,
  QueueModule, QueueRedisModule, QueueTelemetryModule,
  HttpModule.forRoot({ port: 8080 }),
  HttpFastifyModule,
  HttpTelemetryModule,

  // Health
  HealthModule,
  RedisHealthModule,

  // Your app
  AppModule,
],

Next Steps