Aller au contenu

NestJS Backend

Guide d'intégration complet pour le monitoring du backend NestJS.

Informations

Propriété Valeur
Label app nestjs-backend
Rétention 14 jours (prod), 30 jours (erreurs)
Type de logs JSON structuré via Logger NestJS

Choisir votre méthode de déploiement

Plateforme Méthode
VPS (Contabo, etc.) Docker Compose + Alloy
Railway Push direct vers Loki
Autres PaaS Push direct vers Loki

Déploiement sur Railway

Railway ne supporte pas les sidecars Docker. Les logs sont envoyés directement à Loki depuis l'application.

Installation

npm install pino pino-loki

src/common/logger/loki.logger.ts

import pino from 'pino';

const transport = pino.transport({
  targets: [
    // Console output
    {
      target: 'pino/file',
      options: { destination: 1 }, // stdout
      level: 'info',
    },
    // Loki output
    {
      target: 'pino-loki',
      options: {
        batching: true,
        interval: 5,
        host: process.env.LOKI_URL?.replace('/loki/api/v1/push', '') || '',
        basicAuth: {
          username: process.env.LOKI_USER || '',
          password: process.env.LOKI_PASSWORD || '',
        },
        labels: {
          app: 'nestjs-backend',
          env: process.env.NODE_ENV || 'development',
          host: process.env.RAILWAY_SERVICE_NAME || 'railway',
        },
      },
      level: 'info',
    },
  ],
});

export const logger = pino(
  {
    level: 'info',
    formatters: {
      level: (label) => ({ level: label }),
    },
  },
  transport,
);

src/main.ts (Railway)

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { logger } from './common/logger/loki.logger';

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

  // Use Pino logger
  app.useLogger({
    log: (message: string) => logger.info(message),
    error: (message: string, trace?: string) => logger.error({ trace }, message),
    warn: (message: string) => logger.warn(message),
    debug: (message: string) => logger.debug(message),
    verbose: (message: string) => logger.trace(message),
  });

  const port = process.env.PORT || 3000;
  await app.listen(port);

  logger.info({ port, env: process.env.NODE_ENV }, 'Application started');
}
bootstrap();

Variables Railway

Dans Railway Dashboard > Variables :

Variable Valeur
LOKI_URL https://loki.monitoring.lyroh.com
LOKI_USER loki-push
LOKI_PASSWORD Votre mot de passe Loki
NODE_ENV production

Vérification

Après déploiement, dans Grafana :

{app="nestjs-backend", env="production"}

Intégration Docker Compose

Pour les déploiements sur VPS avec Docker.

Structure du Projet

nestjs-backend/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile
├── alloy/
│   └── config.alloy
├── .env.example
├── package.json
├── tsconfig.json
└── src/
    ├── main.ts
    ├── app.module.ts
    └── common/
        ├── logger/
        │   └── json.logger.ts
        └── interceptors/
            └── logging.interceptor.ts

Dockerfile

# Build stage
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine

WORKDIR /app

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./

ENV NODE_ENV=production

EXPOSE 3000

CMD ["node", "dist/main.js"]

docker-compose.yml

# Note: 'version' is obsolete in Docker Compose v2+

services:
  # ═══════════════════════════════════════════════════════════════
  # API - NestJS Backend
  # ═══════════════════════════════════════════════════════════════
  api:
    build: .
    container_name: nestjs-api
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DATABASE_URL=${DATABASE_URL}
    labels:
      - "app=nestjs-backend"
      - "service=api"
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ═══════════════════════════════════════════════════════════════
  # WORKER (optionnel) - Background jobs
  # ═══════════════════════════════════════════════════════════════
  worker:
    build: .
    container_name: nestjs-worker
    restart: unless-stopped
    command: ["node", "dist/worker.js"]
    environment:
      - NODE_ENV=production
      - DATABASE_URL=${DATABASE_URL}
    labels:
      - "app=nestjs-backend"
      - "service=worker"

  # ═══════════════════════════════════════════════════════════════
  # ALLOY - Log Collection
  # ═══════════════════════════════════════════════════════════════
  alloy:
    image: grafana/alloy:latest
    container_name: nestjs-backend-alloy
    restart: unless-stopped
    volumes:
      - ./alloy/config.alloy:/etc/alloy/config.alloy:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - alloy_data:/var/lib/alloy
    command:
      - run
      - --server.http.listen-addr=0.0.0.0:12345
      - --storage.path=/var/lib/alloy
      - /etc/alloy/config.alloy
    environment:
      - LOKI_URL=${LOKI_URL}
      - LOKI_USER=${LOKI_USER}
      - LOKI_PASSWORD=${LOKI_PASSWORD}
      - APP_ENV=${APP_ENV:-prod}
      - HOST_NAME=${HOST_NAME:-backend-server}
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:12345/ready"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  alloy_data:

alloy/config.alloy

// Discovery Docker
discovery.docker "containers" {
  host = "unix:///var/run/docker.sock"
}

// Relabeling
discovery.relabel "docker_labels" {
  targets = discovery.docker.containers.targets

  rule {
    source_labels = ["__meta_docker_container_name"]
    regex         = "/(.*)"
    target_label  = "container"
  }

  rule {
    source_labels = ["__meta_docker_container_label_app"]
    target_label  = "app"
  }

  rule {
    source_labels = ["__meta_docker_container_label_service"]
    target_label  = "service"
  }

  rule {
    source_labels = ["app"]
    regex         = ".+"
    action        = "keep"
  }
}

// Source
loki.source.docker "docker_logs" {
  host       = "unix:///var/run/docker.sock"
  targets    = discovery.relabel.docker_labels.output
  labels     = {
    "env"  = env("APP_ENV"),
    "host" = env("HOST_NAME"),
  }
  forward_to = [loki.process.pipeline.receiver]
}

// Processing Pipeline
loki.process "pipeline" {
  forward_to = [loki.write.loki_remote.receiver]

  // Parse NestJS JSON logs
  stage.json {
    expressions = {
      level      = "level",
      message    = "message",
      context    = "context",
      timestamp  = "timestamp",
      method     = "method",
      url        = "url",
      statusCode = "statusCode",
      duration   = "duration",
      userId     = "userId",
      requestId  = "requestId",
    }
  }

  // Normalize log levels
  stage.replace {
    expression = "(?i)(LOG|INFO)"
    replace    = "info"
    source     = "level"
  }

  stage.replace {
    expression = "(?i)(WARN|WARNING)"
    replace    = "warn"
    source     = "level"
  }

  stage.replace {
    expression = "(?i)(ERROR|ERR)"
    replace    = "error"
    source     = "level"
  }

  stage.labels {
    values = {
      level   = "",
      context = "",
      service = "",
    }
  }

  // Drop health checks (optional)
  // stage.drop {
  //   expression = "(?i)/health"
  // }

  stage.drop {
    expression = "^\\s*$"
  }
}

// Send to Loki
loki.write "loki_remote" {
  endpoint {
    url = env("LOKI_URL")
    basic_auth {
      username = env("LOKI_USER")
      password = env("LOKI_PASSWORD")
    }
  }
}

.env.example

# Application
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/db

# Monitoring
LOKI_URL=https://loki.monitoring.lyroh.com/loki/api/v1/push
LOKI_USER=loki-push
LOKI_PASSWORD=your_password_here
APP_ENV=prod
HOST_NAME=backend-server-1

Configuration NestJS

src/common/logger/json.logger.ts

import { ConsoleLogger, Injectable, LogLevel } from '@nestjs/common';

interface LogObject {
  timestamp: string;
  level: string;
  context?: string;
  message: string;
  [key: string]: any;
}

@Injectable()
export class JsonLogger extends ConsoleLogger {
  private formatLog(
    level: string,
    message: string,
    context?: string,
    meta?: Record<string, any>,
  ): string {
    const log: LogObject = {
      timestamp: new Date().toISOString(),
      level: level.toUpperCase(),
      context: context || this.context || 'Application',
      message,
      ...meta,
    };
    return JSON.stringify(log);
  }

  log(message: string, context?: string): void;
  log(message: string, ...optionalParams: any[]): void;
  log(message: string, ...optionalParams: any[]): void {
    const context = optionalParams[0] as string | undefined;
    console.log(this.formatLog('INFO', message, context));
  }

  error(message: string, trace?: string, context?: string): void;
  error(message: string, ...optionalParams: any[]): void;
  error(message: string, ...optionalParams: any[]): void {
    const [traceOrContext, context] = optionalParams;
    const meta = typeof traceOrContext === 'string' && traceOrContext.includes('\n')
      ? { trace: traceOrContext }
      : {};
    console.error(this.formatLog('ERROR', message, context || traceOrContext, meta));
  }

  warn(message: string, context?: string): void;
  warn(message: string, ...optionalParams: any[]): void;
  warn(message: string, ...optionalParams: any[]): void {
    const context = optionalParams[0] as string | undefined;
    console.warn(this.formatLog('WARN', message, context));
  }

  debug(message: string, context?: string): void;
  debug(message: string, ...optionalParams: any[]): void;
  debug(message: string, ...optionalParams: any[]): void {
    if (process.env.NODE_ENV !== 'production') {
      const context = optionalParams[0] as string | undefined;
      console.debug(this.formatLog('DEBUG', message, context));
    }
  }

  verbose(message: string, context?: string): void;
  verbose(message: string, ...optionalParams: any[]): void;
  verbose(message: string, ...optionalParams: any[]): void {
    if (process.env.NODE_ENV !== 'production') {
      const context = optionalParams[0] as string | undefined;
      console.log(this.formatLog('VERBOSE', message, context));
    }
  }

  // Custom method for structured logs
  logJson(level: LogLevel, message: string, meta: Record<string, any>): void {
    console.log(this.formatLog(level, message, this.context, meta));
  }
}

src/common/interceptors/logging.interceptor.ts

import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger('HTTP');

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const ctx = context.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();

    const { method, url, ip, headers } = request;
    const userAgent = headers['user-agent'] || '';
    const requestId = headers['x-request-id'] || this.generateRequestId();
    const userId = (request as any).user?.id;

    const start = Date.now();

    return next.handle().pipe(
      tap({
        next: () => {
          const duration = Date.now() - start;
          const { statusCode } = response;

          console.log(
            JSON.stringify({
              timestamp: new Date().toISOString(),
              level: 'INFO',
              context: 'HTTP',
              message: `${method} ${url} ${statusCode}`,
              method,
              url,
              statusCode,
              duration,
              ip,
              userAgent,
              requestId,
              userId,
            }),
          );
        },
        error: (error) => {
          const duration = Date.now() - start;
          const statusCode = error.status || 500;

          console.error(
            JSON.stringify({
              timestamp: new Date().toISOString(),
              level: 'ERROR',
              context: 'HTTP',
              message: `${method} ${url} ${statusCode} - ${error.message}`,
              method,
              url,
              statusCode,
              duration,
              ip,
              userAgent,
              requestId,
              userId,
              error: error.message,
              stack: error.stack,
            }),
          );
        },
      }),
    );
  }

  private generateRequestId(): string {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}

src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { JsonLogger } from './common/logger/json.logger';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';

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

  // Use JSON logger
  app.useLogger(new JsonLogger());

  // Global logging interceptor
  app.useGlobalInterceptors(new LoggingInterceptor());

  const port = process.env.PORT || 3000;
  await app.listen(port);

  console.log(
    JSON.stringify({
      timestamp: new Date().toISOString(),
      level: 'INFO',
      context: 'Bootstrap',
      message: `Application started on port ${port}`,
      port,
      env: process.env.NODE_ENV,
    }),
  );
}
bootstrap();

CI/CD

Stratégie de Branches

Branche Environnement APP_ENV Serveur
staging Staging staging STAGING_SERVER_HOST
main Production prod PROD_SERVER_HOST

Un seul stack monitoring

Staging et production envoient leurs logs au même serveur de monitoring. Le label env (staging ou prod) permet de les distinguer dans Grafana.

Guide complet CI/CD

Voir le guide détaillé : CI/CD GitHub Actions

GitHub Actions - .github/workflows/deploy.yml

name: Deploy NestJS Backend

