Introduction

Hello, this is both for future documentation for myself and to help others set up an observability integration for a NestJS application.

Observability

Being able to observe your system gives you confidence, because you’ll be able to explain what happened and why it happened. Not to mention, some regulations require you to have some form of observability in your system.

With observability integrated, you give your system a voice, it speaks back to you, telling the story of your users’ journey within the system, rather than acting like a mute “Trust me bro, I know what I’m doing” kind of system. With much less effort, you’ll know what went wrong and why.

Software errors are the best kind of bad in software development. Despite their scary or frustrating nature, they are actually quite helpful. They tell you exactly where and what went wrong, and 99% of the time you’ll know right away when something breaks. But not all errors make their presence known like logical errors. Some are hidden, with many names, commonly called edge cases or semantic errors, and they only get named after being discovered through one of the following:

  • Users (common)
  • Tests & QA
  • Careful observation of the business logic implementation (rare)

When these kinds of errors occur, your first instinct to resolve them is usually listening to the user’s or QA’s story, understanding the code execution path (which might be untouched for a while), and applying a fix to handle it.

With observability, you’re in control, your system reports its actions at each step rather than only reporting the outcomes.

Getting Started

We’ll integrate an external data source that listens to our app logs and reports them back to us in a filterable, sortable format so we can consume them more effectively.

Tech stack

We’ll be using the following technologies for our observability integration:

  • NestJS
  • Grafana for dashboard and visualization
  • Loki as the data source to store and query our application logs
  • Promtail to deliver logs from log files to Loki
  • Winston, which manages our log files and delivers them from the application to the log files in the specified format
  • Docker to containerize everything and get things up with less effort

Pino is also a popular library that serves a similar purpose to Winston. I found Winston to be more useful for my use case and more intuitive.

Also note that Promtail is in its LTS stage and will no longer be maintained after February 2026. I’ll update the article in the future to use Grafana Alloy. The conversion seems easy, so we should be good to go for now.

Configuration files

Before you start, make sure to set the environment variable GRAFANA_ADMIN_PASSWORD="passwordToLoginToGrafanaAtPort3005". Port 3005 is my choice; the default is 3000, but it is configured as 3005 in the config file. Once Grafana is set up, you’ll be able to view and query logs through the dashboard or the explore section for ad-hoc queries, and you’ll need to use the password when logging into it.

The following folder structure is suggested, and the Docker Compose file in the repo will assume this is the path to access them.

observability/
├── loki-config.yml
├── promtail-config.yml
└── grafana/
   ├── dashboards/
   │   └── nestjs-dashboards.json
   └── provisioning/
       ├── dashboards/
       │   └── dashboards.yml
       └── datasources/
           └── loki.yml

You can find all the configuration files below:

https://github.com/ertankara/observability-config-files/

Make sure your app container shares the same network with the rest of the observability stack.

Getting up & running

Once you set up all the configuration files in the described locations, you should be ready to go after running the following command:

docker compose up -d –build

I have some personal choices for port selection; for example, Grafana by default runs on port 3000, but I picked different ports on my local machine. You can change them according to your requirements or preferences. For the rest of the article, if I refer to these ports, I’ll assume they are configured as described above.

Implementation

Now that we have every component in place, we need to make the data flow to our dashboard. Next, we need to implement the application side of it.

Before we do that, we need to install the following packages:

  • nest-winston
  • winston
  • uuid (not strictly required, but helps improve the semantic meaning of our logs)

logger.module.ts

Here we set up our module and initialize the Winston module.

import { Module, Global } from '@nestjs/common';
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import { CustomLoggerService } from './custom-logger.service';
import * as path from 'path';
import * as fs from 'fs';
import { CorrelationContextService } from './correlation-context.service';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { CorrelationInterceptor } from './correlation.interceptor';

// Ensure logs directory exists
const logsDir = path.join(process.cwd(), 'logs');
if (!fs.existsSync(logsDir)) {
  fs.mkdirSync(logsDir, { recursive: true });
}

