Skip to content
Article

Winston Logger in NestJS: Save Logs to a Database

Persist NestJS logs to a database with Winston: complete code for the custom transport, TypeORM Log entity, and Logger module — working walkthrough from Endertech engineers.

In a recent project I spent a considerable amount of time setting up my NestJS project to use Winston Logger. The documentation covered the basics, but it missed an implementation detail that mattered here: how to save NestJS and Winston logs to a database.

Log files help, but database-backed logs are easier to sort, filter, and work with. This walkthrough builds a simplified logger implementation that writes logs to a database.

This article moves quickly through setup so it can focus on the logger. It assumes you already have some familiarity with NestJS, TypeORM, and @rewiko/crud.

Setting up your Winston Logger project

Install the Nest CLI globally if it is not already installed:

npm install -g @nestjs/cli

Create the project directory and enter it:

mkdir winstonLogger
cd winstonLogger

Create the NestJS backend project:

nest new winston-logger-project

Choose npm as the package manager, then enter the directory:

cd winston-logger-project

Choose REST API when prompted.

Use TypeORM with SQLite, plus Swagger for API interaction. Install the required packages:

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

Install sqlite3 and @nestjs/config. The configuration package lets you read environment variables from a .env file and make them available through ConfigModule.

Create a .env file in the project root:

// winston-logger-project/.env

DB_TYPE=sqlite
DB_NAME=data/api.sqlite
DB_SYNCHRONIZE=true
DB_LOGGING=true

Create a configuration file for 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 the configuration, ConfigModule, and TypeOrmModule. Do not worry if the linter complains about LogsModule; that comes next.

// 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 {}

Set up CRUD endpoints for logs. This example uses @rewiko/crud to automate the basics:

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

Create the logs resource:

nest generate resource logs --no-spec

Or:

nest g res logs --no-spec

The --no-spec flag skips test files. Select REST API, then answer yes to generating CRUD entry points.

Create the Log entity

// 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;
}

Create the service, controller, 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 main.ts:

// 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 the setup is correct, run the app with:

npm run start:dev

How to add Winston

Install Winston and its transport package:

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

You can find the package on npm.

There are many ways to implement Winston in NestJS. This approach makes it possible for the Winston Logger to save logs to the database. Create a LoggerModule that sets up the Winston instance.

Create the Winston configuration. DatabaseTransportWrapper is injected in the constructor and is created shortly.

customFormat controls how logs are formatted in the console. This implementation produces a message such as:

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 determine where each log level goes.

// 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;

To give this transport access to LogsService, inject it through DatabaseTransportWrapper:

// 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);
  }
}

Create a custom logger that translates NestJS LoggerService logs to Winston:

// winston-logger-project/src/logger/nestToWinstonLogger.service.ts

import { Injectable, 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 });
  }
}

Put the pieces together in 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:

  • It is decorated as @Global() so it is available throughout the app.
  • It imports LogsModule to use the exported LogsService.
  • It creates a list of providers, including DatabaseTransportWrapper and a WINSTON provider that creates the logger using the injected wrapper.
  • It creates CustomLogger by injecting the WINSTON token.
  • It exports WINSTON and CustomLogger.

Get LoggerModule running

Add LoggerModule and DatabaseTransport to 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 {}

Update main.ts to retrieve and use 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();

Run npm run start:dev and look at the console output. You should see the 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 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

To log explicitly, add a GET /logs/test route to LogsController:

// 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!');
  }
}

Open the Swagger interface at localhost:3000/api, find GET /logs/test, select Try it out, then select Execute. The test route emits the logs.

Next, find GET /logs, select Try it out, and select Execute. The result should include the logs you created. At the bottom of the list, it should resemble this output:

[
  // ...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!"
  }
]

Winston is now set up to save logs to your database.

Drag to pan. Use +/− or Ctrl/Cmd + scroll to zoom. Pinch to zoom on touch devices.