# 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:

```bash
cd apps/api && pnpm add nestjs-pino pino-http pino
```

- [ ] **Step 2: Install dev dependency**

```bash
cd apps/api && pnpm add -D pino-pretty
```

- [ ] **Step 3: Verify installations**

```bash
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:

```typescript
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:

```typescript
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**

```bash
cd apps/api && pnpm check-types
```

Expected: no errors.

- [ ] **Step 3: Commit**

```bash
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`:

```typescript
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**

```bash
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:

```typescript
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**

```bash
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`:

```typescript
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**

```bash
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:

```typescript
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**

```bash
cd apps/api && pnpm test -- --testPathPattern="(all-exceptions|domain-exception).filter.spec"
```

Expected: all tests PASS.

- [ ] **Step 3: Commit**

```bash
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:

```typescript
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:

```typescript
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**

```bash
git rm apps/api/src/common/interceptors/logging.interceptor.ts
```

- [ ] **Step 2: Verify TypeScript compiles with no errors**

```bash
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**

```bash
cd apps/api && pnpm test
```

Expected: all tests pass.

- [ ] **Step 4: Commit**

```bash
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:

```bash
cd apps/api && pnpm test
```

Expected: all tests pass.

Then start the dev server and verify logs appear correctly:

```bash
cd apps/api && pnpm dev
```

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

With `NODE_ENV=production`:
```bash
NODE_ENV=production pnpm dev
```

Each log line should be a single-line JSON object:
```json
{"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"}
```