on:
  push:
    branches: [main, staging]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}

    steps:
      - uses: actions/checkout@v4

      - name: Set environment
        id: env
        run: |
          if [ "${{ github.ref_name }}" = "main" ]; then
            echo "ENV=prod" >> $GITHUB_OUTPUT
          else
            echo "ENV=staging" >> $GITHUB_OUTPUT
          fi

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=${{ steps.env.outputs.ENV }}
            type=sha,prefix=

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}

  deploy-staging:
    if: github.ref_name == 'staging'
    needs: build
    runs-on: ubuntu-latest
    environment: staging

    steps:
      - uses: actions/checkout@v4

      - name: Copy config files
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.STAGING_SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          source: "docker-compose.yml,docker-compose.staging.yml,alloy/"
          target: "/opt/nestjs-backend"

      - name: Deploy to Staging
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.STAGING_SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /opt/nestjs-backend

            cat > .env << 'EOF'
            NODE_ENV=staging
            PORT=3000
            DATABASE_URL=${{ secrets.DATABASE_URL_STAGING }}
            LOKI_URL=${{ secrets.LOKI_URL }}
            LOKI_USER=${{ secrets.LOKI_USER }}
            LOKI_PASSWORD=${{ secrets.LOKI_PASSWORD }}
            APP_ENV=staging
            HOST_NAME=$(hostname)
            EOF

            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker-compose -f docker-compose.yml -f docker-compose.staging.yml pull
            docker-compose -f docker-compose.yml -f docker-compose.staging.yml up -d

  deploy-prod:
    if: github.ref_name == 'main'
    needs: build
    runs-on: ubuntu-latest
    environment: production

    steps:
      - uses: actions/checkout@v4

      - name: Copy config files
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.PROD_SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          source: "docker-compose.yml,docker-compose.prod.yml,alloy/"
          target: "/opt/nestjs-backend"

      - name: Deploy to Production
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            cd /opt/nestjs-backend

            cat > .env << 'EOF'
            NODE_ENV=production
            PORT=3000
            DATABASE_URL=${{ secrets.DATABASE_URL }}
            LOKI_URL=${{ secrets.LOKI_URL }}
            LOKI_USER=${{ secrets.LOKI_USER }}
            LOKI_PASSWORD=${{ secrets.LOKI_PASSWORD }}
            APP_ENV=prod
            HOST_NAME=$(hostname)
            EOF

            echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker-compose -f docker-compose.yml -f docker-compose.prod.yml pull
            docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Secrets GitHub Requis

Configurez ces secrets dans Settings > Secrets and variables > Actions :

Secret Description
STAGING_SERVER_HOST IP/hostname serveur staging
PROD_SERVER_HOST IP/hostname serveur production
SERVER_USER Utilisateur SSH
SERVER_SSH_KEY Clé privée SSH
DATABASE_URL_STAGING URL DB staging
DATABASE_URL URL DB production
LOKI_URL https://loki.monitoring.lyroh.com/loki/api/v1/push
LOKI_USER loki-push
LOKI_PASSWORD Mot de passe Loki

docker-compose.staging.yml

# Note: 'version' is obsolete in Docker Compose v2+

services:
  api:
    image: ghcr.io/your-org/nestjs-backend:staging
    restart: unless-stopped
    labels:
      - "app=nestjs-backend"
      - "env=staging"

  worker:
    image: ghcr.io/your-org/nestjs-backend:staging
    restart: unless-stopped

  alloy:
    environment:
      - APP_ENV=staging

docker-compose.prod.yml

# Note: 'version' is obsolete in Docker Compose v2+

services:
  api:
    image: ghcr.io/your-org/nestjs-backend:prod
    restart: unless-stopped
    labels:
      - "app=nestjs-backend"
      - "env=prod"

  worker:
    image: ghcr.io/your-org/nestjs-backend:prod
    restart: unless-stopped

  alloy:
    environment:
      - APP_ENV=prod

Requêtes LogQL Utiles

# Tous les logs du backend
{app="nestjs-backend", env="prod"}

# Par service (api ou worker)
{app="nestjs-backend", service="api"}
{app="nestjs-backend", service="worker"}

# Erreurs HTTP 5xx
{app="nestjs-backend"} | json | statusCode >= 500

# Requêtes lentes (> 1 seconde)
{app="nestjs-backend"} | json | duration > 1000

# Logs par contexte NestJS
{app="nestjs-backend"} | json | context="AuthService"

# Erreurs avec stack trace
{app="nestjs-backend"} | json | level="ERROR" | line_format "{{.context}}: {{.message}}\n{{.stack}}"

# Requêtes par endpoint
sum by (url) (count_over_time({app="nestjs-backend"} | json [1h]))

# Latence moyenne par endpoint
avg by (url) ({app="nestjs-backend"} | json | unwrap duration | __error__="")

# Distribution des codes HTTP
sum by (statusCode) (count_over_time({app="nestjs-backend"} | json [1h]))

Alertes Recommandées

# Dans Grafana Alerting
- API down (aucun log en 5 min)
- Taux d'erreurs 5xx > 5%
- Latence P95 > 2 secondes
- Erreurs de connexion DB