Отладка и надёжность

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

Журнал выполнений (Executions) — список всех запусков скрипта за последние дни: кто запустил, сколько длился, чем кончился. Это единственное место, где видно ошибки триггеров и веб-приложения: вам о них никто не сообщит.

Почему обычная отладка тут не работает

Пока вы жмёте «Выполнить» в редакторе, всё просто: ошибка вылезает красной строкой. Но боевой скрипт запускается ночью по триггеру или из чужого браузера через веб-приложение. Никто на него не смотрит. Он падает — и молчит. Через неделю выясняется, что отчёт не обновлялся с прошлого вторника.

Поэтому «отладка» в Apps Script — это две разные задачи. Первая: разобраться, что творится в коде здесь и сейчас (логи, точки останова). Вторая: сделать так, чтобы о падении в 3 часа ночи вы узнали в 3:01, а не через неделю (журнал, try/catch, письмо себе).

Logger.log и console.log

Оба пишут в журнал выполнения, который открывается снизу редактора после запуска. Разница в том, что console.log (а также console.warn и console.error) отправляет запись ещё и в Google Cloud Logging — она сохраняется дольше и доступна из журнала выполнений для любых запусков, включая триггерные и веб-запросы. Правило простое: быстрая проверка руками — что угодно; в коде, который живёт на проде, — console.

function syncOrders() {
  const orders = fetchOrders_();
  console.log('Получено заказов: ' + orders.length);

  if (!orders.length) {
    console.warn('Пустой ответ API — проверьте фильтр по дате');
    return;
  }

  // Объекты печатайте через JSON.stringify: иначе в журнале будет [object Object]
  console.log('Первый заказ: ' + JSON.stringify(orders[0]));
}

Логи не бесплатны: каждая запись — это время. Не логируйте внутри цикла на 5000 итераций, иначе сами приблизите таймаут. Логируйте границы: сколько получили, сколько записали, сколько пропустили и почему.

Точки останова

В редакторе слева от номера строки есть узкая полоска — щёлкните по ней, появится синий кружок. Выберите функцию в списке сверху и нажмите «Отладка» вместо «Выполнить». Скрипт остановится на этой строке, и внизу откроется панель со всеми переменными: можно раскрыть объект, посмотреть массив, шагнуть на следующую строку (Step over) или зайти внутрь функции (Step into).

Это лучший способ понять, что реально приехало из UrlFetchApp или что лежит в e у триггера. Но у отладчика есть жёсткое ограничение: он работает только для запусков из редактора. Ни триггер, ни веб-запрос, ни формулу в ячейке пошагово не пройти — там остаются только логи. Отсюда практический приём: вынесите логику в обычную функцию с аргументами, а doPost(e) или обработчик триггера сделайте тонкой обёрткой. Тогда логику можно отладить, подсунув ей поддельный объект события.

function doPost(e) {
  return handleRequest_(e.parameter);   // тонкая обёртка
}

function handleRequest_(params) {
  // вся логика здесь — её можно отлаживать
  return ContentService.createTextOutput('ok: ' + params.name);
}

function debugHandleRequest() {
  // запускаем в отладчике с поддельными данными
  const res = handleRequest_({ name: 'Иванов', date: '2026-05-01' });
  console.log(res.getContent());
}

Журнал выполнений

Слева в редакторе есть иконка «Выполнения». Там таблица всех запусков за последние дни.

КолонкаЧто смотреть
Тип запускаРедактор, Триггер, Веб-приложение, Надстройка — сразу видно, кто дёрнул код.
СтатусCompleted — отработал; Failed — исключение; Timed out — упёрся в лимит времени; Cancelled — остановлен вручную.
ДлительностьРастёт от запуска к запуску? Данных стало больше — скоро таймаут.
Раскрытая строкаВсе console.log этого запуска и стек ошибки.

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

try/catch и письмо себе

Надёжный фоновый скрипт устроен так: обёртка ловит любое исключение, пишет его в журнал и шлёт вам письмо с текстом ошибки и стеком. Одна тонкость — не превратить это в самообстрел: если триггер падает каждые пять минут, за сутки вы получите под три сотни писем и выберете почтовую квоту. Поэтому уведомление отправляется не чаще раза в час, а сторожем работает CacheService.

function safeRun_(name, fn) {
  try {
    return fn();
  } catch (err) {
    console.error(name + ' упал: ' + err.stack);
    notifyOnce_(name, err);
    throw err;   // пробрасываем: пусть запуск числится Failed в журнале
  }
}

function notifyOnce_(name, err) {
  const cache = CacheService.getScriptCache();
  const key = 'err:' + name;
  if (cache.get(key)) return;        // за последний час уже писали
  cache.put(key, '1', 3600);         // молчим 3600 секунд

  MailApp.sendEmail({
    to: Session.getEffectiveUser().getEmail(),
    subject: '[GAS] Сбой: ' + name,
    body: [
      'Таблица: ' + SpreadsheetApp.getActive().getName(),
      'Функция: ' + name,
      'Ошибка: ' + err.message,
      '',
      'Стек:',
      err.stack
    ].join('\n')
  });
}

// А вот так это выглядит в обработчике триггера
function dailySync() {
  safeRun_('dailySync', () => {
    const orders = fetchWithRetry_('https://api.example.com/orders');
    writeOrders_(JSON.parse(orders.getContentText()));
  });
}

err.stack ценнее err.message: он показывает файл и номер строки. Session.getEffectiveUser().getEmail() возвращает адрес того, чьими правами выполняется код, — то есть ваш, а не случайного посетителя веб-приложения. И обратите внимание на throw err в конце: без него запуск попадёт в журнал как успешный, и вы потеряете статистику падений.

