1.1.1 • Published 3 months ago

@showroomprive/observability v1.1.1

Weekly downloads
-
License
ISC
Repository
-
Last release
3 months ago

@showroomprive/observability

A library to configure observability and health checks for a NestJS application. It provides tools for:

  • Auto-instrumentation in Node.js applications.
  • Manual instrumentation for logs, traces, and metrics.
  • Health check endpoints to monitor application health.
  • Trace context propagation for distributed tracing across services and message queues.

The library is built using OpenTelemetry and introduces three health probe endpoints:

  • /health/live: Liveness probe
  • /health/ready: Readiness probe
  • /health/startup: Startup probe

Note: These endpoints are not exposed in Swagger.

Table of Contents

Requirements

To use this library, the following requirements need to be met:

Library NameVersionDescription
@nestjs/common^11.0.11Common utilities used by NestJS applications.
@nestjs/config^4.0.1A module for managing application configuration in NestJS.
@nestjs/core^11.0.11The core package of the NestJS framework.
rxjs^7.8.2A library for reactive programming using Observables, to make it easier to compose asynchronous or callback-based code.
@nestjs/terminus^11.0.0A module for adding health checks and readiness/liveness probes to NestJS applications.

An OpenTelemetry collector is needed to view the traces, logs, metrics if we don't use the console exporter.

For example, the Aspire dashboard can be used.

To launch it in a container, we can use the following docker command :

docker run --rm -it -p 18888:18888 -p 4317:18889 -d --name aspire-dashboard mcr.microsoft.com/dotnet/aspire-dashboard:9.0

And use the link inside the logs of the container to access the dashboard.

Dependencies

DependencyVersion
@nestjs/axios^4.0.0
@nestjs/common^11.0.11
@nestjs/core^11.0.11
@nestjs/swagger^11.0.6
@nestjs/terminus^11.0.0
@opentelemetry/api-logs^0.57.2
@opentelemetry/auto-instrumentations-node^0.56.1
@opentelemetry/exporter-logs-otlp-grpc^0.57.2
@opentelemetry/exporter-metrics-otlp-grpc^0.57.2
@opentelemetry/exporter-trace-otlp-grpc^0.57.2
@opentelemetry/instrumentation-express^0.47.1
@opentelemetry/instrumentation-http^0.57.2
@opentelemetry/instrumentation-nestjs-core^0.44.1
@opentelemetry/resources^1.30.1
@opentelemetry/sdk-logs^0.57.2
@opentelemetry/sdk-metrics^1.30.1
@opentelemetry/sdk-node^0.57.2
@opentelemetry/sdk-trace-base^1.30.1
@opentelemetry/semantic-conventions^1.30.0
@opentelemetry/exporter-logs-otlp-proto^0.57.2
@opentelemetry/exporter-metrics-otlp-proto^0.57.2
@opentelemetry/exporter-trace-otlp-proto^0.57.2
axios^1.8.1
dotenv^16.4.7
rxjs^7.8.2
koa^2.16.0

Dev Dependencies

DependencyVersion
@types/node^22.13.9
typescript^5.8.2
@types/koa^2.15.0

Peer Dependencies

DependencyVersion
@nestjs/common^11.0.11
@nestjs/core^11.0.11
rxjs^7.8.2
@nestjs/terminus^11.0.0

Installation

To install the package, run:

npm install @showroomprive/observability

Configuration

To use the library, several environment variables need to be provided in a .env file at the root:

Variable NameDescriptionMandatoryDefault Value
OBSERVABILITY_COLLECTOR_URLThe URL of the observability collector.Yes-
OBSERVABILITY_TRACES_EXPORT_CONSOLEEnable console export for traces, if true disable other exporters.Nofalse
OBSERVABILITY_METRICS_EXPORT_CONSOLEEnable console export for metrics, if true disable other exporters.Nofalse
OBSERVABILITY_LOGGING_EXPORT_CONSOLEEnable console export for logging, if true disable other exporters.Nofalse
OBSERVABILITY_SIMPLE_LOGGING_CONSOLEEnable console log for the message (no exporter used, works like consol.log).Nofalse
OBSERVABILITY_SERVICE_NAMEThe name of the service that is display in the observability tool.Yes-
OBSERVABILITY_INDEX_SUFFIXA filter criterious to group data between several applicationsNoValue of OBSERVABILITY_SERVICE_NAME variable
ENVIRONMENTThe current environment.Yes-
ENABLE_DIAGNOSTICSEnable diagnostics.Nofalse
GRPCEnable gRPC, if false the http/protobuf procol will be use with the collector URLNotrue
OBSERVABILITY_HEADERSHeaders for observability calls.No-
ENABLE_DEBUG_LOGGINGEnable sending debug logging to exporterNofalse

