Серверные логи и Web Vitals: структурные логи с request id и beacon, который называет плохой деплой
Логи серверных компонентов идут только в stdout сервера — после миграции с pages команды теряют улики. Структурный pino-JSON с request id из middleware на каждой строке их возвращает; useReportWebVitals шлёт полевые LCP и INP с id деплоя, который их и испортил.
Через три недели после выкатки миграции с pages на App Router платёжный провайдер начинает отклонять 4% попыток чекаута. Дежурный инженер делает то, что всегда работало: открывает консоль браузера на воспроизведении. Пусто. Логика чекаута теперь — серверный компонент: его вызовы console.log исправно печатались в stdout serverless-функции, неструктурно, где шиппер логов режет каждый многострочный дамп объекта на семь строк-сирот, а удержание — 24 часа. Request id нет, поэтому три переплетённых попытки чекаута в уцелевших логах не различить. Тот же деплой просадил LCP страницы товара с 2,1 с до 3,4 с — Lighthouse в CI оставался зелёным, потому что лаборатория и поле — разные вещи, — и девять дней этого никто не замечал, пока конверсия не упала настолько, что первыми спросили финансы. Два сбоя, одна корневая причина: команда мигрировала место, где исполняется код, не мигрировав место, куда уходят улики.
Ваши логи переехали — одна JSON-строка на событие, на сервере
Механизм «пропавших логов» — сама модель рендеринга. В pages-роутере код компонента выполнялся дважды — серверный рендер, затем гидрация в браузере, — поэтому его console.log появлялся в консоли браузера, и отладка там работала. Серверный компонент на клиент не отправляется никогда: его код исполняется только на сервере, и его логи существуют только в stdout серверного процесса. Ничего не потерялось — всё переехало туда, куда команда не смотрела: на serverless это часто платформенный сток логов с удержанием, измеряемым часами. Пункт чек-листа миграции, который никто не записывает: переместите глаза.
Когда вы уже смотрите в серверный stdout, следующим отказывает формат. Увидев payment failed без привязки к конкретному чекауту, поймите: это не проблема поиска — это проблема структуры. console.log(order) печатает многострочный дамп объекта; шипперы логов построчны, и одно событие превращается в семь фрагментов-сирот — незапрашиваемых и полуатрибутированных. Лечение структурное, не косметическое: один JSON-объект на строку на событие — модель pino — с уровнями, child-логгерами и встроенной редакцией:
// lib/logger.ts
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie', '*.email', '*.token'],
censor: '[redacted]',
},
});Блок redact — это дисциплина PII, закреплённая в логгере, чтобы её нельзя было забыть в точке вызова. Логи — самое протекающее хранилище из всех, что вы эксплуатируете: они переживают строку в базе (холодное хранение, копии у вендора, ноутбуки разработчиков), они минуют права доступа, которые есть у таблиц, а «мы логировали всё тело запроса для отладки» — это то, как токены и почты попадают в сторонний индексатор. Редактируйте централизованно; логируйте идентификаторы (user id, id заказа), но никогда — идентифицирующее содержимое.
После миграции чекаута с pages на серверные компоненты App Router инженеры жалуются: «наше логирование пропало» — консоль браузера пуста во время сбоев. Что произошло на самом деле?
Корреляция: один request id на каждой строке
Структурные логи конкурентного трафика без корреляции — всё ещё шредер: три чекаута переплетаются, и payment failed не принадлежит никому. Лечение — id, отчеканенный один раз на первом хопе и приклеенный к каждой строке. Middleware и есть первый хоп — примите входящий x-request-id от балансировщика, если он есть (тогда ваш id склеится и с логами LB), иначе отчеканьте свой:
// middleware.ts — каждый запрос получает id до того, как кто-либо что-то залогирует
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(req: NextRequest) {
const requestId = req.headers.get('x-request-id') ?? crypto.randomUUID();
const headers = new Headers(req.headers);
headers.set('x-request-id', requestId);
const res = NextResponse.next({ request: { headers } });
res.headers.set('x-request-id', requestId); // эхо клиенту — тикеты поддержки его цитируют
return res;
}// В любом серверном компоненте, действии или route handler
import { headers } from 'next/headers';
import { logger } from '~/lib/logger';
const log = logger.child({ reqId: (await headers()).get('x-request-id') });
log.error({ provider: 'stripe', code: 'card_declined' }, 'payment authorization failed');Для глубокого служебного кода, которому нужно логировать без протаскивания id через десять сигнатур, нодовский AsyncLocalStorage несёт контекст запроса через границы await — войдите в store на входе handler-а или действия, и любая функция ниже достанет привязанный логгер. Честная оговорка: middleware может выполняться в отдельном edge-изоляте, не в вашем Node-процессе рендера, поэтому контекст ALS не перекрывает разрыв между middleware и рендером — мостом между процессами служит заголовок; ALS — удобство внутри одного. Дальше решите, что логирует каждый слой, потому что слои работают с разной частотой: middleware выполняется на каждом запросе и заслуживает максимум одну короткую строку решений маршрутизации или авторизации; серверные действия логируют намерение мутации и результат с id актора — это ваш аудиторский след; серверные компоненты логируют только сбои загрузки данных (рендеры слишком часты, чтобы пересказывать успех); route handler-ы — однострочную сводку запроса. И здесь окупается digest из прошлого урока: onRequestError отправляет digest с приклеенным request id, и тикет пользователя с любым из двух разворачивается в полную историю одним запросом.
Полевые vitals, beacon-ом домой
Серверная половина теперь крепка; клиентскую вскрывает девятидневная регрессия LCP (Largest Contentful Paint — время до отрисовки крупнейшего элемента страницы). Lighthouse в CI — лабораторное число: одна синтетическая машина, один профиль сети, пустой кеш. Ваши пользователи — распределение устройств и сетей, и единственное честное число производительности — полевой p75. Next.js даёт хук сбора — useReportWebVitals срабатывает в браузере для каждой метрики по мере её готовности; вы пересылаете её через navigator.sendBeacon, который переживает выгрузку страницы (обычный fetch был бы отменён на полпути, когда пользователь уходит, — ровно в момент, когда финализируются данные LCP):
'use client';
// app/vitals.tsx — монтируется один раз в корневом layout
import { useReportWebVitals } from 'next/web-vitals';
export function Vitals() {
useReportWebVitals((m) => {
const body = JSON.stringify({
name: m.name, // 'LCP' | 'INP' | 'CLS' | ...
value: m.value,
rating: m.rating, // 'good' | 'needs-improvement' | 'poor'
route: location.pathname,
deploy: process.env.NEXT_PUBLIC_DEPLOY_ID, // ключ склейки, называющий плохой деплой
});
if (!navigator.sendBeacon('/api/vitals', body)) {
fetch('/api/vitals', { method: 'POST', body, keepalive: true });
}
});
return null;
}Поле deploy — весь смысл затеи. Агрегируйте p75 по маршруту и id деплоя, и «LCP стал хуже» превращается в «деплой 7f3a сдвинул LCP страницы товара с 2,1 с до 3,4 с» — однострочный group-by вместо девяти дней дрейфа. Сверяйтесь со стандартными порогами на p75: LCP хорош до 2,5 с, плох после 4 с; INP хорош до 200 мс, плох после 500 мс; CLS хорош до 0,1, плох после 0,25. И замкните контур политикой алертов, уважающей человеческое внимание: алерт — на частоты, пейдж — на ущерб пользователям. Одиночная строка с 500 — ни то ни другое; частота ошибок по маршруту, пробившая бюджет, — алерт; частота сбоев чекаута или p75 страницы товара за порогом — пейдж. Пейджинг по строкам логов приучает дежурного игнорировать пейджер — самый дорогой провал наблюдаемости из всех.
▸Почему это работает
Почему p75, а не среднее и не p50? Среднее прячет бимодальную реальность — половина пользователей на быстрых ноутбуках, половина на средних телефонах через сотовую сеть, — а p50 буквально описывает везучую половину. p75 говорит: три четверти пользовательских опытов как минимум настолько хороши — это достаточно строго, чтобы вытащить болезненный хвост, но не позволяет сломанному расширению одного пользователя вас разбудить. К тому же это перцентиль, на котором определены пороги Core Web Vitals, — ваши числа остаются сравнимыми с экосистемой.
Lighthouse в CI зелёный, но полевой p75 LCP страницы товара держится на 3,4 с девять дней, прежде чем кто-то замечает. Какого звена не хватало, чтобы поймать это в первый день?
- 01Почему console.log в серверном компоненте никогда не появляется в браузере и на что команды на pages-роутере полагались, сами того не зная?
- 02Пройдите полную историю корреляции: как тикет поддержки становится одним запросом и как регрессия vitals называет свой деплой?
Миграция с pages переместила место исполнения кода компонентов, и улики переехали вместе с ним: серверный компонент не отправляется в браузер, его console.log печатает только в серверный stdout — в pages гидрация перевыполняла тот же код на клиенте, и именно эта привычка делала отладку через консоль браузера надёжной на вид. Смотреть в правильное место — половина лечения; вторая половина — формат и дисциплина: одна pino-JSON-строка на событие (шипперы построчны — многострочные дампы объектов рвутся на фрагменты-сироты), уровни и редакция, закреплённая в логгере, потому что логи переживают базы данных и минуют их права доступа, — логируйте идентификаторы, никогда — идентифицирующее содержимое. Корреляция делает конкурентные логи читаемыми: middleware чеканит или принимает x-request-id, пробрасывает его в заголовках запроса, эхом возвращает в ответе для тикетов поддержки, и каждый слой привязывает его через child-логгеры — AsyncLocalStorage несёт контекст внутри Node-процесса, но не перекрывает разрыв с edge-изолятом middleware, так что мостом между процессами служит заголовок. Слои логируют по своей частоте: middleware — одну короткую строку маршрутизации, действия — намерение и результат мутации как аудиторский след, серверные компоненты — только сбои загрузки данных, handler-ы — однострочную сводку; а onRequestError отправляет digest-ы ошибок с request id, превращая тикет в один запрос. На клиенте единственное честное число производительности — полевой p75: useReportWebVitals шлёт каждую метрику через sendBeacon, переживающий выгрузку, с маршрутом и id деплоя, и p75-по-маршруту-по-деплою называет деплой, просадивший LCP, — по стандартным порогам (LCP 2,5/4 с, INP 200/500 мс, CLS 0,1/0,25). И политика алертов, сохраняющая смысл пейджера: алерт на частоты за бюджетом, пейдж на ущерб пользователям, и никогда — на одиночную строку лога. Теперь, открыв пустую консоль браузера при производственном сбое, вы знаете, куда смотреть — и как сделать найденное пригодным к работе.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.