Повторные попытки

Значительная часть «ошибок» — это не ошибки, а короткие сбои сети или ответ 429 «слишком часто». Такие вещи лечатся повтором с нарастающей паузой, и до письма дело просто не дойдёт.

function fetchWithRetry_(url, params) {
  let wait = 1000;

  for (let attempt = 1; attempt <= 5; attempt++) {
    const options = Object.assign({ muteHttpExceptions: true }, params || {});
    const res = UrlFetchApp.fetch(url, options);
    const code = res.getResponseCode();

    if (code < 400) return res;

    // 4xx (кроме 429) — наша вина: неверный токен или адрес. Повторять бессмысленно.
    if (code < 500 && code !== 429) {
      throw new Error('Запрос отклонён (' + code + '): ' + res.getContentText());
    }

    console.warn('Попытка ' + attempt + ' — код ' + code + ', ждём ' + wait + ' мс');
    Utilities.sleep(wait + Math.floor(Math.random() * 500));
    wait *= 2;   // 1с, 2с, 4с, 8с
  }

  throw new Error('Сервис не ответил за 5 попыток: ' + url);
}

muteHttpExceptions: true отключает автоматическое исключение при коде 500 — иначе мы не успеем прочитать ответ и решить, повторять ли. Случайная добавка к паузе (джиттер) нужна, чтобы десять параллельных запусков не долбили сервис в одну и ту же миллисекунду. И помните про шесть минут: паузы тоже съедают время выполнения, поэтому больше пяти попыток ставить не стоит.

Версии и откат

Плохой день выглядит так: поправили формулу, развернули новую версию, через час выясняется, что скрипт третий час пишет в таблицу мусор. Спасают две вещи.

История изменений. Редактор сам хранит правки: меню «Файл» → «История версий» показывает, каким код был вчера, и позволяет вернуть. Это про исходники.

Развёртывания. Это про то, какой код видят пользователи. «Развернуть» → «Управление развёртываниями»: у каждого свой номер версии. Чтобы откатиться, откройте карандаш и выберите в списке версий предыдущий номер — боевая ссылка /exec тут же начнёт отдавать старый код. Всё это занимает секунд тридцать и не требует ничего восстанавливать из копий.

Отсюда рабочая привычка: не редактируйте боевой код в редакторе «по-быстрому». Каждая правка — это новая версия развёртывания с осмысленным описанием («добавил проверку даты»). Тогда в аварийной ситуации вы не гадаете, что именно сломалось, а просто возвращаете предыдущий номер. Если проект вырос до нескольких человек, следующий шаг — консольная утилита clasp: она умеет выгружать проект в файлы и класть их в Git, а дальше действуют обычные pull request и code review.

Как это работает

У Apps Script нет «сервера, который упал». Каждый запуск — изолированное короткое выполнение на инфраструктуре Google, и единственный след, который от него остаётся, — запись в журнале. Поэтому наблюдаемость приходится закладывать в код: если функция не написала в лог, сколько строк обработала, — узнать это постфактум невозможно.

Исключение, не пойманное вашим try/catch, просто помечает запуск как Failed. Никакого отката: строки, записанные до ошибки, остаются в таблице, письма, ушедшие до ошибки, не вернуть. Поэтому надёжность здесь строится не на транзакциях, а на порядке действий и повторяемости — сначала сделали работу, потом пометили строку «готово»; повторный запуск видит отметку и проходит мимо.

Частые ошибки

  • Пустой catch. catch (e) {} прячет ошибку: скрипт «отработал успешно», данные не записаны. Ловить исключение можно только для того, чтобы что-то с ним сделать.
  • Письмо на каждую ошибку. Падающий каждые пять минут триггер сжигает суточную почтовую квоту и заваливает вам ящик. Ограничивайте частоту уведомлений.
  • console.log(объект). В журнале окажется [object Object]. Оборачивайте в JSON.stringify.
  • Логи в цикле на тысячи итераций. Заметно замедляют выполнение и приближают таймаут.
  • Попытка отладить триггер точками останова. Не выйдет: вынесите логику в отдельную функцию и запускайте её из редактора с поддельными данными.
  • Правка боевого кода без новой версии. Пользователи по /exec продолжают работать со старым, вы тестируете новый, и никто не понимает, чей результат в таблице.

Итоги

  • console.log/console.error — основной инструмент боевой отладки: их видно в журнале выполнений даже для триггеров и веб-запросов.
  • Точки останова и пошаговый проход работают только для запуска из редактора; логику стоит выносить из doPost и обработчиков триггеров в обычные функции.
  • Журнал выполнений показывает тип запуска, статус и длительность — растущая длительность предупреждает о будущем таймауте.
  • Обёртка try/catch + письмо себе со стеком превращает тихое ночное падение в понятное уведомление; частоту писем ограничивайте через CacheService.
  • Сетевые сбои и 429 лечатся повторами с удвоением паузы, а не письмом.
  • Откат — это выбор предыдущей версии в «Управлении развёртываниями», а не восстановление кода из памяти.
Проверьте себя
1. Триггер падает каждую ночь, но писем от скрипта нет. Где искать причину сбоя?
AНигде: ошибки триггеров не сохраняются
BВ журнале выполнений — там видны статус Failed и стек ошибки
CВ истории версий проекта
DОшибку покажет ячейка таблицы, куда писал скрипт
2. Почему в обработчике ошибок письмо отправляют не чаще раза в час, а не на каждое исключение?
AMailApp не умеет слать два письма подряд
BПадающий раз в 5 минут триггер выберет суточную квоту на письма и завалит ящик
CGoogle блокирует скрипты, отправляющие письма самим себе
DКаждое письмо откатывает изменения в таблице