Next.js отправляет мои console.log по WebSocket и это нельзя отключить

Недавно я потратил несколько часов на поиск причины серьёзной проблемы с производительностью в приложении на Next.js.

Сначала я подозревал повторные рендеры React, большие обновления состояния, трафик WebSocket или даже собственный код. Но профилировщик показал совсем другую картину.

Большая часть времени уходила на выполнение кода внутри:

node_modules/next/dist/next-devtools/userspace/app/forward-logs.js

А если точнее:

sendClientFileLogs()

Копнув глубже, я обнаружил, что Next.js перехватывает браузерные методы console и пересылает содержимое логов на сервер разработки.

Неожиданность

Как и многие разработчики, я предполагаю, что:

console.log(data)

просто выводит что-то в консоль браузера.

По всей видимости, теперь это уже не так.

В режиме разработки Next.js оборачивает методы console собственной реализацией:

const originalLog = console.log

console.log = (...args) => {
  wrapper("log", ...args)
  originalLog.apply(console, args)
}

Эта обёртка сериализует данные и отправляет их обратно на сервер разработки через WebSocket-соединение.

Звучит безобидно — пока приложение не начинает работать с большими объектами.

console.log на 65 МБ

В моём приложении есть крупные структуры данных, существующие во время выполнения.

Во время профилирования я обнаружил, что один-единственный вызов логирования приводил к сериализации и передаче примерно 65 МБ данных.

Не сохранению.

Не рендерингу.

Передаче.

По WebSocket.

Просто потому, что я залогировал объект, внутри которого находилось большое дерево состояния.

Результатом стали резкий рост нагрузки на CPU, огромные объёмы выделяемой памяти и почти непригодная для работы среда разработки.

На flame graph было хорошо видно, что Next.js тратит больше времени на пересылку логов, чем моё приложение — на реальную работу.

И что ещё хуже: отключение не работает

Очевидная реакция:

«Так просто отключи эту функцию».

Я тоже первым делом так подумал.

Я попробовал:

logging: false

Потом:

logging: {
  browserToTerminal: false
}

Я изучил документацию.

Поискал в GitHub Issues.

Покопался в исходном коде.

Но console всё равно оставался обёрнутым.

Логи всё равно пересылались.

Трафик по WebSocket никуда не исчез.

Насколько я могу судить, сейчас нет надёжного и документированного способа вернуть нативное поведение браузерного console.

Обходное решение

Единственное, что действительно сработало, — полностью обойти Next.js.

Да, это некрасиво.

Да, это хак.

Но накладные расходы исчезли сразу:

setTimeout(() => {
  if (typeof window !== 'undefined') {
    // @ts-expect-error
    global.console.log = global.console.log.__proto__
  }
}, 0)

После применения этого обходного решения пересылка прекратилась, а производительность вернулась в норму.

Сам факт того, что такой хак сейчас работает надёжнее документированных настроек конфигурации, вызывает вопросы.

Проблема глубже

Проблема с производительностью раздражает.

Но куда сильнее меня беспокоит само архитектурное решение.

Переопределение фундаментальных браузерных API — очень серьёзное вмешательство со стороны фреймворка.

Если фреймворк перехватывает:

  • console.log
  • console.warn
  • console.error

и начинает передавать пользовательские данные по сети, у разработчиков должны быть:

  1. Чёткая документация.
  2. Явное включение этой функции.
  3. Гарантированная возможность её отключить.

Вместо этого многие разработчики, вероятно, обнаружат такое поведение только после того, как начнут профилировать проблемы с производительностью.

Фреймворки должны быть предсказуемыми

Главный урок этой истории вообще не про логирование.

Он про предсказуемость.

Когда я пишу:

console.log(obj)

я ожидаю вывод в консоль.

Я не ожидаю:

  • сериализации;
  • сетевого трафика;
  • обмена данными по WebSocket;
  • пересылки на сервер;
  • дополнительной нагрузки на CPU.

Современные фреймворки постоянно добавляют удобные функции, но у каждого скрытого слоя магии есть своя цена.

Иногда эта цена измеряется растерянностью разработчиков.

Иногда — миллисекундами.

А иногда — 65-мегабайтными сообщениями по WebSocket, созданными одним-единственным вызовом console.log.