feat: add centralized logging stack with log exporter

- Add Loki + Promtail + Grafana logging infrastructure
- Add log-exporter service (REST API to query logs)
- Add docker-compose.logging.yml (standalone logging stack)
- Add docker-compose.full.yml (merged dev + logging in one file)
- Update docker-compose.dev.yml with network labels for Promtail
- Add admin logs dashboard page
- Fix invitation DTO and users settings page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
David 2026-04-02 12:46:10 +02:00
parent e1f813bd92
commit 6a38c236b2
16 changed files with 2118 additions and 20 deletions

View File

@ -72,10 +72,15 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
// Logging
LoggerModule.forRootAsync({
useFactory: (configService: ConfigService) => ({
pinoHttp: {
transport:
configService.get('NODE_ENV') === 'development'
useFactory: (configService: ConfigService) => {
const isDev = configService.get('NODE_ENV') === 'development';
// LOG_FORMAT=json forces structured JSON output (e.g. inside Docker + Promtail)
const forceJson = configService.get('LOG_FORMAT') === 'json';
const usePretty = isDev && !forceJson;
return {
pinoHttp: {
transport: usePretty
? {
target: 'pino-pretty',
options: {
@ -85,9 +90,21 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
},
}
: undefined,
level: configService.get('NODE_ENV') === 'production' ? 'info' : 'debug',
},
}),
level: isDev ? 'debug' : 'info',
// Redact sensitive fields from logs
redact: {
paths: [
'req.headers.authorization',
'req.headers["x-api-key"]',
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
],
censor: '[REDACTED]',
},
},
};
},
inject: [ConfigService],
}),

View File

