API logging schema: код, который мы копируем в каждый проект

Автор: 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, а не в теле строки.

Что делать после внедрения


  1. Три недели собираем baseline — p95 latency и среднее bytes для каждого endpoint.
  2. На 3-й неделе настраиваем алерты с порогами от baseline.
  3. Первые 1–2 инцидента покажут, какие поля ещё нужно добавить. Добавляем. Итерируем.
  4. К месяцу 2 — работающая observability-платформа, которая ловит инциденты до звонка клиента.

Если хочется быстрее


48-часовой аудит — мы смотрим ваш стек, даём конкретный patch-файл для middleware и готовые Loki-дашборды. Free, без обязательств.


webgoodpeople.com/api-audit

Расскажите нам о своем проекте

Наши офисы

  • Россия
    Россия, Санкт-Петербург, Рижская улица, 5, корп. 1 офис 402
    +7 (967) 555-90-32
  • Казахстан
    Алма-Ата
    +7 (707) 340-29-12