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:
Loggerimported fromnestjs-pino(not@nestjs/common)bufferLogs: trueensures logs during bootstrap are capturedapp.useGlobalFilters(...)removed — filters now registered viaAPP_FILTERapp.useGlobalInterceptors(...)removed — HTTP logging handled bypino-http- Static
Logger.log(...)replaced withconsole.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 thenestjs-pinodecorator for injecting a logger with a named context. It is equivalent tonew 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.tsmust already haveLoggingInterceptorremoved andLoggerModulewired viaapp.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(...)becomeslogger.info(...)— Pino usesinfoinstead of NestJS'slog.
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"}