There is a example for an aspire dashbord run on the local machine (http://localhost:4317):

OBSERVABILITY_COLLECTOR_URL=http://localhost:4317
OBSERVABILITY_SERVICE_NAME=observability.sample.nestjs
ENVIRONMENT=local

A change need to be done on the main.ts file to configure the application to use observability.

Before creating the modules, we need to initialize the observability, by adding the following line:

initializeObservability();

Inside the file :

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { initializeObservability } from '@showroomprive/observability/dist/tracing';

async function bootstrap() {
  initializeObservability();
  
  const app = await NestFactory.create(AppModule);
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

This ensure the sdk is well configured before the bootstrap of the application.

For the health checks we can create custom HealthIndicators, the library allows adding custom HealthIndicators on the 2 probes : startup and ready.

We need to create a class that is inherited by HealthIndicatorBase and this classe should be injectable.

There is a sample class:

import { Injectable } from '@nestjs/common';
import { HealthCheckError, HealthIndicatorResult } from '@nestjs/terminus';
import { HealthIndicatorBase } from '@showroomprive/observability/dist/health/health-indicator-base';

@Injectable()
export class TestHealthIndicator extends HealthIndicatorBase {
  public async isHealthy(key: string): Promise<HealthIndicatorResult> {
    const isHealthy = true; // Add custom logic here
    const result = this.getStatus(key, isHealthy);
    if (isHealthy) {
      return result;
    }
    throw new HealthCheckError('Health check failed', result.error);
  }
}

The isHealthy value in this example is true but you can replace it with a function that do the check you wantthe logic you want to test.

You can create several classes like this.

After creating these classes we need to register them, and this should be done in app.module.ts :

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HealthModule } from '@showroomprive/observability';
import { TestHealthIndicator1 } from './healthIndicators/test1-health-indicator';
import { TestHealthIndicator2 } from './healthIndicators/test2-health-indicator';

@Module({
  imports: [
    HealthModule.register({
      ready: [
        { name: "test1", indicator: Test1HealthIndicator},
      ],
      startup: [
        { name: "test2", indicator: Test2HealthIndicator},
      ],
    })
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

In this sample we can register the ready health indicators and the startup ones, we need to give HealthModule an object that contains 2 properties: ready and startup, each of them are arrays and the name property is the name that will be display when we visit the routes: /health/ready, /health/startup for the indicator indicator, the indicator property should reference the class that inherits HealthIndicatorBase.

Note: We log the error occured in the probes using the configuration described previously.

Usage

The probes will be reachable on :

  • /health/live
  • /health/ready
  • /health/startup

To create Logs, Traces, Metrics, we can use the Logger, Tracer, Meter availlable by the library.

To create a log :

import { Logger } from '@showroomprive/observability/dist/tracing';

Logger.info('Hello world', { hello: 'world' });

The second parameter is the custom columns we want to add to our log.

To create a metric :

import { Meter } from '@showroomprive/observability/dist/tracing';

Meter().createCounter('customCounter', { description: 'A custom counter' }).add(1);

To create a trace:

import { Tracer } from '@showroomprive/observability/dist/tracing';
Tracer().startActiveSpan('customTrace_number1', { attributes: { hello: "OK"}}, (span) => {
      Logger.info('Log from Custom trace 1');
      span.end();      
    });

There is an example of a controller that have logs, trace, metrics that we saw:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { Logger, Meter, Tracer } from '@showroomprive/observability/dist/tracing';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get("Hello")
  getHello(): string {
    Meter().createCounter('customCounter', { description: 'A custom counter' }).add(1);
    
    Tracer().startActiveSpan('customTrace_number1', { attributes: { hello: "OK"}}, (span) => {
      Logger.info('Log from Custom trace 1');
      span.end();      
    });
    Logger.info('Hello world', { hello: 'world' });

    return Tracer().startActiveSpan('customTrace_number2', { attributes: { action: "service call"}}, (span) => {
      Logger.info('Log from Custom trace 2');
      span.end();
      return result;
    });
  }
}

Auto-Instrumentation

There are some auto instrumentation that is enabled by default :

  • Node.js
  • Express
  • NestJS

We plan to add more in the future.

Trace Context Propagation

The library supports trace context propagation for distributed tracing across services and message queues. This ensures that traces are correctly linked across different parts of your system, providing a complete view of the request flow.

sendMessageWithTrace

Sends a message with trace context information embedded in the message properties:

import { ServiceBusClient } from '@azure/service-bus';
import { sendMessageWithTrace, Logger } from '@showroomprive/observability/dist/tracing';

// In your controller/service:
const sbClient = new ServiceBusClient(connectionString);
const sender = sbClient.createSender(queueName);

try {
    // Send a message with trace context
    await sendMessageWithTrace(
        async (msg) => await sender.sendMessages(msg),
        { name: 'payload-example' }
    );
    
    Logger.info('Message sent with trace');
} finally {
    await sbClient.close();
}

executeWithTraceContext

Execute code within a trace context extracted from message properties:

import { executeWithTraceContext, Logger, Tracer } from '@showroomprive/observability/dist/tracing';
import { InvocationContext } from "@azure/functions";

export async function azureFunctionHandler(queueItem: unknown, invocationContext: InvocationContext): Promise<void> {
    // Execute code while preserving the trace context from the original message
    await executeWithTraceContext(invocationContext.triggerMetadata, async () => {
        Tracer().startActiveSpan('processing-message', span => {
            Logger.info('Processing queue item', { item: JSON.stringify(queueItem) });
            // Message processing logic
            span.end();
        });
    });
}

TraceContext Class

Object-oriented approach to work with trace context:

import { TraceContext, Logger, Meter, Tracer } from '@showroomprive/observability/dist/tracing';
import { InvocationContext } from "@azure/functions";

export async function azureFunctionHandler(queueItem: unknown, invocationContext: InvocationContext): Promise<void> {
    const context = new TraceContext(invocationContext.triggerMetadata);
    
    await context.with(async () => {
        // All code here will inherit the trace context from the original message
        Meter().createCounter("message_processed", { description: "Count of processed messages" }).add(1);
        
        Tracer().startActiveSpan('processing-message', span => {
            Logger.info('Processing message', { messageId: JSON.stringify(queueItem) });
            // Processing logic
            span.end();
        });
    });
}

Automatic Log and Trace Flushing

The library automatically registers handlers to flush all telemetry data before shutdown:

  • On normal process exit
  • On SIGINT (Ctrl+C)
  • On SIGTERM (Docker, Kubernetes termination signals)

This ensures that all telemetry data is properly sent before the application terminates.

Koa.js support

There is a new middleware that is added for koa.js:

import Koa from 'koa';
import { createKoaTelemetryMiddleware } from '@showroomprive/observability/dist/Instrumentations/koa.instrumentation';
import { initializeObservability } from '@showroomprive/observability/dist/tracing';
import dotenv from 'dotenv'

dotenv.config();

initializeObservability();

const app = new Koa();
app.use(createKoaTelemetryMiddleware());

initializeObservability();

app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});