@Global()
@Module({
  imports: [
    WinstonModule.forRoot({
      transports: [
        // Console transport for development
        new winston.transports.Console({
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.colorize(),
            winston.format.printf(
              ({ timestamp, level, message, context, ...meta }) => {
                return `[${timestamp}] [${level}] [${context || 'Application'}] ${message} ${
                  Object.keys(meta).length ? JSON.stringify(meta) : ''
                }`;
              },
            ),
          ),
        }),

        // File transport for structured JSON logs (Loki-friendly)
        new winston.transports.File({
          filename: path.join(logsDir, 'application.log'),
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.errors({ stack: true }),
            winston.format.json(),
          ),
          maxsize: 10485760,
          maxFiles: 20,
        }),

        // Separate error log file
        new winston.transports.File({
          filename: path.join(logsDir, 'error.log'),
          level: 'error',
          format: winston.format.combine(
            winston.format.timestamp(),
            winston.format.errors({ stack: true }),
            winston.format.json(),
          ),
          maxsize: 10485760,
          maxFiles: 20,
        }),
      ],
    }),
  ],
  exports: [CustomLoggerService],
  providers: [
    CustomLoggerService,
    CorrelationContextService,
    {
      provide: APP_INTERCEPTOR,
      useClass: CorrelationInterceptor,
    },
  ],
})
export class LoggerModule {}

correlation.interceptor.ts

This interceptor creates a request-scoped context that is accessible throughout the execution of a request, from the initial hit to the global interceptor we registered, and until the response. This context helps us correlate logs that share the same context, allowing us to track the request’s journey across the services it executed.

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { CorrelationContextService } from './correlation-context.service';

@Injectable()
export class CorrelationInterceptor implements NestInterceptor {
  constructor(private readonly correlationService: CorrelationContextService) {}

  public intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<any> {
    return new Observable((subscriber) => {
      this.correlationService.runWithCorrelation(() => {
        next.handle().subscribe(subscriber);
      });
    });
  }
}

correlation-context.service.ts

This service allows us to access correlation tracking between requests. cid stands for correlation id. We specifically configured Promtail to index this field in our JSON logs, making querying more efficient. I chose UUID v7 because it is more indexing-friendly.

seq stands for sequence number and serves two purposes: sometimes time precision is not enough, which can cause the order of logs to be unclear (although more precise timestamps are possible, I avoided them to reduce overhead). The sequence number helps us better identify which action happened first. Once you have the correlation id, you can quickly see how many steps were involved in that action at a glance. Instead of manually extracting this information, our logs will be documented as if they are telling the story of the execution.

import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
import { v7 as uuidv7 } from 'uuid';

interface CorrelationContext {
  cid: string;
  seq: number;
}

@Injectable()
export class CorrelationContextService {
  private static asyncLocalStorage =
    new AsyncLocalStorage<CorrelationContext>();

  // Run callback with correlation context
  public runWithCorrelation<T>(callback: () => T): T {
    const context: CorrelationContext = {
      cid: uuidv7(),
      seq: 1,
    };

    return CorrelationContextService.asyncLocalStorage.run(context, callback);
  }

  public getCorrelationId(): { cid: string; seq: number } | null {
    const context = CorrelationContextService.asyncLocalStorage.getStore();
    if (!context) return null;

    return {
      cid: context.cid,
      seq: context.seq++,
    };
  }

  public getCurrentCorrelationId(): string | null {
    const context = CorrelationContextService.asyncLocalStorage.getStore();
    return context?.cid || null;
  }
}

custom-logger.service.ts

Here we are using a specialized Logger that communicates with Winston to forward our logs to the configured log files. We’ll be injecting this service to log our system actions.

import { Injectable, Inject, Logger as NestLogger } from '@nestjs/common';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { CorrelationContextService } from 'src/logger/correlation-context.service';
import { Logger } from 'winston';

