API Logging Schema: The Code We Copy Into Every Project
Author: WebGoodPeople
В статье от 21 апреля мы разобрали, почему 5-польная схема API-лога ловит «тихие» инциденты. Эта статья — практическая. Как именно внедрить эту схему в существующий стек за 1–2 дня работы, без переписывания приложения и без простоя.
Покажу три варианта кода (Node.js/Express, Next.js API routes, Python/FastAPI), конфигурацию Grafana Loki, пример алерт-правил и реальный расчёт стоимости логов.
Что именно мы логируем
Напомню целевую схему:
req_id UUID v4, сгенерированный на входе endpoint Логический идентификатор (/api/catalog/filter), без параметров status HTTP статус (200, 404, 502) bytes Размер тела ответа в байтах latency_ms Время обработки запроса в миллисекундах data_version Версия данных, из которых сформирован ответ
Плюс стандартные поля: timestamp, user_id (если авторизован), method, query_params (отдельно, не в endpoint).
Node.js / Express middleware
Самый простой вариант. Одно middleware, подключается в начало цепочки.
// middleware/request-logger.js
const { randomUUID } = require('crypto');
const pino = require('pino')();
function requestLogger(req, res, next) {
req.reqId = req.headers['x-request-id'] || randomUUID();
req.startTime = process.hrtime.bigint();
const originalSend = res.send;
let bytes = 0;
res.send = function (body) {
bytes = Buffer.byteLength(body || '', 'utf8');
return originalSend.call(this, body);
};
res.on('finish', () => {
const latencyMs = Number(process.hrtime.bigint() - req.startTime) / 1e6;
pino.info({
req_id: req.reqId,
endpoint: req.route?.path || req.path,
method: req.method,
status: res.statusCode,
bytes,
latency_ms: Math.round(latencyMs),
data_version: res.getHeader('x-data-version') || null,
user_id: req.user?.id || null,
});
});
next();
}
module.exports = requestLogger;
Подключение в app.js:
const requestLogger = require('./middleware/request-logger');
app.use(requestLogger);
Поле data_version ставится в хендлере через res.setHeader('x-data-version', indexVersion) — обязательно на всех эндпоинтах, которые читают из ES/кэша/MV.
Next.js API routes
Для Next.js (App Router или Pages) оборачиваем хендлер:
// lib/with-logging.ts
import { randomUUID } from 'crypto';
import pino from 'pino';
import { NextRequest, NextResponse } from 'next/server';
const log = pino();
export function withLogging(
handler: (req: NextRequest) => Promise
) {
return async function wrapped(req: NextRequest) {
const reqId = req.headers.get('x-request-id') || randomUUID();
const start = process.hrtime.bigint();
const endpoint = new URL(req.url).pathname;
let response: NextResponse;
try {
response = await handler(req);
} catch (err) {
log.error({ req_id: reqId, endpoint, err: String(err) });
throw err;
}
const bytes = Number(response.headers.get('content-length') || 0);
const latencyMs = Number(process.hrtime.bigint() - start) / 1e6;
log.info({
req_id: reqId,
endpoint,
method: req.method,
status: response.status,
bytes,
latency_ms: Math.round(latencyMs),
data_version: response.headers.get('x-data-version'),
});
return response;
};
} Использование в route-хендлере:
// app/api/catalog/filter/route.ts
import { withLogging } from '@/lib/with-logging';
export const POST = withLogging(async (req) => {
const data = await searchCatalog(req);
return NextResponse.json(data, {
headers: { 'x-data-version': `index-v${CURRENT_INDEX_VERSION}` }
});
});Python / FastAPI middleware
# middleware.py
import time
import uuid
import json
import logging
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
log = logging.getLogger("api")
class RequestLogger(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
req_id = request.headers.get("x-request-id") or str(uuid.uuid4())
request.state.req_id = req_id
start = time.perf_counter()
response = await call_next(request)
body_iter = [chunk async for chunk in response.body_iterator]
body = b"".join(body_iter)
bytes_len = len(body)
log.info(json.dumps({
"req_id": req_id,
"endpoint": request.url.path,
"method": request.method,
"status": response.status_code,
"bytes": bytes_len,
"latency_ms": int((time.perf_counter() - start) * 1000),
"data_version": response.headers.get("x-data-version"),
}))
from starlette.responses import Response
return Response(
content=body, status_code=response.status_code,
headers=dict(response.headers), media_type=response.media_type,
)Доставка в Loki (Grafana Labs)
Рекомендуем Promtail или Fluent Bit. Конфиг Promtail:
# promtail.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: api-logs
static_configs:
- targets: [localhost]
labels:
job: api
env: production
__path__: /var/log/api/*.json
pipeline_stages:
- json:
expressions:
req_id: req_id
endpoint: endpoint
status: status
latency_ms: latency_ms
data_version: data_version
- labels:
endpoint:
status:
В Loki для каждого запроса будет full-text search + label index по endpoint и status.
Query-примеры в LogQL
Найти все «тихие» 200-с-пустотой за последний час:
{job="api", status="200"} | json | bytes < 200 and endpoint!~".*health.*"
Трассировка одного запроса через все сервисы:
{env="production"} | json | req_id="a3f1-4c2b-..."
p95 latency по endpoint за 15 минут:
quantile_over_time(0.95,
{job="api"} | json | unwrap latency_ms [15m]
) by (endpoint)
Alert-правила
В Grafana Alerting или Alertmanager:
Empty-body alert (ловит Чёрную пятницу):
- alert: EmptyBodyHighRate
expr: |
sum(rate({job="api", status="200"} |
json | bytes < 200 [5m])) by (endpoint)
> 0.2 * sum(rate({job="api", status="200"} [5m])) by (endpoint)
for: 2m
labels: { severity: critical }
annotations:
summary: "Endpoint {{ $labels.endpoint }} >20% empty 200 responses"
Result-count drop alert (производная от bytes):
- alert: ResponseBytesDropped
expr: |
avg_over_time({job="api"} | json | unwrap bytes [5m])
< 0.3 * avg_over_time({job="api"} | json | unwrap bytes [1h])
for: 3mСтоимость логов (важный расчёт)
Типовой e-com: 500k сессий/мес × 20 API-запросов/сессия = 10M записей/мес.
Средняя запись: ~300 байт (JSON с 8 полями) = 3 GB/мес.
Grafana Cloud Loki: ~$0.50 за GB/мес для retention 30 дней. Общая стоимость ~$1.5/мес.
Self-hosted Loki на $40 VPS: с retention 90 дней и 15 GB — влезает без проблем.
Бюджет — копеечный. Главное — не логировать тело ответа целиком (оно обычно в 100 раз больше структурной записи).
Что мы НЕ рекомендуем
Не логируйте полные тела. Логируйте только размер. Если нужна детальная трассировка конкретного запроса — используйте sampling (1 из 1000 записей пишется с body).
Не логируйте PII. Никаких email, телефонов, адресов в логах. user_id — OK. Всё остальное — только по явному бизнес-кейсу с согласованием security.
Не используйте unstructured (free-text) логи. «ERROR: Something went wrong» — бесполезно. Всё в структурированный JSON. Любое текстовое сообщение должно быть в поле message, а не в теле строки.
Что делать после внедрения
- Три недели собираем baseline — p95 latency и среднее
bytesдля каждого endpoint. - На 3-й неделе настраиваем алерты с порогами от baseline.
- Первые 1–2 инцидента покажут, какие поля ещё нужно добавить. Добавляем. Итерируем.
- К месяцу 2 — работающая observability-платформа, которая ловит инциденты до звонка клиента.
Если хочется быстрее
48-часовой аудит — мы смотрим ваш стек, даём конкретный patch-файл для middleware и готовые Loki-дашборды. Free, без обязательств.