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
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 :
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 :
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)"