interface ExtraLogData {
  [key: string]: unknown;
}

@Injectable()
export class CustomLoggerService extends NestLogger {
  constructor(
    @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
    private readonly correlationCtxService: CorrelationContextService,
  ) {
    super();
  }

  public log(message: any, context?: string, extra?: ExtraLogData) {
    this.logger.info(message, {
      context,
      ...this.correlationCtxService.getCorrelationId(),
      ...extra,
    });
  }

  public error(
    message: any,
    trace?: string,
    context?: string,
    extra?: ExtraLogData,
  ) {
    this.logger.error(message, {
      trace,
      ...this.correlationCtxService.getCorrelationId(),
      context,
      ...extra,
    });
  }

  public warn(message: any, context?: string, extra?: ExtraLogData) {
    this.logger.warn(message, {
      context,
      ...this.correlationCtxService.getCorrelationId(),
      ...extra,
    });
  }

  public debug(message: any, context?: string, extra?: ExtraLogData) {
    this.logger.debug(message, {
      context,
      ...this.correlationCtxService.getCorrelationId(),
      ...extra,
    });
  }

  public verbose(message: any, context?: string, extra?: ExtraLogData) {
    this.logger.verbose(message, {
      context,
      ...this.correlationCtxService.getCorrelationId(),
      ...extra,
    });
  }
}

Conclusion

Now that you have all the functionality in place, logging should be added strategically to reflect the action flow, highlighting the important parts.

@Injectable()
class Auth {
  constructor(
    /* Other injections... */
    private readonly logger: CustomLoggerService
  ) {}

  public async signIn(signInDto: SignInDto, response: Response) {
    try {
      this.logger.log('Sign in attempt', 'sign-in', {
        email: signInDto.email,
      });
  
      const erorrMessage = 'Invalid credentials were provided';
      const userPass = await this.iamRepo.findPasswordByUserEmail(
        signInDto.email,
      );
  
      if (!userPass || !userPass.password) {
        this.logger.warn(
          "Email didn't match with existing accounts",
          'sign-in',
          {
            email: signInDto.email,
          },
        );
  
        throw new UnauthorizedException(erorrMessage);
      }
  
      const isValid = await this.hashingService.compare(
        signInDto.password,
        userPass.password,
      );
  
      this.logger.log('User found', 'sign-in', {
        email: signInDto.email,
        userId: userPass.userId,
      });
  
      if (!isValid) {
        this.logger.warn('Invalid password provided', 'sign-in', {
          email: signInDto.email,
        });
  
        throw new UnauthorizedException(erorrMessage);
      }
  
      this.logger.log('User credentials validated', 'sign-in', {
        email: signInDto.email,
        userId: userPass.userId,
      });
  
      const { token, refreshToken, refreshTokenId } =
        await this.tokenService.generateTokenPair({ sub: userPass.userId! });
  
      this.authCookieService.saveCredentials(response, {
        userId: userPass.userId!,
        refreshTokenId,
        refreshToken,
      });
  
      this.logger.log('Token pair generated for user', 'sign-in', {
        email: signInDto.email,
        userId: userPass.userId,
      });
  
      await this.emailService.sendEmail({
        subject: 'New Sign In to Acme',
        body: this.emailService.renderEmail({
          templateName: 'new-sign-in',
          params: {
            email: signInDto.email,
          },
        }),
        toAddresses: [signInDto.email],
        type: EmailType.GREET,
      });
  
      this.logger.log('Sign in successful', 'sign-in', {
        email: signInDto.email,
        userId: userPass.userId,
      });
  
      return { token, refreshToken, expiresIn: this.config.expiresIn };
    } catch (error) {
      if (error instanceof HttpException) {
        throw error;
      }
  
      this.logger.error(
        'Sign in failed',
        error instanceof Error ? error.stack : '',
        'sign-in',
        {
          email: signInDto.email,
        },
      );
  
      throw new InternalServerErrorException('Unknown error occurred');
    }
  }

  /** And in email service */
  
  // Truncated for demo purposes
  public sendEmail() {
    this.logger.log(`Email sent successfully`, 'send-email', {
      type: payload.type,
      from: email,
      toAddresses: payload.toAddresses,
      subject: payload.subject,
    });
  }
}

Notice how the context (the second argument of the log function) describes the action context. This is useful for observing how context flows throughout the action execution.

Example outputs

Here are some screenshots showing how it looks in Grafana. It allows you to run detailed queries on your logs, by the way.

Successful sign in flow Successful sign in flow

Filtered by a correlation id, you can view the journey of a request. This is also useful for raising support tickets, where the user provides the id and you get more context.

Successful sign in flow Successful sign in flow

Below is an example log of a successful sign-in action.

[
  {
    "timestamp": "1749292305695000000",
    "fields": {
      "cid": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "cid_extracted": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "Sign in successful",
      "seq": "6",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:31:45.695Z",
      "userId": "91497450212163585"
    }
  },
  {
    "timestamp": "1749292305695000000",
    "fields": {
      "cid": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "cid_extracted": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "context": "send-email",
      "context_extracted": "send-email",
      "filename": "/app/logs/application.log",
      "from": "hello@acme.app",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "Email sent successfully",
      "seq": "5",
      "service_name": "acme-be-app",
      "subject": "New Sign In to ACME",
      "timestamp": "2025-06-07T10:31:45.695Z",
      "type": "greet"
    }
  },
  {
    "timestamp": "1749292305135000000",
    "fields": {
      "cid": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "cid_extracted": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "Token pair generated for user",
      "seq": "4",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:31:45.135Z",
      "userId": "91497450212163585"
    }
  },
  {
    "timestamp": "1749292305132000000",
    "fields": {
      "cid": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "cid_extracted": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "User credentials validated",
      "seq": "3",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:31:45.132Z",
      "userId": "91497450212163585"
    }
  },
  {
    "timestamp": "1749292305131000000",
    "fields": {
      "cid": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "cid_extracted": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "User found",
      "seq": "2",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:31:45.131Z",
      "userId": "91497450212163585"
    }
  },
  {
    "timestamp": "1749292305042000000",
    "fields": {
      "cid": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "cid_extracted": "019749f2-4a8d-7469-bcab-d226a096fc72",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "Sign in attempt",
      "seq": "1",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:31:45.042Z"
    }
  }
]

An example log showing when a user provided a password that didn’t match the account.

[
  {
    "timestamp": "1749291963026000000",
    "fields": {
      "cid": "019749ed-124a-7128-a3b4-ffb28f37df99",
      "cid_extracted": "019749ed-124a-7128-a3b4-ffb28f37df99",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "warn",
      "level_extracted": "warn",
      "message": "Invalid password provided",
      "seq": "3",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:26:03.026Z"
    }
  },
  {
    "timestamp": "1749291963025000000",
    "fields": {
      "cid": "019749ed-124a-7128-a3b4-ffb28f37df99",
      "cid_extracted": "019749ed-124a-7128-a3b4-ffb28f37df99",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "User found",
      "seq": "2",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:26:03.025Z",
      "userId": "91497450212163585"
    }
  },
  {
    "timestamp": "1749291962956000000",
    "fields": {
      "cid": "019749ed-124a-7128-a3b4-ffb28f37df99",
      "cid_extracted": "019749ed-124a-7128-a3b4-ffb28f37df99",
      "context": "sign-in",
      "context_extracted": "sign-in",
      "email": "jane@example.com",
      "filename": "/app/logs/application.log",
      "job": "acme-be-app",
      "level": "info",
      "level_extracted": "info",
      "message": "Sign in attempt",
      "seq": "1",
      "service_name": "acme-be-app",
      "timestamp": "2025-06-07T10:26:02.956Z"
    }
  }
]