Aller au contenu

NextJS Apps

Guide d'intégration complet pour le monitoring des applications NextJS (x4).

Informations

App Label app Rétention
NextJS App 1 nextjs-app-1 7 jours
NextJS App 2 nextjs-app-2 7 jours
NextJS App 3 nextjs-app-3 7 jours
NextJS App 4 nextjs-app-4 7 jours
Erreurs (toutes) - 30 jours

Choisir votre méthode de déploiement

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

Déploiement sur Vercel

Vercel est une plateforme serverless. Les logs sont envoyés directement à Loki depuis l'application.

Installation

npm install pino pino-loki

src/lib/logger.ts (Vercel)

import pino from 'pino';

// Déterminer si on est côté serveur
const isServer = typeof window === 'undefined';

// Configuration pour Vercel (serverless)
const createLogger = () => {
  if (!isServer) {
    // Client-side: simple console wrapper
    return {
      info: (obj: any, msg?: string) => console.log(msg || obj),
      warn: (obj: any, msg?: string) => console.warn(msg || obj),
      error: (obj: any, msg?: string) => console.error(msg || obj),
      debug: (obj: any, msg?: string) => console.debug(msg || obj),
    };
  }

  // Server-side: Pino with Loki transport
  const transport = pino.transport({
    targets: [
      {
        target: 'pino/file',
        options: { destination: 1 },
        level: 'info',
      },
      {
        target: 'pino-loki',
        options: {
          batching: true,
          interval: 2, // Flush rapidement (serverless)
          host: process.env.LOKI_URL?.replace('/loki/api/v1/push', '') || '',
          basicAuth: {
            username: process.env.LOKI_USER || '',
            password: process.env.LOKI_PASSWORD || '',
          },
          labels: {
            app: process.env.NEXT_PUBLIC_APP_NAME || 'nextjs-app',
            env: process.env.VERCEL_ENV || 'development',
            host: 'vercel',
            region: process.env.VERCEL_REGION || 'unknown',
          },
        },
        level: 'info',
      },
    ],
  });

  return pino({ level: 'info' }, transport);
};

export const logger = createLogger();

API Route avec logging

// src/app/api/example/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';

export async function GET(request: NextRequest) {
  const start = Date.now();

  try {
    // Votre logique
    const data = { message: 'Hello' };

    logger.info({
      method: 'GET',
      url: request.nextUrl.pathname,
      status: 200,
      duration: Date.now() - start,
    }, 'API request completed');

    return NextResponse.json(data);
  } catch (error) {
    logger.error({
      method: 'GET',
      url: request.nextUrl.pathname,
      error: error instanceof Error ? error.message : 'Unknown',
      stack: error instanceof Error ? error.stack : undefined,
    }, 'API request failed');

    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }
}

Server Actions avec logging

// src/app/actions.ts
'use server';

import { logger } from '@/lib/logger';

export async function submitForm(formData: FormData) {
  const start = Date.now();

  try {
    const name = formData.get('name');

    // Votre logique

    logger.info({
      action: 'submitForm',
      duration: Date.now() - start,
    }, 'Form submitted successfully');

    return { success: true };
  } catch (error) {
    logger.error({
      action: 'submitForm',
      error: error instanceof Error ? error.message : 'Unknown',
    }, 'Form submission failed');

    return { success: false, error: 'Submission failed' };
  }
}

Variables Vercel

Dans Vercel Dashboard > Settings > Environment Variables :

Variable Valeur Environments
LOKI_URL https://loki.monitoring.lyroh.com Production, Preview
LOKI_USER loki-push Production, Preview
LOKI_PASSWORD Votre mot de passe Production, Preview
NEXT_PUBLIC_APP_NAME nextjs-app-1 Production, Preview

Pour chaque app

Changez NEXT_PUBLIC_APP_NAME pour chaque application (nextjs-app-1, nextjs-app-2, etc.)

Vérification

Après déploiement, dans Grafana :

{app="nextjs-app-1", env="production"}

Intégration Docker Compose

Pour les déploiements sur VPS avec Docker.

Structure du Projet

nextjs-app/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── docker-compose.yml
├── docker-compose.prod.yml
├── Dockerfile
├── alloy/
│   └── config.alloy
├── .env.example
├── package.json
├── next.config.js
└── src/
    ├── app/
    │   └── ...
    └── lib/
        └── logger.ts

