Published
11/21/2023
Categories
Software
Tags
NestJS

How to Add a Winston Logger to your NestJS Project that Saves Logs to the Database

Image on how to add a Winston Logger to your Nestjs project that saves logs to the database. This is showcasing the localhost:3000/api after executing the GET /test route.

In a recent project I spent a considerable amount of time setting up my NestJs Project to use Winston Logger. This should have been a fairly straightforward process according to the documentation, but I found the documentation was lacking an important (to me) implementation detail: how to get your NestJS / Winston logs written to the database.

Sure it's all well and good to have log files, but when it comes time to sort, filter, or otherwise prettify them, you want a bit more flexibility than a log file.

So I decided to reproduce a simplified implementation of my logger and study how it works to satisfy my own curiosity, as well as provide a bit of help for future me or anyone else who might be struggling with a similar issue.

This article will move a little fast on the setup so we can get to meat and potatoes a little quicker. I'm assuming you already have some comfort with NestJs, TypeORM, and nestjsx/crud or @rewiko/crud

Setting up your Winston Logger Project

First we'll need to install Nest CLI globally if it isn't already.

npm install -g @nestjs/cli

Now lets make our project directory and cd into it like this:

mkdir winstonLogger cd winstonLogger

Then, we'll create the NestJS project for backend API:

nest new winston-logger-project

Use npm as the preferred package manager and wait for install cd into the directory like this:

cd winston-logger-project

Use REST API if prompted.

But first let's set that that up so we can make our database connection:

Let's use TypeORM as our ORM and SQLite. We will also use Swagger to easily interact with our API. First we'll need to install the necessary packages:

npm i typeorm npm i @nestjs/[email protected] npm i sqlite3 npm i @nestjs/config npm i @nestjs/swagger swagger-ui-express

First we will install the sqlite3 package as well as the @nestjs/config package. This package will allow us to use dotenv to grab environment variables from a .env file as well as create a ConfigModule to pass into our application.

Now we can create a .env in the root of our seeder-api directory:

// winston-logger-project/.env DB_TYPE=sqlite DB_NAME=data/api.sqlite DB_SYNCHRONIZE=true DB_LOGGING=true

Lets create a configuration file that we can pass into the ConfigModule:

// winston-logger-project/src/config/configuration.ts export default () => ({   database: {     type: process.env.DB_TYPE,     database: process.env.DB_NAME,     synchronize: process.env.DB_SYNCHRONIZE === 'true',     logging: process.env.DB_LOGGING === 'true',     host: process.env.DB_HOST || null,     port: process.env.DB_PORT || null,     username: process.env.DB_USER || null,     password: process.env.DB_PASSWORD || null,     entities: ['dist/**/*.entity{.ts,.js}'],   }, });

Update app.module.ts to use our new configuration as well as ConfigModule and TypeOrmModule. Don't worry if your linter complains about the LogsModule, we'll create that in a minute:

// winston-logger-project/src/app.module.ts    import { Module } from '@nestjs/common';  import { AppController } from './app.controller';  import { AppService } from './app.service';  import { ConfigModule, ConfigService } from "@nestjs/config";  import { TypeOrmModule } from "@nestjs/typeorm";  import { LogsModule } from "./logs/logs.module";  import configuration from './config/configuration';    @Module({    imports: [      ConfigModule.forRoot({        envFilePath: ['.env.local', '.env.dev', '.env.prod', '.env'],        load: [configuration],        isGlobal: true,      }),      TypeOrmModule.forRootAsync({        imports: [ConfigModule],        useFactory: async (config: ConfigService) => config.get('database'),        inject: [ConfigService],      }),     LogsModule,   ],    controllers: [AppController],    providers: [AppService],  })  export class AppModule {  }

OK cool! Now let's implement CRUD that will take requests and operate on Logs. I like to use @rewiko/crud because it helps automate the basics.

npm i @rewiko/[email protected] @rewiko/crud 

Next lets create our Logs resource with TypeORM:

In Nest, we create “Resources” to organize our code. `g` here stands for "generate", `res` stands for "resource." You can use whichever version you prefer.

nest generate resource logs --no-spec

OR nest g res logs --no-spec

We're using the `--no-spec` flag to skip creating test files

Select REST API for the transport layer like this:

❯ REST API   GraphQL (code first)   GraphQL (schema first)   Microservice (non-HTTP)   WebSockets 

And Y to Would you like to generate CRUD entry points? (Y/n) 

Let’s set up the log entity now. Start with this:

// winston-logger-project/src/logs/entities/log.entity.ts import { Entity, PrimaryGeneratedColumn, Column} from 'typeorm'    @Entity()  export class Log {      @PrimaryGeneratedColumn()      id: number;        @Column()      level: string;  @Column()      message: string; }

And the Controller, Service, and Module:

// winston-logger-project/src/logs/logs.service.ts import { Injectable } from '@nestjs/common';  import { Repository } from 'typeorm';  import { Log } from './entities/log.entity';  import { TypeOrmCrudService } from '@rewiko/crud-typeorm';  import { InjectRepository } from '@nestjs/typeorm';    @Injectable()  export class LogsService extends TypeOrmCrudService<Log> {    constructor(      @InjectRepository(Log)      public repo: Repository<Log>,    ) {      super(repo);    }  }

// winston-logger-project/src/logs/logs.controller.ts import { Controller } from '@nestjs/common';  import { LogsService } from './logs.service';  import { Crud, CrudController } from '@rewiko/crud';  import { Log } from './entities/log.entity';    @Crud({    model: {      type: Log,    },  })  @Controller('logs')  export class LogsController implements CrudController<Log> {    constructor(public service: LogsService) {}  }

// winston-logger-project/src/logs/logs.module.ts import { Module } from '@nestjs/common';  import { LogsService } from './logs.service';  import { LogsController } from './logs.controller';  import { TypeOrmModule } from '@nestjs/typeorm';  import { Log } from './entities/log.entity';    @Module({    imports: [TypeOrmModule.forFeature([Log])],    controllers: [LogsController],    providers: [LogsService],   exports: [LogsService], // <--- we need this exported so we can use it later with our Logger })  export class LogsModule {}

Adding Logger Swagger

Start with this:

// winston-logger-project/src/main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; async function bootstrap() {     const app = await NestFactory.create(AppModule);     const config = new DocumentBuilder()         .setTitle('Winston Logger API')         .setDescription(             'The Winston Logger API lets easily query the database',         )         .setVersion('1.0')         .build();     const document = SwaggerModule.createDocument(app, config);     SwaggerModule.setup('api', app, document);     await app.listen(3000); } bootstrap();

If all was set up correctly we should be able to run the app with npm run start:dev

How to Add Winston

npm install nest-winston@^1.9.4 winston-transport@^4.5.0

Here's the link!

There are many ways to implement Winston within Nest, but this is what ultimately allowed me to be able to have my Winston Logger save logs to the database.

We are going to create a LoggerModule that will be responsible for setting up the Winston instance.

First let's create the Winston Config. Don't worry about the DatabaseTransportWrapper in the constructor, we'll get to that in a minute.

The customFormat is setting up how the logs will be formatted and logged to the console. This current implementation for instance will render a message that looks something like this:

2023-10-14T22:17:00.651Z - [INFO] - AppController {/}: - CONTEXT: RoutesResolver

// winston-logger-project/src/logger/winston.config.ts import { createLogger, format, transports } from 'winston';  import { DatabaseTransportWrapper } from "./database-transport-wrapper.service";    export class WinstonConfig {    constructor(private customTransportWrapper: DatabaseTransportWrapper) {}      createLogger() {      const customFormat = format.printf(        ({ timestamp, level, stack, message, context }) => {          return (            `${timestamp} - [${level.toUpperCase()}] - ${stack || message} ` +            (context ? `- CONTEXT: ${context}` : '')          );        },      );   

    const options = {        file: {          filename: 'error.log',          level: 'error',        },        console: {          level: 'silly',        },      };        const devLogger = {        level: 'silly',        format: format.combine(          format.timestamp(),          format.errors({ stack: true }),          customFormat,        ),        transports: [          new transports.Console(options.console),          this.customTransportWrapper.transport,        ],      };          const instanceLogger = devLogger;        return createLogger(instanceLogger);    }  }

Winston uses Transports to handle what log levels go where. 

// winston-logger-project/src/logger/logs.transport.ts import TransportStream = require('winston-transport');  import { LogsService } from '../logs/logs.service';    class DatabaseTransport extends TransportStream {    constructor(      private logsService: LogsService,      opts?: TransportStream.TransportStreamOptions,    ) {      super(opts);    }      log(info: any, callback: () => void): any {      setImmediate(() => this.emit('logged', info));      this.saveLogToDatabase(info);      callback();    }      private saveLogToDatabase(info: any) {     // In the interest of keeping this tutorial simple I am not using/logging these, but I am providing a way to retrieve this data so you can implement that on your own.     let trace = null;     let context = null;     const splat = info[Symbol.for('splat')];     if (info.level === 'error' && splat && splat?.length > 0) {       trace = splat[0]?.trace;       context = splat[0]?.context;     } else {       context = info?.context;     }     this.logsService.repo.save({       level: info.level,       message: info.message,     });   } }    export default DatabaseTransport;

In order for this transport to be able to have access to the LogsService, we need to inject it. This DatabaseTransportWrapper will help us with that injection.

// winston-logger-project/src/logger/database-transport-wrapper.service.ts import { Injectable } from '@nestjs/common';  import { LogsService } from '../logs/logs.service';  import DatabaseTransport from './logs.transport';    @Injectable()  export class DatabaseTransportWrapper {    transport: DatabaseTransport;      constructor(private logsService: LogsService) {      this.transport = new DatabaseTransport(logsService);    }  }

And this Custom Logger to translate nestJs LoggerService logs to Winston

//winston-logger-project/src/logger/nestToWinstonLogger.service.ts import { Injectable } from '@nestjs/common';  import { LoggerService } from '@nestjs/common';  import { Logger as WinstonLogger } from 'winston';    @Injectable()  export class CustomLogger implements LoggerService {    constructor(private readonly winstonLogger: WinstonLogger) {}      log(      message: any,      context?: string,    ) {      this.winstonLogger.info(message, {context});    }      error(      message: any,      stack?: string,      context?: string,    ) {      this.winstonLogger.error(message, {        context,        stack,      });    }      warn(message: any, context?: string) {      this.winstonLogger.warn(message, { context });    }      debug(      message: any,      context?: string,    ) {      this.winstonLogger.debug(message, {        context,      });    }      verbose(message: any, context?: string, payload?: string) {      this.winstonLogger.verbose(message, { context, payload });    }  }

Finally let's put it all together in the LoggerModule.

// winston-logger-project/src/logger/logger.module.ts import { Module, Global } from '@nestjs/common'; import { DatabaseTransportWrapper } from './database-transport-wrapper.service'; import { LogsModule } from '../logs/logs.module'; import { CustomLogger } from './nestToWinstonLogger.service'; import { WinstonConfig } from './winston.config'; @Global() @Module({   imports: [LogsModule],   providers: [     DatabaseTransportWrapper,     {       provide: 'WINSTON',       useFactory: (customTransportWrapper: DatabaseTransportWrapper) => {         const winstonConfig = new WinstonConfig(customTransportWrapper);         return winstonConfig.createLogger();       },       inject: [DatabaseTransportWrapper],     },     {       provide: CustomLogger,       useFactory: (winston) => new CustomLogger(winston),       inject: ['WINSTON'],     },   ],   exports: ['WINSTON', CustomLogger ], }) export class LoggerModule {}

In this module:

  • We have decorated it as a @Global() to make it available throughout the app.

  • We imported the LogsModule in order to use the exported LogsService.

  • We created a list of providers:

    • the DatabaseTransportWrapper we created.

    • a provider where we inject that wrapper and used it in the useFactory in order to create an instance of the Winston Logger that can call the LogsService methods and is provided as a token called 'WINSTON' to the array.

    • a provider where we inject the 'WINSTON' token and use that to create our CustomLogger which is then provided to the array.

  • Finally, we export 'Winston' and CustomLogger

Get LoggerModule Running

Add the LoggerModule and DatabaseTransport to our AppModule:

// winston-logger-project/src/app.module.ts    import { Module } from '@nestjs/common';  import { AppController } from './app.controller';  import { AppService } from './app.service';  import { ConfigModule, ConfigService } from "@nestjs/config";  import { TypeOrmModule } from "@nestjs/typeorm";  import { LogsModule } from "./logs/logs.module";  import configuration from './config/configuration';  import {LoggerModule} from "./logger/logger.module";  import DatabaseTransport from "./logger/logs.transport";    @Module({    imports: [      ConfigModule.forRoot({        envFilePath: ['.env.local', '.env.dev', '.env.prod', '.env'],        load: [configuration],        isGlobal: true,      }),      TypeOrmModule.forRootAsync({        imports: [ConfigModule],        useFactory: async (config: ConfigService) => config.get('database'),        inject: [ConfigService],      }),      LogsModule,      LoggerModule,    ],    controllers: [AppController],    providers: [AppService, DatabaseTransport],  })  export class AppModule {  }

Now all that is left is to update our main.ts to tell it to use this Logger with the app.get(CustomLogger)  and app.useLogger(customLogger)

import { NestFactory } from '@nestjs/core';  import { AppModule } from './app.module';  import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';  import { CustomLogger } from "./logger/nestToWinstonLogger.service";    async function bootstrap() {    const app = await NestFactory.create(AppModule);    const customLogger = app.get(CustomLogger);    app.useLogger(customLogger);      const config = new DocumentBuilder()      .setTitle('Winston Logger API')      .setDescription('The Winston Logger API lets easily query the database')      .setVersion('1.0')      .build();    const document = SwaggerModule.createDocument(app, config);    SwaggerModule.setup('api', app, document);      await app.listen(3000);  }    bootstrap();

Now run npm run start:dev and look at the console output. You'll see our custom Winston logs:

2023-10-14T22:17:00.651Z - [INFO] - AppController {/}: - CONTEXT: RoutesResolver 2023-10-14T22:17:00.652Z - [INFO] - Mapped {/, GET} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.653Z - [INFO] - LogsController {/logs}: - CONTEXT: RoutesResolver 2023-10-14T22:17:00.653Z - [INFO] - Mapped {/logs/:id, GET} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.653Z - [INFO] - Mapped {/logs, GET} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.653Z - [INFO] - Mapped {/logs, POST} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.653Z - [INFO] - Mapped {/logs/bulk, POST} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.654Z - [INFO] - Mapped {/logs/:id, PATCH} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.654Z - [INFO] - Mapped {/logs/:id, PUT} route - CONTEXT: RouterExplorer 2023-10-14T22:17:00.654Z - [INFO] - Mapped {/logs/:id, DELETE} route - CONTEXT: RouterExplorer

You may also see our logs being inserted into the database:

query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/, GET} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","LogsController {/logs}:"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs/:id, GET} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs, GET} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs, POST} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs/bulk, POST} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs/:id, PATCH} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs/:id, PUT} route"] query: INSERT INTO "log"("id", "level", "message") VALUES (NULL, ?, ?) -- PARAMETERS: ["info","Mapped {/logs/:id, DELETE} route"]

Putting Winston Logger all Together

Okay, so what if we want to log things explicitly? Let's set up a test route on our controller that we can use to trigger some logs. Head over to your logs resource and update the LogsController to add a GET /test route. 

// winston-logger-project/src/logs/logs.controller.ts import {Controller, Get } from '@nestjs/common';  import { LogsService } from './logs.service';  import { Crud, CrudController } from '@rewiko/crud';  import { Log } from './entities/log.entity';  import { CustomLogger } from "../logger/nestToWinstonLogger.service";    @Crud({    model: {      type: Log,    },  })  @Controller('logs')  export class LogsController implements CrudController<Log> {    constructor(        public service: LogsService,        public logger: CustomLogger,    ) {}      @Get('test')    getTestLogs() {      this.logger.debug(`Test run!`);      this.logger.log('Test run! of level info');      try {        let lala;        console.log(lala.doesntExist);      } catch (e) {        this.logger.error(            `${e.name}: ${e.message}`,         );      }      this.logger.warn('This is a warning message!');    }  }

Go to your swagger interface (localhost:3000/api) and find the GET logs/test route. You may need to refresh your page for this to appear. Click "Try it out" and then click "Execute."

Image on how to add a Winston Logger to your Nestjs project that saves logs to the database. This is showcasing the localhost:3000/api after executing the GET /test route.

You'll probably see these various logs we just created execute in your app's console output. 

Now scroll down to where you see GET /logs and click "Try it out" and scroll down in this expanded view and click "Execute."

If we did everything right, you should see a result with all of your logs. Scroll down to the bottom of this list and there should be the following output. (Your Ids may not match, that's okay.)

[   // ...other logs,   {     "id": 57,     "level": "info",     "message": "Test run! of level info"   },   {     "id": 58,     "level": "warn",     "message": "This is a warning message!"   },   {     "id": 59,     "level": "error",     "message": "TypeError: Cannot read properties of undefined (reading 'doesntExist')"   },   {     "id": 60,     "level": "debug",     "message": "Test run!"   }, ]

There you have it! Winston is now set up to log to your database! Happy Logging!