@ -2,7 +2,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength, IsEnum, IsOptional } from 'class-validator';
export enum InvitationRole {
ADMIN = 'ADMIN',
MANAGER = 'MANAGER',
USER = 'USER',
VIEWER = 'VIEWER',

View File

@ -0,0 +1,548 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
Download,
RefreshCw,
Filter,
Activity,
AlertTriangle,
Info,
Bug,
Server,
} from 'lucide-react';
const LOG_EXPORTER_URL =
process.env.NEXT_PUBLIC_LOG_EXPORTER_URL || 'http://localhost:3200';
// ─── Types ────────────────────────────────────────────────────────────────────
interface LogEntry {
timestamp: string;
service: string;
level: string;
context: string;
message: string;
reqId: string;
req_method: string;
req_url: string;
res_status: string;
response_time_ms: string;
error: string;
}
interface LogsResponse {
total: number;
query: string;
range: { from: string; to: string };
logs: LogEntry[];
}
interface Filters {
service: string;
level: string;
search: string;
startDate: string;
endDate: string;
limit: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 border border-red-200',
fatal: 'bg-red-200 text-red-900 border border-red-300',
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200',
info: 'bg-blue-100 text-blue-700 border border-blue-200',
debug: 'bg-gray-100 text-gray-600 border border-gray-200',
trace: 'bg-purple-100 text-purple-700 border border-purple-200',
};
const LEVEL_ROW_BG: Record<string, string> = {
error: 'bg-red-50',
fatal: 'bg-red-100',
warn: 'bg-yellow-50',
info: '',
debug: '',
trace: '',
};
function LevelBadge({ level }: { level: string }) {
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}>
{level}
</span>
);
}
function StatCard({
label,
value,
icon: Icon,
color,
}: {
label: string;
value: number | string;
icon: any;
color: string;
}) {
return (
<div className="bg-white rounded-lg border p-4 flex items-center gap-4">
<div className={`p-2 rounded-lg ${color}`}>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-sm text-gray-500">{label}</p>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AdminLogsPage() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [services, setServices] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const [filters, setFilters] = useState<Filters>({
service: 'all',
level: 'all',
search: '',
startDate: oneHourAgo.toISOString().slice(0, 16),
endDate: now.toISOString().slice(0, 16),
limit: '500',
});
// Load available services
useEffect(() => {
fetch(`${LOG_EXPORTER_URL}/api/logs/services`)
.then(r => r.json())
.then(d => setServices(d.services || []))
.catch(() => {});
}, []);
const buildQueryString = useCallback(
(fmt?: string) => {
const params = new URLSearchParams();
if (filters.service !== 'all') params.set('service', filters.service);
if (filters.level !== 'all') params.set('level', filters.level);
if (filters.search) params.set('search', filters.search);
if (filters.startDate) params.set('start', new Date(filters.startDate).toISOString());
if (filters.endDate) params.set('end', new Date(filters.endDate).toISOString());
params.set('limit', filters.limit);
if (fmt) params.set('format', fmt);
return params.toString();
},
[filters],
);
const fetchLogs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString('json')}`,
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
const data: LogsResponse = await res.json();
setLogs(data.logs || []);
setTotal(data.total || 0);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
}, [buildQueryString]);
useEffect(() => {
fetchLogs();
}, []);
const handleExport = async (format: 'json' | 'csv') => {
setExportLoading(true);
try {
const res = await fetch(
`${LOG_EXPORTER_URL}/api/logs/export?${buildQueryString(format)}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err: any) {
setError(err.message);
} finally {
setExportLoading(false);
}
};
// Stats
const countByLevel = (level: string) =>
logs.filter(l => l.level === level).length;
const setFilter = (key: keyof Filters, value: string) =>
setFilters(prev => ({ ...prev, [key]: value }));
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Logs système</h1>
<p className="mt-1 text-sm text-gray-500">
Visualisation et export des logs applicatifs en temps réel
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchLogs}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Actualiser
</button>
<div className="relative group">
<button
disabled={exportLoading || loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
{exportLoading ? 'Export...' : 'Exporter'}
</button>
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
<button
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger CSV
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Télécharger JSON
</button>
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Total logs"
value={total}
icon={Activity}
color="bg-blue-100 text-blue-600"
/>
<StatCard
label="Erreurs"
value={countByLevel('error') + countByLevel('fatal')}
icon={AlertTriangle}
color="bg-red-100 text-red-600"
/>
<StatCard
label="Warnings"
value={countByLevel('warn')}
icon={AlertTriangle}
color="bg-yellow-100 text-yellow-600"
/>
<StatCard
label="Info"
value={countByLevel('info')}
icon={Info}
color="bg-green-100 text-green-600"
/>
</div>
{/* Filters */}
<div className="bg-white rounded-lg border p-4">
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700">Filtres</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Service */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Service</label>
<select
value={filters.service}
onChange={e => setFilter('service', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">Tous</option>
{services.map(s => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{/* Level */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Niveau</label>
<select
value={filters.level}
onChange={e => setFilter('level', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">Tous</option>
<option value="error">Error</option>
<option value="fatal">Fatal</option>
<option value="warn">Warn</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
</select>
</div>
{/* Search */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Recherche</label>
<input
type="text"
placeholder="Texte libre..."
value={filters.search}
onChange={e => setFilter('search', e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* Start */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Début</label>
<input
type="datetime-local"
value={filters.startDate}
onChange={e => setFilter('startDate', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* End */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Fin</label>
<input
type="datetime-local"
value={filters.endDate}
onChange={e => setFilter('endDate', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* Limit + Apply */}
<div className="flex flex-col justify-end gap-2">
<label className="block text-xs font-medium text-gray-500">Limite</label>
<div className="flex gap-2">
<select
value={filters.limit}
onChange={e => setFilter('limit', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:outline-none"
>
<option value="100">100</option>
<option value="500">500</option>
<option value="1000">1000</option>
<option value="5000">5000</option>
</select>
<button
onClick={fetchLogs}
disabled={loading}
className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap"
>
Filtrer
</button>
</div>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg flex items-center gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span className="text-sm">
Impossible de contacter le log-exporter : <strong>{error}</strong>
<br />
<span className="text-xs text-red-500">
Vérifiez que le container log-exporter est démarré sur{' '}
<code className="font-mono">{LOG_EXPORTER_URL}</code>
</span>
</span>
</div>
)}
{/* Table */}
<div className="bg-white rounded-lg border overflow-hidden">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{loading ? 'Chargement...' : `${total} entrée${total !== 1 ? 's' : ''}`}
</span>
</div>
{!loading && logs.length > 0 && (
<span className="text-xs text-gray-400">
Cliquer sur une ligne pour les détails
</span>
)}
</div>
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#34CCCD]" />
</div>
) : logs.length === 0 && !error ? (
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
<Bug className="h-8 w-8" />
<p className="text-sm">Aucun log trouvé pour ces filtres</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Timestamp
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Service
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Niveau
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Contexte
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
Req / Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{logs.map((log, i) => (
<>
<tr
key={i}
onClick={() => setExpandedRow(expandedRow === i ? null : i)}
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<span className="px-2 py-0.5 bg-[#10183A] text-white text-xs rounded font-mono">
{log.service}
</span>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<LevelBadge level={log.level} />
</td>
<td className="px-4 py-2 text-xs text-gray-500 whitespace-nowrap">
{log.context || '—'}
</td>
<td className="px-4 py-2 max-w-xs">
<span className="line-clamp-1 text-gray-800">
{log.error ? (
<span className="text-red-600">{log.error}</span>
) : (
log.message
)}
</span>
</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
{log.req_method && (
<span>
<span className="font-semibold">{log.req_method}</span>{' '}
{log.req_url}{' '}
{log.res_status && (
<span
className={
String(log.res_status).startsWith('5')
? 'text-red-500 font-bold'
: String(log.res_status).startsWith('4')
? 'text-yellow-600 font-bold'
: 'text-green-600'
}
>
{log.res_status}
</span>
)}
</span>
)}
</td>
</tr>
{/* Expanded detail row */}
{expandedRow === i && (
<tr key={`detail-${i}`} className="bg-gray-50">
<td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<span className="font-semibold text-gray-600">Timestamp</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
</div>
{log.reqId && (
<div>
<span className="font-semibold text-gray-600">Request ID</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">{log.reqId}</p>
</div>
)}
{log.response_time_ms && (
<div>
<span className="font-semibold text-gray-600">Durée</span>
<p className="font-mono text-gray-800 mt-0.5">
{log.response_time_ms} ms
</p>
</div>
)}
<div className="col-span-2 md:col-span-4">
<span className="font-semibold text-gray-600">Message complet</span>
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
{log.error
? `[ERROR] ${log.error}\n\n${log.message}`
: log.message}
</pre>
</div>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -80,7 +80,7 @@ export default function UsersManagementPage() {
email: '',
firstName: '',
lastName: '',
role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
@ -635,12 +635,8 @@ export default function UsersManagementPage() {
>
<option value="USER">Utilisateur</option>
<option value="MANAGER">Manager</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
<option value="VIEWER">Lecteur</option>
</select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">Seuls les administrateurs peuvent attribuer le rôle ADMIN</p>
)}
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button

View File

@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
import { Users, Building2, Package, FileText, BarChart3, Settings, ScrollText, type LucideIcon } from 'lucide-react';
interface AdminMenuItem {
name: string;
@ -43,6 +43,12 @@ const adminMenuItems: AdminMenuItem[] = [
icon: BarChart3,
description: 'Importer et gérer les tarifs CSV',
},
{
name: 'Logs système',
href: '/dashboard/admin/logs',
icon: ScrollText,
description: 'Visualiser et télécharger les logs applicatifs',
},
];
export default function AdminPanelDropdown() {

View File

@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src/ ./src/
EXPOSE 3200
USER node
CMD ["node", "src/index.js"]

View File

@ -0,0 +1,15 @@
{
"name": "xpeditis-log-exporter",
"version": "1.0.0",
"description": "Log export API for Xpeditis - queries Loki and exports logs as CSV/JSON",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"express": "^4.18.2",
"node-fetch": "^3.3.2",
"json2csv": "^6.0.0-alpha.2"
}
}

View File

@ -0,0 +1,319 @@
'use strict';
const express = require('express');
const { Transform } = require('stream');
const app = express();
const PORT = process.env.PORT || 3200;
const LOKI_URL = process.env.LOKI_URL || 'http://loki:3100';
const API_KEY = process.env.LOG_EXPORTER_API_KEY;
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Simple API key middleware (optional, enabled when LOG_EXPORTER_API_KEY is set).
*/
function authMiddleware(req, res, next) {
if (!API_KEY) return next();
const key = req.headers['x-api-key'] || req.query.apiKey;
if (key !== API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
/**
* Build a Loki LogQL query from request params.
* Supports: service, level, search (free text filter)
*/
function buildLogQLQuery({ service, level, search }) {
const labelFilters = [];
if (service && service !== 'all') {
const services = service.split(',').map((s) => s.trim()).filter(Boolean);
if (services.length === 1) {
labelFilters.push(`service="${services[0]}"`);
} else {
labelFilters.push(`service=~"${services.join('|')}"`);
}
}
if (level && level !== 'all') {
const levels = level.split(',').map((l) => l.trim()).filter(Boolean);
if (levels.length === 1) {
labelFilters.push(`level="${levels[0]}"`);
} else {
labelFilters.push(`level=~"${levels.join('|')}"`);
}
}
const streamSelector = labelFilters.length > 0
? `{${labelFilters.join(', ')}}`
: `{service=~".+"}`;
const lineFilters = search ? ` |= \`${search}\`` : '';
return `${streamSelector}${lineFilters}`;
}
/**
* Query Loki's query_range endpoint and return flattened log entries.
*/
async function queryLoki({ query, start, end, limit = 5000 }) {
const params = new URLSearchParams({
query,
start: String(start),
end: String(end),
limit: String(Math.min(limit, 5000)),
direction: 'BACKWARD',
});
const url = `${LOKI_URL}/loki/api/v1/query_range?${params}`;
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Loki query failed (${response.status}): ${body}`);
}
const data = await response.json();
if (data.status !== 'success') {
throw new Error(`Loki returned status: ${data.status}`);
}
// Flatten streams → individual log entries
const entries = [];
for (const stream of data.data.result || []) {
const labels = stream.stream || {};
for (const [tsNano, line] of stream.values || []) {
let parsed = {};
try {
parsed = JSON.parse(line);
} catch {
parsed = { msg: line };
}
entries.push({
timestamp: new Date(Math.floor(Number(tsNano) / 1e6)).toISOString(),
service: labels.service || labels.container || 'unknown',
level: labels.level || parsed.level || 'info',
context: labels.context || parsed.context || '',
message: parsed.msg || parsed.message || line,
reqId: parsed.reqId || '',
req_method: parsed.req?.method || '',
req_url: parsed.req?.url || '',
res_status: parsed.res?.statusCode || '',
response_time_ms: parsed.responseTime || '',
error: parsed.err?.message || '',
raw: line,
});
}
}
// Sort by timestamp ascending
entries.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
return entries;
}
/**
* Convert array of objects to CSV string.
*/
function toCSV(entries) {
if (entries.length === 0) return '';
const headers = [
'timestamp', 'service', 'level', 'context',
'message', 'reqId', 'req_method', 'req_url',
'res_status', 'response_time_ms', 'error',
];
const escape = (val) => {
if (val === null || val === undefined) return '';
const str = String(val);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const rows = [headers.join(',')];
for (const entry of entries) {
rows.push(headers.map((h) => escape(entry[h])).join(','));
}
return rows.join('\n');
}
// ─── Routes ──────────────────────────────────────────────────────────────────
app.use(express.json());
// Rate limiting (basic — 60 requests/min per IP)
const requestCounts = new Map();
setInterval(() => requestCounts.clear(), 60000);
app.use((req, res, next) => {
const ip = req.ip;
const count = (requestCounts.get(ip) || 0) + 1;
requestCounts.set(ip, count);
if (count > 60) {
return res.status(429).json({ error: 'Too Many Requests' });
}
next();
});
// CORS for Grafana / frontend
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
if (req.method === 'OPTIONS') return res.sendStatus(204);
next();
});
/**
* GET /health
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', loki: LOKI_URL });
});
/**
* GET /api/logs/services
* Returns the list of services currently emitting logs.
*/
app.get('/api/logs/services', authMiddleware, async (req, res) => {
try {
const response = await fetch(`${LOKI_URL}/loki/api/v1/label/service/values`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
const data = await response.json();
res.json({ services: data.data || [] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/logs/labels
* Returns all available label names.
*/
app.get('/api/logs/labels', authMiddleware, async (req, res) => {
try {
const response = await fetch(`${LOKI_URL}/loki/api/v1/labels`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) throw new Error(`Loki error: ${response.status}`);
const data = await response.json();
res.json({ labels: data.data || [] });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/logs/export
*
* Query params:
* - start : ISO date or Unix timestamp in ns (default: 1h ago)
* - end : ISO date or Unix timestamp in ns (default: now)
* - service : comma-separated service names (default: all)
* - level : comma-separated levels: error,warn,info,debug (default: all)
* - search : free-text search string
* - limit : max number of log lines (default: 5000, max: 5000)
* - format : "json" | "csv" (default: json)
*
* Examples:
* GET /api/logs/export?service=backend&level=error&format=csv
* GET /api/logs/export?start=2024-01-01T00:00:00Z&end=2024-01-02T00:00:00Z&format=json
*/
app.get('/api/logs/export', authMiddleware, async (req, res) => {
try {
const now = Date.now();
const ONE_HOUR_NS = 3600 * 1e9;
const nowNs = BigInt(now) * 1000000n;
const oneHourAgoNs = nowNs - BigInt(ONE_HOUR_NS);
// Parse time range
const parseTime = (val, defaultNs) => {
if (!val) return defaultNs;
// Already in nanoseconds (large number)
if (/^\d{18,}$/.test(val)) return BigInt(val);
// Unix timestamp in seconds or ms
const n = Number(val);
if (!isNaN(n)) {
// seconds → ns
if (n < 1e12) return BigInt(Math.floor(n * 1e9));
// ms → ns
if (n < 1e15) return BigInt(n) * 1000000n;
return BigInt(n);
}
// ISO date string
const ms = Date.parse(val);
if (isNaN(ms)) throw new Error(`Invalid time value: ${val}`);
return BigInt(ms) * 1000000n;
};
const startNs = parseTime(req.query.start, oneHourAgoNs);
const endNs = parseTime(req.query.end, nowNs);
if (endNs <= startNs) {
return res.status(400).json({ error: '"end" must be after "start"' });
}
const format = (req.query.format || 'json').toLowerCase();
if (!['json', 'csv'].includes(format)) {
return res.status(400).json({ error: 'format must be "json" or "csv"' });
}
const limit = Math.min(parseInt(req.query.limit, 10) || 5000, 5000);
const query = buildLogQLQuery({
service: req.query.service,
level: req.query.level,
search: req.query.search,
});
const entries = await queryLoki({
query,
start: startNs.toString(),
end: endNs.toString(),
limit,
});
if (format === 'csv') {
const csv = toCSV(entries);
const filename = `xpeditis-logs-${new Date().toISOString().slice(0, 10)}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(csv);
}
// JSON response
res.json({
total: entries.length,
query,
range: {
from: new Date(Number(startNs / 1000000n)).toISOString(),
to: new Date(Number(endNs / 1000000n)).toISOString(),
},
logs: entries,
});
} catch (err) {
console.error('[log-exporter] Export error:', err.message);
res.status(500).json({ error: err.message });
}
});
// ─── Start ────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`[log-exporter] Listening on port ${PORT}`);
console.log(`[log-exporter] Loki URL: ${LOKI_URL}`);
console.log(`[log-exporter] API key protection: ${API_KEY ? 'enabled' : 'disabled'}`);
});

View File

@ -50,7 +50,10 @@ services:
dockerfile: Dockerfile
container_name: xpeditis-backend-dev
ports:
- "4001:4000"
- "4000:4000"
labels:
logging: promtail
logging.service: backend
depends_on:
postgres:
condition: service_healthy
@ -58,6 +61,8 @@ services:
condition: service_healthy
environment:
NODE_ENV: development
# Force JSON logs in Docker so Promtail can parse them
LOG_FORMAT: json
PORT: 4000
API_PREFIX: api/v1
@ -89,10 +94,10 @@ services:
AWS_S3_BUCKET: xpeditis-csv-rates
# CORS - Allow both localhost (browser) and container network
CORS_ORIGIN: "http://localhost:3001,http://localhost:4001"
CORS_ORIGIN: "http://localhost:3000,http://localhost:4000"
# Application URL
APP_URL: http://localhost:3001
APP_URL: http://localhost:3000
# Security
BCRYPT_ROUNDS: 10
@ -102,19 +107,30 @@ services:
RATE_LIMIT_TTL: 60
RATE_LIMIT_MAX: 100
# SMTP (Brevo)
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587
SMTP_USER: 9637ef001@smtp-brevo.com
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
SMTP_SECURE: "false"
SMTP_FROM: noreply@xpeditis.com
frontend:
build:
context: ./apps/frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost:4001
NEXT_PUBLIC_API_URL: http://localhost:4000
container_name: xpeditis-frontend-dev
ports:
- "3001:3000"
- "3000:3000"
labels:
logging: promtail
logging.service: frontend
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://localhost:4001
NEXT_PUBLIC_API_URL: http://localhost:4000
volumes:
postgres_data:

254
docker-compose.full.yml Normal file
View File

@ -0,0 +1,254 @@
# ─────────────────────────────────────────────────────────────────────────────
# Xpeditis — Full Dev Stack (infrastructure + app + logging)
#
# Usage:
# docker-compose -f docker-compose.full.yml up -d
#
# Exposed ports:
# - Frontend: http://localhost:3000
# - Backend: http://localhost:4000 (Swagger: /api/docs)
# - Grafana: http://localhost:3030 (admin / xpeditis_grafana)
# - Loki: http://localhost:3100 (internal)
# - Promtail: http://localhost:9080 (internal)
# - log-exporter: http://localhost:3200
# - MinIO: http://localhost:9001 (console)
# ─────────────────────────────────────────────────────────────────────────────
version: '3.8'
services:
# ─── Infrastructure ────────────────────────────────────────────────────────
postgres:
image: postgres:15-alpine
container_name: xpeditis-postgres-dev
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: xpeditis_dev
POSTGRES_USER: xpeditis
POSTGRES_PASSWORD: xpeditis_dev_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xpeditis"]
interval: 10s
timeout: 5s
retries: 5
networks:
- xpeditis-network
redis:
image: redis:7-alpine
container_name: xpeditis-redis-dev
ports:
- "6379:6379"
command: redis-server --requirepass xpeditis_redis_password
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- xpeditis-network
minio:
image: minio/minio:latest
container_name: xpeditis-minio-dev
ports:
- "9000:9000"
- "9001:9001"
command: server /data --console-address ":9001"
volumes:
- minio_data:/data
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
networks:
- xpeditis-network
# ─── Application ───────────────────────────────────────────────────────────
backend:
build:
context: ./apps/backend
dockerfile: Dockerfile
container_name: xpeditis-backend-dev
ports:
- "4000:4000"
labels:
logging: promtail
logging.service: backend
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
NODE_ENV: development
LOG_FORMAT: json
PORT: 4000
API_PREFIX: api/v1
# Database
DATABASE_HOST: postgres
DATABASE_PORT: 5432
DATABASE_USER: xpeditis
DATABASE_PASSWORD: xpeditis_dev_password
DATABASE_NAME: xpeditis_dev
DATABASE_SYNC: false
DATABASE_LOGGING: true
# Redis
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: xpeditis_redis_password
REDIS_DB: 0
# JWT
JWT_SECRET: dev-secret-jwt-key-for-docker
JWT_ACCESS_EXPIRATION: 15m
JWT_REFRESH_EXPIRATION: 7d
# S3/MinIO
AWS_S3_ENDPOINT: http://minio:9000
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: minioadmin
AWS_SECRET_ACCESS_KEY: minioadmin
AWS_S3_BUCKET: xpeditis-csv-rates
# CORS
CORS_ORIGIN: "http://localhost:3000,http://localhost:4000"
# Application URL
APP_URL: http://localhost:3000
# Security
BCRYPT_ROUNDS: 10
SESSION_TIMEOUT_MS: 7200000
# Rate Limiting
RATE_LIMIT_TTL: 60
RATE_LIMIT_MAX: 100
# SMTP (Brevo)
SMTP_HOST: smtp-relay.brevo.com
SMTP_PORT: 587
SMTP_USER: 9637ef001@smtp-brevo.com
SMTP_PASS: xsmtpsib-8d965bda028cd63bed868a119f9e0330485204bf9f4e1f92a3a11c8e61000722-xUYUSrGGxhMqlUcu
SMTP_SECURE: "false"
SMTP_FROM: noreply@xpeditis.com
networks:
- xpeditis-network
frontend:
build:
context: ./apps/frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost:4000
container_name: xpeditis-frontend-dev
ports:
- "3000:3000"
labels:
logging: promtail
logging.service: frontend
depends_on:
- backend
environment:
NEXT_PUBLIC_API_URL: http://localhost:4000
networks:
- xpeditis-network
# ─── Logging Stack ─────────────────────────────────────────────────────────
loki:
image: grafana/loki:3.0.0
container_name: xpeditis-loki
restart: unless-stopped
ports:
- '3100:3100'
volumes:
- ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
promtail:
image: grafana/promtail:3.0.0
container_name: xpeditis-promtail
restart: unless-stopped
ports:
- '9080:9080'
volumes:
- ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
command: -config.file=/etc/promtail/config.yml
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
grafana:
image: grafana/grafana:11.0.0
container_name: xpeditis-grafana
restart: unless-stopped
ports:
- '3030:3000'
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_AUTH_ANONYMOUS_ENABLED: 'false'
GF_SERVER_ROOT_URL: http://localhost:3030
GF_ANALYTICS_REPORTING_ENABLED: 'false'
GF_ANALYTICS_CHECK_FOR_UPDATES: 'false'
volumes:
- ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
log-exporter:
build:
context: ./apps/log-exporter
dockerfile: Dockerfile
container_name: xpeditis-log-exporter
restart: unless-stopped
ports:
- '3200:3200'
environment:
PORT: 3200
LOKI_URL: http://loki:3100
# Optional: set LOG_EXPORTER_API_KEY to require x-api-key header
# LOG_EXPORTER_API_KEY: your-secret-key-here
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
volumes:
postgres_data:
redis_data:
minio_data:
loki_data:
driver: local
grafana_data:
driver: local
networks:
xpeditis-network:
name: xpeditis-network

115
docker-compose.logging.yml Normal file
View File

@ -0,0 +1,115 @@
# ─────────────────────────────────────────────────────────────────────────────
# Xpeditis — Centralized Logging Stack
#
# Usage (standalone):
# docker-compose -f docker-compose.yml -f docker-compose.logging.yml up -d
#
# Usage (full dev environment with logging):
# docker-compose -f docker-compose.dev.yml -f docker-compose.logging.yml up -d
#
# Exposed ports:
# - Grafana: http://localhost:3000 (admin / xpeditis_grafana)
# - Loki: http://localhost:3100 (internal use only)
# - Promtail: http://localhost:9080 (internal use only)
# - log-exporter: http://localhost:3200 (export API)
# ─────────────────────────────────────────────────────────────────────────────
services:
# ─── Loki — Log storage & query engine ────────────────────────────────────
loki:
image: grafana/loki:3.0.0
container_name: xpeditis-loki
restart: unless-stopped
ports:
- '3100:3100'
volumes:
- ./infra/logging/loki/loki-config.yml:/etc/loki/local-config.yaml:ro
- loki_data:/loki
command: -config.file=/etc/loki/local-config.yaml
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3100/ready || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
# ─── Promtail — Docker log collector ──────────────────────────────────────
promtail:
image: grafana/promtail:3.0.0
container_name: xpeditis-promtail
restart: unless-stopped
ports:
- '9080:9080'
volumes:
- ./infra/logging/promtail/promtail-config.yml:/etc/promtail/config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
# Note: /var/lib/docker/containers is not needed with docker_sd_configs (uses Docker API)
command: -config.file=/etc/promtail/config.yml
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
# ─── Grafana — Visualization ───────────────────────────────────────────────
grafana:
image: grafana/grafana:11.0.0
container_name: xpeditis-grafana
restart: unless-stopped
ports:
- '3030:3000'
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: xpeditis_grafana
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_AUTH_ANONYMOUS_ENABLED: 'false'
GF_SERVER_ROOT_URL: http://localhost:3030
# Disable telemetry
GF_ANALYTICS_REPORTING_ENABLED: 'false'
GF_ANALYTICS_CHECK_FOR_UPDATES: 'false'
volumes:
- ./infra/logging/grafana/provisioning:/etc/grafana/provisioning:ro
- grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --quiet --tries=1 --spider http://localhost:3000/api/health || exit 1']
interval: 15s
timeout: 5s
retries: 5
networks:
- xpeditis-network
# ─── log-exporter — REST export API ───────────────────────────────────────
log-exporter:
build:
context: ./apps/log-exporter
dockerfile: Dockerfile
container_name: xpeditis-log-exporter
restart: unless-stopped
ports:
- '3200:3200'
environment:
PORT: 3200
LOKI_URL: http://loki:3100
# Optional: set LOG_EXPORTER_API_KEY to require x-api-key header
# LOG_EXPORTER_API_KEY: your-secret-key-here
depends_on:
loki:
condition: service_healthy
networks:
- xpeditis-network
volumes:
loki_data:
driver: local
grafana_data:
driver: local
networks:
xpeditis-network:
name: xpeditis-network
# Re-uses the network created by docker-compose.yml / docker-compose.dev.yml.
# If starting this stack alone, the network is created automatically.

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: Xpeditis Dashboards
orgId: 1
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false

View File

@ -0,0 +1,636 @@
{
"title": "Xpeditis — Logs & Monitoring",
"uid": "xpeditis-logs",
"description": "Dashboard complet — logs backend/frontend, métriques HTTP, erreurs",
"tags": ["xpeditis", "logs", "backend", "frontend"],
"timezone": "browser",
"refresh": "30s",
"schemaVersion": 38,
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"editable": true,
"version": 1,
"weekStart": "",
"links": [],
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0,211,255,1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"templating": {
"list": [
{
"name": "service",
"label": "Service",
"type": "query",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"query": "label_values(service)",
"refresh": 2,
"sort": 1,
"includeAll": true,
"allValue": ".+",
"multi": false,
"hide": 0,
"current": {},
"options": []
},
{
"name": "level",
"label": "Niveau",
"type": "custom",
"query": "All : .+, error : error, fatal : fatal, warn : warn, info : info, debug : debug",
"includeAll": false,
"multi": false,
"hide": 0,
"current": { "text": "All", "value": ".+" },
"options": [
{ "text": "All", "value": ".+", "selected": true },
{ "text": "error", "value": "error", "selected": false },
{ "text": "fatal", "value": "fatal", "selected": false },
{ "text": "warn", "value": "warn", "selected": false },
{ "text": "info", "value": "info", "selected": false },
{ "text": "debug", "value": "debug", "selected": false }
]
},
{
"name": "search",
"label": "Recherche",
"type": "textbox",
"query": "",
"hide": 0,
"current": { "text": "", "value": "" },
"options": [{ "selected": true, "text": "", "value": "" }]
}
]
},
"panels": [
{
"id": 100,
"type": "row",
"title": "Vue d'ensemble",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }
},
{
"id": 1,
"title": "Total logs",
"type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"mappings": []
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=~\"$service\"} [$__range]))",
"legendFormat": "Total",
"instant": true
}
]
},
{
"id": 2,
"title": "Erreurs & Fatal",
"type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 1 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"mappings": []
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=~\"$service\", level=~\"error|fatal\"} [$__range]))",
"legendFormat": "Erreurs",
"instant": true
}
]
},
{
"id": 3,
"title": "Warnings",
"type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 1 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 1 }] },
"mappings": []
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=~\"$service\", level=\"warn\"} [$__range]))",
"legendFormat": "Warnings",
"instant": true
}
]
},
{
"id": 4,
"title": "Info",
"type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 1 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "fixedColor": "blue", "mode": "fixed" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] },
"mappings": []
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=~\"$service\", level=\"info\"} [$__range]))",
"legendFormat": "Info",
"instant": true
}
]
},
{
"id": 5,
"title": "Requêtes HTTP 5xx",
"type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 1 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] },
"mappings": []
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__range]))",
"legendFormat": "5xx",
"instant": true
}
]
},
{
"id": 6,
"title": "Temps réponse moyen (ms)",
"type": "stat",
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
"orientation": "auto",
"textMode": "auto",
"colorMode": "background",
"graphMode": "area",
"justifyMode": "center"
},
"fieldConfig": {
"defaults": {
"color": { "mode": "thresholds" },
"unit": "ms",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] },
"mappings": []
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__range]))",
"legendFormat": "Avg",
"instant": true
}
]
},
{
"id": 200,
"type": "row",
"title": "Volume des logs",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }
},
{
"id": 7,
"title": "Volume par niveau",
"type": "timeseries",
"gridPos": { "h": 8, "w": 14, "x": 0, "y": 6 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "bars",
"fillOpacity": 80,
"stacking": { "group": "A", "mode": "normal" },
"lineWidth": 1,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false
},
"unit": "short",
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "error" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "fatal" }, "properties": [{ "id": "color", "value": { "fixedColor": "dark-red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "warn" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "info" }, "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "debug" }, "properties": [{ "id": "color", "value": { "fixedColor": "gray", "mode": "fixed" } }] }
]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum by (level) (count_over_time({service=~\"$service\", level=~\".+\"} [$__interval]))",
"legendFormat": "{{level}}"
}
]
},
{
"id": 8,
"title": "Volume par service",
"type": "timeseries",
"gridPos": { "h": 8, "w": 10, "x": 14, "y": 6 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"legend": { "calcs": ["sum"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "bars",
"fillOpacity": 60,
"stacking": { "group": "A", "mode": "normal" },
"lineWidth": 1,
"showPoints": "never",
"spanNulls": false
},
"unit": "short",
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum by (service) (count_over_time({service=~\"$service\"} [$__interval]))",
"legendFormat": "{{service}}"
}
]
},
{
"id": 300,
"type": "row",
"title": "HTTP — Backend",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }
},
{
"id": 9,
"title": "Taux d'erreur HTTP",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "line",
"fillOpacity": 20,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false
},
"unit": "short",
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
},
"overrides": [
{ "matcher": { "id": "byName", "options": "5xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "4xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] },
{ "matcher": { "id": "byName", "options": "2xx" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }] }
]
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 500 [$__interval]))",
"legendFormat": "5xx"
},
{
"refId": "B",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 400 < 500 [$__interval]))",
"legendFormat": "4xx"
},
{
"refId": "C",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "sum(count_over_time({service=\"backend\"} | json | res_statusCode >= 200 < 300 [$__interval]))",
"legendFormat": "2xx"
}
]
},
{
"id": 10,
"title": "Temps de réponse (ms)",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"legend": { "calcs": ["max", "mean"], "displayMode": "list", "placement": "bottom", "showLegend": true },
"tooltip": { "mode": "multi", "sort": "desc" }
},
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"drawStyle": "line",
"fillOpacity": 10,
"lineWidth": 2,
"pointSize": 5,
"showPoints": "never",
"spanNulls": false
},
"unit": "ms",
"mappings": [],
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 500 }, { "color": "red", "value": 2000 }] }
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "avg(avg_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))",
"legendFormat": "Moy"
},
{
"refId": "B",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "max(max_over_time({service=\"backend\"} | json | unwrap responseTime [$__interval]))",
"legendFormat": "Max"
}
]
},
{
"id": 400,
"type": "row",
"title": "Logs — Flux en direct",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 }
},
{
"id": 11,
"title": "Backend — Logs",
"type": "logs",
"gridPos": { "h": 14, "w": 12, "x": 0, "y": 24 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=\"backend\", level=~\"$level\"} |= \"$search\"",
"legendFormat": ""
}
]
},
{
"id": 12,
"title": "Frontend — Logs",
"type": "logs",
"gridPos": { "h": 14, "w": 12, "x": 12, "y": 24 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": false
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=\"frontend\", level=~\"$level\"} |= \"$search\"",
"legendFormat": ""
}
]
},
{
"id": 500,
"type": "row",
"title": "Tous les logs filtrés",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 38 }
},
{
"id": 13,
"title": "Flux filtré — $service / $level",
"description": "Utilisez les variables en haut pour filtrer par service, niveau ou mot-clé",
"type": "logs",
"gridPos": { "h": 14, "w": 24, "x": 0, "y": 39 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "none",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": true,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=~\"$service\", level=~\"$level\"} |= \"$search\"",
"legendFormat": ""
}
]
},
{
"id": 600,
"type": "row",
"title": "Erreurs & Exceptions",
"collapsed": false,
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 53 }
},
{
"id": 14,
"title": "Erreurs — Backend",
"type": "logs",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 54 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "signature",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=\"backend\", level=~\"error|fatal\"}",
"legendFormat": ""
}
]
},
{
"id": 15,
"title": "Erreurs — Frontend",
"type": "logs",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 54 },
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"options": {
"dedupStrategy": "signature",
"enableLogDetails": true,
"prettifyLogMessage": true,
"showCommonLabels": false,
"showLabels": false,
"showTime": true,
"sortOrder": "Descending",
"wrapLogMessage": true
},
"targets": [
{
"refId": "A",
"datasource": { "type": "loki", "uid": "loki-xpeditis" },
"expr": "{service=\"frontend\", level=~\"error|fatal\"}",
"legendFormat": ""
}
]
}
]
}