Dockerfile

# Build stage
FROM node:20-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# Production stage
FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

Configuration Next.js

Pour utiliser le mode standalone, ajoutez dans next.config.js :

module.exports = {
  output: 'standalone',
}

docker-compose.yml

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

services:
  # ═══════════════════════════════════════════════════════════════
  # NEXTJS APP
  # ═══════════════════════════════════════════════════════════════
  web:
    build: .
    container_name: nextjs-app
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
    labels:
      - "app=${APP_NAME:-nextjs-app-1}"    # Changer pour chaque app
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  # ═══════════════════════════════════════════════════════════════
  # ALLOY - Log Collection
  # ═══════════════════════════════════════════════════════════════
  alloy:
    image: grafana/alloy:latest
    container_name: ${APP_NAME:-nextjs-app-1}-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:-web-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 = ["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]

  // Handle multiline (stack traces)
  stage.multiline {
    firstline     = "^(\\[|{|\\d{4}-|Error:|TypeError:|at )"
    max_wait_time = "3s"
    max_lines     = 100
  }

  // Parse JSON logs (if using structured logging)
  stage.json {
    expressions = {
      level     = "level",
      message   = "msg",
      timestamp = "time",
      method    = "method",
      url       = "url",
      status    = "status",
      duration  = "duration",
      type      = "type",
    }
  }

  // Normalize level
  stage.template {
    source   = "level"
    template = "{{ ToLower .Value }}"
  }

  stage.labels {
    values = {
      level = "",
      type  = "",
    }
  }

  // Detect level from Next.js output
  stage.regex {
    expression = "(?P<detected_level>error|warn|info|ready|event)"
  }

  stage.labels {
    values = {
      level = "detected_level",
    }
  }

  // Drop noisy logs (optional)
  // stage.drop {
  //   expression = "(?i)(favicon|_next/static|hot-update|\\.map)"
  // }

  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
NEXT_PUBLIC_API_URL=https://api.lyroh.com

# App Identification (IMPORTANT: changer pour chaque app)
APP_NAME=nextjs-app-1

# 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=web-server-1

Logger NextJS Recommandé

src/lib/logger.ts

type LogLevel = 'info' | 'warn' | 'error' | 'debug';

interface LogEntry {
  time: string;
  level: LogLevel;
  msg: string;
  [key: string]: unknown;
}

class Logger {
  private log(level: LogLevel, message: string, meta?: Record<string, unknown>): void {
    const entry: LogEntry = {
      time: new Date().toISOString(),
      level,
      msg: message,
      ...meta,
    };

    const output = JSON.stringify(entry);

    switch (level) {
      case 'error':
        console.error(output);
        break;
      case 'warn':
        console.warn(output);
        break;
      default:
        console.log(output);
    }
  }

  info(message: string, meta?: Record<string, unknown>): void {
    this.log('info', message, meta);
  }

  warn(message: string, meta?: Record<string, unknown>): void {
    this.log('warn', message, meta);
  }

  error(message: string, meta?: Record<string, unknown>): void {
    this.log('error', message, meta);
  }

  debug(message: string, meta?: Record<string, unknown>): void {
    if (process.env.NODE_ENV !== 'production') {
      this.log('debug', message, meta);
    }
  }

  // Log HTTP requests (for API routes)
  request(req: Request, status: number, duration: number): void {
    const url = new URL(req.url);
    this.info(`${req.method} ${url.pathname} ${status}`, {
      type: 'request',
      method: req.method,
      url: url.pathname,
      status,
      duration,
    });
  }
}

export const logger = new Logger();

Middleware pour API Routes (App Router)

// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const start = Date.now();
  const response = NextResponse.next();

  // Log after response (approximation)
  const duration = Date.now() - start;

  // Only log API routes
  if (request.nextUrl.pathname.startsWith('/api')) {
    console.log(
      JSON.stringify({
        time: new Date().toISOString(),
        level: 'info',
        msg: `${request.method} ${request.nextUrl.pathname}`,
        type: 'request',
        method: request.method,
        url: request.nextUrl.pathname,
        duration,
      })
    );
  }

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

API Route avec Logging

// src/app/api/example/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';

export async function GET(request: NextRequest) {
  const start = Date.now();

  try {
    // Your logic here
    const data = { message: 'Hello' };

    logger.request(request, 200, Date.now() - start);
    return NextResponse.json(data);
  } catch (error) {
    logger.error('API error', {
      type: 'api_error',
      error: error instanceof Error ? error.message : 'Unknown error',
      stack: error instanceof Error ? error.stack : undefined,
    });

    return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
  }
}

Error Boundary avec Logging

// src/components/ErrorBoundary.tsx
'use client';

import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
    // Log to console (captured by Alloy)
    console.error(
      JSON.stringify({
        time: new Date().toISOString(),
        level: 'error',
        msg: 'React Error Boundary caught an error',
        type: 'react_error',
        error: error.message,
        stack: error.stack,
        componentStack: errorInfo.componentStack,
      })
    );
  }

  render(): ReactNode {
    if (this.state.hasError) {
      return this.props.fallback || <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

CI/CD

Stratégie de Branches

Branche Environnement APP_ENV
staging Staging staging
main Production prod

Un seul stack monitoring

Staging et production envoient leurs logs au même serveur de monitoring. Le label env 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 NextJS App

on:
  push:
    branches: [main, staging]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  APP_NAME: nextjs-app-1    # Changer pour chaque app

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

    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 }}
          build-args: |
            NEXT_PUBLIC_API_URL=${{ steps.env.outputs.ENV == 'prod' && vars.NEXT_PUBLIC_API_URL || vars.NEXT_PUBLIC_API_URL_STAGING }}

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

    steps:
      - uses: actions/checkout@v4

      - 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/${{ env.APP_NAME }}

            cat > .env << 'EOF'
            NODE_ENV=staging
            APP_NAME=${{ env.APP_NAME }}
            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: 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/${{ env.APP_NAME }}

            cat > .env << 'EOF'
            NODE_ENV=production
            APP_NAME=${{ env.APP_NAME }}
            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

docker-compose.staging.yml

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

services:
  web:
    image: ghcr.io/your-org/nextjs-app:staging
    restart: unless-stopped
    labels:
      - "app=${APP_NAME}"
      - "env=staging"

  alloy:
    environment:
      - APP_ENV=staging

docker-compose.prod.yml

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

services:
  web:
    image: ghcr.io/your-org/nextjs-app:prod
    restart: unless-stopped
    labels:
      - "app=${APP_NAME}"
      - "env=prod"

  alloy:
    environment:
      - APP_ENV=prod

Déploiement des 4 Apps NextJS

Pour chaque app, modifiez APP_NAME dans le workflow :

App APP_NAME Staging Secret Prod Secret
App 1 nextjs-app-1 STAGING_SERVER_HOST PROD_SERVER_HOST
App 2 nextjs-app-2 STAGING_SERVER_HOST PROD_SERVER_HOST
App 3 nextjs-app-3 STAGING_SERVER_HOST PROD_SERVER_HOST
App 4 nextjs-app-4 STAGING_SERVER_HOST PROD_SERVER_HOST

Si sur le même serveur avec différents ports :

# docker-compose.prod.yml pour App 2
services:
  web:
    ports:
      - "3002:3000"
    labels:
      - "app=nextjs-app-2"
      - "env=prod"

Requêtes LogQL Utiles

# Toutes les apps NextJS
{app=~"nextjs-app-.*", env="prod"}

# Une app spécifique
{app="nextjs-app-1", env="prod"}

# Erreurs React
{app=~"nextjs-app-.*"} | json | type="react_error"

# Requêtes API
{app=~"nextjs-app-.*"} | json | type="request"

# Erreurs par app
sum by (app) (count_over_time({app=~"nextjs-app-.*"} |~ "error" [1h]))

# Comparaison du trafic entre apps
sum by (app) (count_over_time({app=~"nextjs-app-.*"} | json | type="request" [1h]))

# Build/compilation logs
{app=~"nextjs-app-.*"} |~ "(?i)(compiled|build|ready)"

Alertes Recommandées

# Dans Grafana Alerting
- App silencieuse (aucun log en 10 min)
- Erreurs React > 5 en 5 min
- Erreurs API > 10 en 5 min
- Build failed (détection pattern)