Qadam Roadmap
проектplans/archived/2026-03-28-integrated/2026-03-14-pino-logger.md

Pino Logger Integration — Implementation Plan

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

Pino Logger Integration — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace NestJS built-in Logger with Pino for structured JSON logging across the API.

Architecture: nestjs-pino wraps Pino and integrates with NestJS DI — services/filters receive PinoLogger via constructor injection. pino-http handles HTTP request logging at the Fastify middleware level, replacing LoggingInterceptor. Exception filters are migrated from new-instantiation to APP_FILTER DI registration.

Tech Stack: nestjs-pino, pino-http, pino, pino-pretty (dev), Jest + @nestjs/testing

Spec: docs/superpowers/specs/2026-03-14-pino-logger-design.md


Chunk 1: Install dependencies and wire LoggerModule

Task 1: Install packages

Files:

  • Modify: apps/api/package.json

  • Step 1: Install runtime dependencies

From the apps/api directory:

cd apps/api && pnpm add nestjs-pino pino-http pino
  • Step 2: Install dev dependency
cd apps/api && pnpm add -D pino-pretty
  • Step 3: Verify installations
cd apps/api && pnpm list nestjs-pino pino-http pino pino-pretty

Expected: all four packages listed with versions.


Task 2: Wire LoggerModule into AppModule

Files:

  • Modify: apps/api/src/app.module.ts

  • Step 1: Add LoggerModule import to AppModule

Open apps/api/src/app.module.ts. Add LoggerModule to the imports array with pino-http configuration:

import { Module } from '@nestjs/common';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { LoggerModule } from 'nestjs-pino';
import { AppController } from './app.controller';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuthModule } from './modules/auth/auth.module';
import { BuyerModule } from './modules/buyer/buyer.module';
import { SellerModule } from './modules/seller/seller.module';
import { StaffModule } from './modules/staff/staff.module';
import { ReferenceModule } from './modules/reference/reference.module';
import { ItemModule } from './modules/item/item.module';
import { CatalogModule } from './modules/catalog/catalog.module';
import { LeadModule } from './modules/lead/lead.module';
import { ReviewModule } from './modules/review/review.module';
import { AdminModule } from './modules/admin/admin.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { DomainExceptionFilter } from './common/filters/domain-exception.filter';

@Module({
  imports: [
    LoggerModule.forRoot({
      pinoHttp: {
        autoLogging: true,
        quietReqLogger: false,
        transport: process.env.NODE_ENV !== 'production'
          ? { target: 'pino-pretty', options: { colorize: true, singleLine: true } }
          : undefined,
      },
    }),
    ConfigModule.forRoot({ isGlobal: true }),
    JwtModule.registerAsync({
      global: true,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get<string>('JWT_SECRET'),
      }),
    }),
    PrismaModule,
    RedisModule,
    AuthModule,
    BuyerModule,
    SellerModule,
    StaffModule,
    ReferenceModule,
    ItemModule,
    CatalogModule,
    LeadModule,
    ReviewModule,
    AdminModule,
  ],
  controllers: [AppController],
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    // APP_FILTER is applied in reverse order — DomainExceptionFilter runs first,
    // AllExceptionsFilter catches anything remaining (including non-domain errors).
    { provide: APP_FILTER, useClass: AllExceptionsFilter },
    { provide: APP_FILTER, useClass: DomainExceptionFilter },
  ],
})
export class AppModule {}

Task 3: Update main.ts

Files:

  • Modify: apps/api/src/main.ts

  • Step 1: Update main.ts

Replace the entire file content:

import { NestFactory } from '@nestjs/core';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { Logger } from 'nestjs-pino';
import fastifyCookie from '@fastify/cookie';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
    { bufferLogs: true },
  );

  // Type cast needed due to NestJS/Fastify adapter type mismatch
  await app.register(fastifyCookie as never);

  app.setGlobalPrefix('api/v1');

  app.enableCors({
    origin: process.env.CORS_ORIGIN || true,
    credentials: true,
  });

  // JwtAuthGuard is registered via APP_GUARD in AppModule for DI support
  // AllExceptionsFilter and DomainExceptionFilter are registered via APP_FILTER in AppModule
  app.useLogger(app.get(Logger));

  const port = process.env.PORT || 5001;
  await app.listen(port, '0.0.0.0');
  console.log(`API running on http://localhost:${port}`);
}

bootstrap();

Key changes:

  • Logger imported from nestjs-pino (not @nestjs/common)
  • bufferLogs: true ensures logs during bootstrap are captured
  • app.useGlobalFilters(...) removed — filters now registered via APP_FILTER
  • app.useGlobalInterceptors(...) removed — HTTP logging handled by pino-http
  • Static Logger.log(...) replaced with console.log (runs outside DI container)
  • Step 2: Verify TypeScript compiles
cd apps/api && pnpm check-types

Expected: no errors.

  • Step 3: Commit
git add apps/api/package.json pnpm-lock.yaml apps/api/src/app.module.ts apps/api/src/main.ts
git commit -m "feat(api): wire nestjs-pino LoggerModule and migrate filter registration to APP_FILTER"

Chunk 2: Migrate exception filters to PinoLogger

Task 4: Write tests for AllExceptionsFilter

Files:

  • Create: apps/api/src/common/filters/all-exceptions.filter.spec.ts

  • Step 1: Write the failing tests

Create apps/api/src/common/filters/all-exceptions.filter.spec.ts:

import { Test } from '@nestjs/testing';
import { HttpException, HttpStatus } from '@nestjs/common';
import { getLoggerToken, PinoLogger } from 'nestjs-pino';
import { AllExceptionsFilter } from './all-exceptions.filter';

const mockResponse = () => ({
  status: jest.fn().mockReturnThis(),
  send: jest.fn().mockReturnThis(),
});

const mockHost = (response: ReturnType<typeof mockResponse>) => ({
  switchToHttp: () => ({
    getResponse: () => response,
  }),
});

describe('AllExceptionsFilter', () => {
  let filter: AllExceptionsFilter;
  let logger: jest.Mocked<PinoLogger>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        AllExceptionsFilter,
        {
          // @InjectPinoLogger resolves to token 'PinoLogger:AllExceptionsFilter'
          provide: getLoggerToken(AllExceptionsFilter.name),
          useValue: {
            setContext: jest.fn(),
            error: jest.fn(),
            warn: jest.fn(),
            log: jest.fn(),
          },
        },
      ],
    }).compile();

    filter = module.get(AllExceptionsFilter);
    logger = module.get(getLoggerToken(AllExceptionsFilter.name));
  });

  it('handles HttpException with string body without logging', () => {
    const response = mockResponse();
    const exception = new HttpException('Not found', HttpStatus.NOT_FOUND);

    filter.catch(exception, mockHost(response) as any);

    expect(response.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
    expect(response.send).toHaveBeenCalledWith({
      statusCode: HttpStatus.NOT_FOUND,
      message: 'Not found',
    });
    expect(logger.error).not.toHaveBeenCalled();
  });

  it('handles HttpException with object body', () => {
    const response = mockResponse();
    const body = { statusCode: 400, message: 'Bad request' };
    const exception = new HttpException(body, HttpStatus.BAD_REQUEST);

    filter.catch(exception, mockHost(response) as any);

    expect(response.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
    expect(response.send).toHaveBeenCalledWith(body);
    expect(logger.error).not.toHaveBeenCalled();
  });

  it('logs and returns 500 for unhandled Error', () => {
    const response = mockResponse();
    const exception = new Error('Something broke');

    filter.catch(exception, mockHost(response) as any);

    expect(logger.error).toHaveBeenCalledWith(
      { err: exception },
      'Unhandled exception',
    );
    expect(response.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
    expect(response.send).toHaveBeenCalledWith({
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      message: 'Internal server error',
    });
  });

  it('logs and returns 500 for non-Error unhandled exception, wrapping it in Error', () => {
    const response = mockResponse();
    const exception = 'raw string error';

    filter.catch(exception, mockHost(response) as any);

    expect(logger.error).toHaveBeenCalledWith(
      { err: expect.any(Error) },
      'Unhandled exception',
    );
    expect(response.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
  });
});
  • Step 2: Run tests to verify they fail
cd apps/api && pnpm test -- --testPathPattern="all-exceptions.filter.spec"

Expected: FAIL — PinoLogger is not available / constructor injection not set up yet.


Task 5: Migrate AllExceptionsFilter

Files:

  • Modify: apps/api/src/common/filters/all-exceptions.filter.ts

  • Step 1: Update AllExceptionsFilter to use PinoLogger

Replace the entire file content:

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { PinoLogger, InjectPinoLogger } from 'nestjs-pino';
import type { FastifyReply } from 'fastify';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(
    @InjectPinoLogger(AllExceptionsFilter.name)
    private readonly logger: PinoLogger,
  ) {}

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<FastifyReply>();

    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      const body = exception.getResponse();
      response.status(status).send(
        typeof body === 'string' ? { statusCode: status, message: body } : body,
      );
      return;
    }

    this.logger.error(
      { err: exception instanceof Error ? exception : new Error(String(exception)) },
      'Unhandled exception',
    );

    response.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
      statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
      message: 'Internal server error',
    });
  }
}

@InjectPinoLogger(context) is the nestjs-pino decorator for injecting a logger with a named context. It is equivalent to new Logger(ClassName.name) in the old code.

  • Step 2: Run tests to verify they pass
cd apps/api && pnpm test -- --testPathPattern="all-exceptions.filter.spec"

Expected: all tests PASS.


Task 6: Write tests for DomainExceptionFilter

Files:

  • Create: apps/api/src/common/filters/domain-exception.filter.spec.ts

  • Step 1: Write the failing tests

Create apps/api/src/common/filters/domain-exception.filter.spec.ts:

import { Test } from '@nestjs/testing';
import { HttpException, HttpStatus } from '@nestjs/common';
import { getLoggerToken, PinoLogger } from 'nestjs-pino';
import { DomainExceptionFilter } from './domain-exception.filter';

const mockResponse = () => ({
  status: jest.fn().mockReturnThis(),
  send: jest.fn().mockReturnThis(),
});

const mockHost = (response: ReturnType<typeof mockResponse>) => ({
  switchToHttp: () => ({
    getResponse: () => response,
  }),
});

describe('DomainExceptionFilter', () => {
  let filter: DomainExceptionFilter;
  let logger: jest.Mocked<PinoLogger>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        DomainExceptionFilter,
        {
          // @InjectPinoLogger resolves to token 'PinoLogger:DomainExceptionFilter'
          provide: getLoggerToken(DomainExceptionFilter.name),
          useValue: {
            setContext: jest.fn(),
            error: jest.fn(),
            warn: jest.fn(),
            log: jest.fn(),
          },
        },
      ],
    }).compile();

    filter = module.get(DomainExceptionFilter);
    logger = module.get(getLoggerToken(DomainExceptionFilter.name));
  });

  it('does nothing for plain Error (not a domain exception)', () => {
    const response = mockResponse();
    const exception = new Error('plain error');

    filter.catch(exception, mockHost(response) as any);

    expect(response.status).not.toHaveBeenCalled();
    expect(logger.warn).not.toHaveBeenCalled();
  });

  it('does nothing for HttpException (passes through to AllExceptionsFilter)', () => {
    const response = mockResponse();
    const exception = new HttpException('Forbidden', HttpStatus.FORBIDDEN);

    filter.catch(exception, mockHost(response) as any);

    expect(response.status).not.toHaveBeenCalled();
    expect(logger.warn).not.toHaveBeenCalled();
  });

  it('handles known domain exception code', () => {
    const response = mockResponse();
    const exception = { code: 'NOT_FOUND', message: 'Item not found' };

    filter.catch(exception, mockHost(response) as any);

    expect(logger.warn).toHaveBeenCalledWith(
      'DomainException: NOT_FOUND — Item not found',
    );
    expect(response.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND);
    expect(response.send).toHaveBeenCalledWith({
      statusCode: HttpStatus.NOT_FOUND,
      errorCode: 'NOT_FOUND',
      message: 'Item not found',
    });
  });

  it('falls back to 500 for unknown domain error code', () => {
    const response = mockResponse();
    const exception = { code: 'UNKNOWN_CODE', message: 'Something happened' };

    filter.catch(exception, mockHost(response) as any);

    expect(response.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
  });
});
  • Step 2: Run tests to verify they fail
cd apps/api && pnpm test -- --testPathPattern="domain-exception.filter.spec"

Expected: FAIL.


Task 7: Migrate DomainExceptionFilter

Files:

  • Modify: apps/api/src/common/filters/domain-exception.filter.ts

  • Step 1: Update DomainExceptionFilter to use PinoLogger

Replace the entire file content:

import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/common';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
import type { FastifyReply } from 'fastify';

interface DomainException {
  code: string;
  message: string;
}

const ERROR_CODE_TO_STATUS: Record<string, HttpStatus> = {
  NOT_FOUND: HttpStatus.NOT_FOUND,
  ITEM_NOT_FOUND: HttpStatus.NOT_FOUND,
  SELLER_NOT_FOUND: HttpStatus.NOT_FOUND,
  BUYER_NOT_FOUND: HttpStatus.NOT_FOUND,
  FORBIDDEN: HttpStatus.FORBIDDEN,
  UNAUTHORIZED: HttpStatus.UNAUTHORIZED,
  SLUG_TAKEN: HttpStatus.CONFLICT,
  DUPLICATE: HttpStatus.CONFLICT,
  VALIDATION_ERROR: HttpStatus.BAD_REQUEST,
  INVALID_CREDENTIALS: HttpStatus.UNAUTHORIZED,
  TOKEN_EXPIRED: HttpStatus.UNAUTHORIZED,
  INVALID_COORDINATES: HttpStatus.BAD_REQUEST,
  ACCOUNT_SUSPENDED: HttpStatus.FORBIDDEN,
};

function isDomainException(exception: unknown): exception is DomainException {
  return (
    typeof exception === 'object' &&
    exception !== null &&
    'code' in exception &&
    'message' in exception &&
    typeof (exception as DomainException).code === 'string'
  );
}

@Catch()
export class DomainExceptionFilter implements ExceptionFilter {
  constructor(
    @InjectPinoLogger(DomainExceptionFilter.name)
    private readonly logger: PinoLogger,
  ) {}

  catch(exception: unknown, host: ArgumentsHost) {
    if (!isDomainException(exception)) return;

    const ctx = host.switchToHttp();
    const response = ctx.getResponse<FastifyReply>();
    const status = ERROR_CODE_TO_STATUS[exception.code] || HttpStatus.INTERNAL_SERVER_ERROR;

    this.logger.warn(`DomainException: ${exception.code} — ${exception.message}`);

    response.status(status).send({
      statusCode: status,
      errorCode: exception.code,
      message: exception.message,
    });
  }
}
  • Step 2: Run all filter tests
cd apps/api && pnpm test -- --testPathPattern="(all-exceptions|domain-exception).filter.spec"

Expected: all tests PASS.

  • Step 3: Commit
git add apps/api/src/common/filters/
git commit -m "feat(api): migrate exception filters to PinoLogger with APP_FILTER registration"

Chunk 3: Migrate infrastructure services and cleanup

Prerequisite: Chunk 1 (Tasks 1–3) must be completed before executing this chunk. In particular, main.ts must already have LoggingInterceptor removed and LoggerModule wired via app.useLogger(app.get(Logger)).

Task 8: Migrate PrismaService

Files:

  • Modify: apps/api/src/prisma/prisma.service.ts

  • Step 1: Update PrismaService to use PinoLogger

Replace the entire file content:

import {
  Injectable,
  OnModuleInit,
  OnModuleDestroy,
} from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';

@Injectable()
export class PrismaService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  private replicaClient: PrismaClient | null = null;

  constructor(
    @InjectPinoLogger(PrismaService.name)
    private readonly logger: PinoLogger,
  ) {
    super({ datasourceUrl: process.env.DATABASE_URL });

    if (
      process.env.DATABASE_REPLICA_URL &&
      process.env.DATABASE_REPLICA_URL !== process.env.DATABASE_URL
    ) {
      this.replicaClient = new PrismaClient({
        datasourceUrl: process.env.DATABASE_REPLICA_URL,
      });
    }
  }

  /** Use for all read operations (findMany, findFirst, findUnique, count, aggregate) */
  $replica(): PrismaClient {
    return this.replicaClient || this;
  }

  async onModuleInit() {
    await this.$connect();
    this.logger.info('Primary database connected');

    if (this.replicaClient) {
      await this.replicaClient.$connect();
      this.logger.info('Read replica connected');
    }
  }

  async onModuleDestroy() {
    await this.$disconnect();
    if (this.replicaClient) {
      await this.replicaClient.$disconnect();
    }
  }
}

Note: logger.log(...) becomes logger.info(...) — Pino uses info instead of NestJS's log.


Task 9: Migrate RedisService

Files:

  • Modify: apps/api/src/redis/redis.service.ts

  • Step 1: Update RedisService to use PinoLogger

Replace the entire file content:

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';

@Injectable()
export class RedisService extends Redis implements OnModuleInit, OnModuleDestroy {
  constructor(
    @InjectPinoLogger(RedisService.name)
    private readonly logger: PinoLogger,
  ) {
    super(process.env.REDIS_URL || 'redis://localhost:6379', {
      maxRetriesPerRequest: 3,
      lazyConnect: true,
    });
  }

  async onModuleInit() {
    await this.connect();
    this.logger.info('Redis connected');
  }

  async onModuleDestroy() {
    await this.quit();
  }
}

Task 10: Delete LoggingInterceptor

Files:

  • Delete: apps/api/src/common/interceptors/logging.interceptor.ts

  • Step 1: Remove interceptor, verify compile, run tests, and commit

git rm apps/api/src/common/interceptors/logging.interceptor.ts
  • Step 2: Verify TypeScript compiles with no errors
cd apps/api && pnpm check-types

Expected: no errors. (All references to LoggingInterceptor were already removed from main.ts in Chunk 1 Task 3.)

  • Step 3: Run all tests
cd apps/api && pnpm test

Expected: all tests pass.

  • Step 4: Commit
git add apps/api/src/prisma/prisma.service.ts apps/api/src/redis/redis.service.ts
git commit -m "feat(api): migrate PrismaService and RedisService to PinoLogger, remove LoggingInterceptor"

Verification

After all tasks are complete, run the full test suite first:

cd apps/api && pnpm test

Expected: all tests pass.

Then start the dev server and verify logs appear correctly:

cd apps/api && pnpm dev

Without NODE_ENV=production, output should be pretty-printed via pino-pretty.

With NODE_ENV=production:

NODE_ENV=production pnpm dev

Each log line should be a single-line JSON object:

{"level":30,"time":1710000000000,"pid":1234,"msg":"Primary database connected","context":"PrismaService"}
{"level":30,"time":1710000000001,"pid":1234,"req":{"method":"GET","url":"/api/v1/items"},"res":{"statusCode":200},"responseTime":45,"msg":"request completed"}