Email yourself when the nightly job fails

TL;DR — Email yourself when an Apps Script nightly job fails: wrap real work in try/catch, log one sheet line, and send plain Gmail with function name + error.

You've got a time-driven sync that "mostly works" until it doesn't — and you find out three days later from a confused teammate. Triggers don't Slack you by default. Here's the boring, reliable pattern: wrap the real work in try/catch, write one Log sheet line, and send yourself a plain Gmail with the function name and error text.

TL;DR

  • Catch around the entry point, not every helper.
  • Log ERROR to a sheet (timestamp, function, message) before you email.
  • emailErrorAlert(subject, err, context) → Gmail to the installing user.
  • Re-throw after alerting so the execution stays failed in the dashboard.
  • Keep context small (ids, counts) — not full payloads.

Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.

Why do nightly jobs fail silently?

Time-driven triggers run when nobody is watching. Logger.log vanishes into the execution transcript. SpreadsheetApp.toast does nothing useful without a UI. If you only notice failures when a client asks "where's yesterday's import?", you're operating on hope.

You want three layers:

  1. Detect — try/catch around the work that can throw
  2. Record — a Log sheet row you can filter later
  3. Notify — one email you will actually open

What should the catch block look like?

Keep the happy path readable. One try around the orchestration function is enough.

function nightlySync() { var started = new Date(); try { var result = syncLeads_(); // real work: fetch, map, write logRow_('INFO', 'nightlySync', 'ok rows=' + (result && result.written)); return result; } catch (err) { logRow_('ERROR', 'nightlySync', err.message || String(err)); emailErrorAlert('nightlySync failed', err, { fn: 'nightlySync', spreadsheetId: SpreadsheetApp.getActiveSpreadsheet().getId(), startedAt: started.toISOString() }); throw err; } }

Re-throwing matters: if you swallow the error, the Apps Script dashboard shows success and you lose the red flag when debugging quotas later.

How do you send a useful Gmail alert?

Use a small helper dedicated to errors — not your marketing sendHtmlEmail. Subject searchable; body short.

function emailErrorAlert(subject, err, context) { var to = Session.getActiveUser().getEmail(); if (!to) throw new Error('emailErrorAlert: no active user email'); var msg = (err && err.message) ? err.message : String(err); var stack = (err && err.stack) ? String(err.stack).substring(0, 800) : ''; var ctx = ''; if (context != null) { try { ctx = JSON.stringify(context).substring(0, 500); } catch (e) { ctx = String(context).substring(0, 500); } } var body = [ 'Apps Script error alert', '', 'Message: ' + msg, stack ? ('Stack:\n' + stack) : '', ctx ? ('Context: ' + ctx) : '', '', 'Time: ' + new Date().toISOString() ].filter(Boolean).join('\n'); GmailApp.sendEmail(to, subject || 'Apps Script error', body); }

Triggers run as the user who installed them. Confirm that mailbox is yours (or a shared ops inbox you read).

What belongs on the Log sheet?

One line per event is enough. Match the pattern from the Log sheet guide: timestamp, level, source, message.

function logRow_(level, source, message) { var ss = SpreadsheetApp.getActiveSpreadsheet(); var log = ss.getSheetByName('Log'); if (!log) { log = ss.insertSheet('Log'); log.appendRow(['at', 'level', 'source', 'message']); log.setFrozenRows(1); } log.appendRow([new Date(), level, source, String(message || '').substring(0, 500)]); }

Email is the pager; the Log sheet is the diary. When someone asks "did Tuesday's run finish?", you filter — you don't dig through Gmail threads.

Should you alert on every warning?

No. Alert on failed entry points and true exceptions. Noise trains you to ignore the inbox. For soft skips ("0 rows, API empty"), log INFO or WARN without emailing unless empty runs are themselves a failure for that client.

A simple rule: if you would wake up to fix it, email; if you would shrug until Monday standup, log only.

How do you test the alert without waiting for 2am?

Force a throw from a menu item with the same wrapper:

function menuTestNightlyAlert() { try { throw new Error('test alert from menu — ignore'); } catch (err) { logRow_('ERROR', 'menuTestNightlyAlert', err.message); emailErrorAlert('menuTestNightlyAlert (test)', err, { test: true }); // do not re-throw on intentional tests if you prefer a toast SpreadsheetApp.getActiveSpreadsheet().toast('Alert sent', 'Test', 3); } }

Authorize Gmail once under the same account that owns the trigger.

What goes in the email subject vs body?

Subject is for triage. Body is for diagnosis.

  • Subject: nightlySync failed or [Acme] nightlySync failed — searchable, short.
  • Body: message, truncated stack, small context JSON, ISO timestamp.

Don't put PII dumps or full API payloads in email. If you need the payload, write it to Drive or a restricted Log tab and reference an id in the alert.

emailErrorAlert('[Acme] nightlySync failed', err, { fn: 'nightlySync', runId: Utilities.getUuid(), writtenBeforeFail: partialCount });

Store runId on the Log row too so email ↔ sheet join is trivial.

How do you avoid alert storms?

One failure can cascade: retry loops, per-row throws, overlapping triggers. Guardrails:

  1. Catch at the job boundary, not inside the per-row loop.
  2. Use a document/script lock so two triggers don't double-email.
  3. Optional: Script Property LAST_ALERT_AT — skip email if last alert was < 15 minutes ago (still log every time).
function shouldEmailAlert_() { var props = PropertiesService.getScriptProperties(); var last = Number(props.getProperty('LAST_ALERT_AT') || '0'); var now = Date.now(); if (now - last < 15 * 60 * 1000) return false; props.setProperty('LAST_ALERT_AT', String(now)); return true; }

Call shouldEmailAlert_() only in the catch path. Logs stay complete; inbox stays sane.

What about MailApp vs GmailApp?

Either works for plain text. Prefer GmailApp when the script already uses Gmail scopes; stick to one. Consumer accounts have daily recipient quotas — a broken loop that emails per row will burn them. Job-level alerts stay well under limits.

Minimal test plan

  1. Menu test throws → Log ERROR + email arrive within a minute.
  2. Happy-path nightlySync → Log INFO, no email.
  3. Force API failure mid-job → one email, execution marked failed.
  4. Fire catch twice within 15 minutes with throttle on → one email, two Log lines.
  5. Confirm trigger owner email is the inbox you watch.

Soft Co-Pilot note

If the catch block is fighting you — wrong user email, stack too long, or you're unsure what to put in contextNitroGAS ships emailErrorAlert as a snippet, and Co-Pilot can help shape the alert without rewriting the whole job. Free extension forever; Co-Pilot optional when you're stuck.

Closing checklist

  • try/catch on the time-driven entry function
  • Log sheet line on ERROR before email
  • Subject includes function name
  • Context is small JSON (ids/counts)
  • Error re-thrown after alert
  • Menu test proves Gmail + Log under the trigger user

Happy Coding!