View File

@ -0,0 +1,19 @@
apiVersion: 1
datasources:
- name: Loki
uid: loki-xpeditis
type: loki
access: proxy
url: http://loki:3100
isDefault: true
version: 1
editable: false
jsonData:
maxLines: 1000
timeout: 60
derivedFields:
- datasourceUid: ''
matcherRegex: '"reqId":"([^"]+)"'
name: RequestID
url: ''

View File

@ -0,0 +1,62 @@
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
log_level: warn
# Memberlist-based ring coordination — required for single-node Loki 3.x
memberlist:
bind_port: 7946
join_members:
- 127.0.0.1:7946
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: memberlist
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
allow_structured_metadata: true
volume_enabled: true
retention_period: 744h # 31 days
reject_old_samples: true
reject_old_samples_max_age: 168h # Accept logs up to 7 days old
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
max_entries_limit_per_query: 5000
compactor:
working_directory: /loki/compactor
compaction_interval: 10m
retention_enabled: true
retention_delete_delay: 2h
retention_delete_worker_count: 150
delete_request_store: filesystem
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
analytics:
reporting_enabled: false

View File

@ -0,0 +1,70 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: warn
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
timeout: 10s
scrape_configs:
# ─── Docker container log collection (Mac-compatible via Docker socket API) ─
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
filters:
# Only collect containers with label: logging=promtail
# Add this label to backend and frontend in docker-compose.dev.yml
- name: label
values: ['logging=promtail']
relabel_configs:
# Use docker-compose service name as the "service" label
- source_labels: ['__meta_docker_container_label_com_docker_compose_service']
target_label: service
# Keep container name for context
- source_labels: ['__meta_docker_container_name']
regex: '/?(.*)'
replacement: '${1}'
target_label: container
# Log stream (stdout / stderr)
- source_labels: ['__meta_docker_container_log_stream']
target_label: stream
pipeline_stages:
# Drop entries older than 15 min to avoid replaying full container log history
- drop:
older_than: 15m
drop_counter_reason: entry_too_old
# Drop noisy health-check / ping lines
- drop:
expression: 'GET /(health|metrics|minio/health)'
# Try to parse JSON (NestJS/pino output)
- json:
expressions:
level: level
msg: msg
context: context
reqId: reqId
# Promote parsed fields as Loki labels
- labels:
level:
context:
# Map pino numeric levels to strings
- template:
source: level
template: >-
{{ if eq .Value "10" }}trace{{ else if eq .Value "20" }}debug{{ else if eq .Value "30" }}info{{ else if eq .Value "40" }}warn{{ else if eq .Value "50" }}error{{ else if eq .Value "60" }}fatal{{ else }}{{ .Value }}{{ end }}
- labels:
level: