🧾事務自動化ラボ

GASのトリガーが止まっても気づけるようにする(実行ログ・失敗通知・全文コピペ配布)

定期実行が失敗しても画面には何も出ません。実行ログを1行ずつ残し、落ちたら自分にメールを出し、そもそも動かなくなったことを別トリガーで検知するGASのコードです。上限値は公式リファレンスで確認した数字を載せています。

Google Apps Script(GAS)で作った自動処理を、トリガーで毎日動かす。ここまでは多くの記事に書かれています。問題はその先です。

トリガーで動かした処理が失敗しても、画面には何も出ません。 公式リファレンスにもそう書かれています。

インストール可能なトリガーが起動しても、関数が例外をスローしたり、正常に実行されなかったりした場合、画面にエラー メッセージは表示されません。 — インストール可能なトリガー | Google for Developers

代わりに noreply-apps-scripts-notifications@google.com から「Summary of failures for Apps Script」という件名のメールが届きます。ただしこれには2つ弱点があります。

  1. 英語の定型文で、何がどう失敗したのかが分からない。 迷惑メール扱いになっていることもあります
  2. 失敗を知らせるだけで、「そもそも動かなくなった」ことは分からない。 トリガーが消えた、上限に当たって実行されなくなった、という状態では失敗自体が発生しないので、通知も来ません

この記事では、この2つを塞ぐ土台を作ります。具体的には、処理を包むだけで実行ログが1行残り、落ちたら自分宛にメールが飛び、別のトリガーが「一定時間 成功していない」を検知する仕組みです。コードは全文このページに置いています。

先に、上限の数字を確認しておく

トリガーが止まる原因の多くは上限です。推測で書かれた記事が多い箇所なので、Google サービスの割り当て(2026-08-25 確認)の数字をそのまま載せます。

項目 一般ユーザー向けアカウント(gmail.com など) Google Workspace アカウント
スクリプトの実行時間 6分 / 実行 6分 / 実行
トリガーの合計実行時間 90分 / 日 6時間 / 日
トリガー 20 / ユーザー / スクリプト 20 / ユーザー / スクリプト
1日あたりのメール受信者数(MailApp など) 100 / 日 1,500 / 日
プロパティの合計ストレージ 500 KB / プロパティストア 500 KB / プロパティストア

注意したいのは「トリガーの合計実行時間」です。1日の合計なので、実行間隔を詰めるほど1回あたりに使える時間が減ります。

  • 1分ごとのトリガー = 1日1440回。90分 ÷ 1440回 = 1回あたり3.7秒
  • 5分ごと = 1日288回。90分 ÷ 288回 = 1回あたり18.7秒
  • 1日1回 = 6分(実行時間の上限まで丸ごと使える)

個人アカウントで1分ごとのトリガーを回すと、1回3.7秒を超えた時点で日中に枠が尽きます。そのとき出る例外が Service using too much computer time for one day. で、公式にも「トリガーで実行されるスクリプトで最もよく発生します」と書かれています。「昼までは動いていたのに午後から動かない」はこれを疑ってください。

トリガーの実行時刻は約束されない

もうひとつ、監視の設計に直接効く仕様です。

時間は多少ランダム化されることがあります。たとえば、午前 9 時の定期的なトリガーを作成すると、Apps Script は午前 9 時から午前 10 時までの時間を選択し、そのタイミングを毎日維持して、トリガーが再び起動するまでに 24 時間が経過するようにします。 — インストール可能なトリガー

つまり「7時のトリガー」は7時00分には動きません。7時台のどこかです。nearMinute() を使っても前後15分の幅があります。

このため、後で出てくる「何時間 成功が無ければ異常とみなすか」の閾値を24時間ちょうどにすると毎日のように誤検知します。1日1回のジョブなら26時間くらいに置いてください。

作るもの

3つの部品です。

  • withGuard_() — 処理を包む。二重起動を止め、成功も失敗も実行ログへ1行残し、失敗したらメールを出し、成功した時刻を保存する
  • checkHeartbeat() — 別トリガーで1日1回動く。「最後の成功から時間が経ちすぎていないか」「トリガーがそもそも登録されているか」を見る
  • setupTriggers() — トリガーを作り直す。同名のトリガーを先に消すので、何度実行しても増えない

実行ログはスプレッドシートの「実行ログ」シートに、この形で溜まります。

日時 関数 結果 所要秒 内容
2026/08/25 7:23:11 runDailyJob OK 4.2 18件を処理しました
2026/08/25 12:14:02 checkHeartbeat OK 0 監視対象 1 件はすべて動いています
2026/08/26 7:31:45 runDailyJob ERROR 1.1 Error: 「日報」シートがありません …

SKIP(前の実行がまだ終わっていない)と STALLED(止まっている)も同じ列に入ります。この列を眺めるだけで、いつから何が起きているかが分かるようにするのが狙いです。

コード全文

スプレッドシートの「拡張機能 > Apps Script」に貼ります。runDailyJob() の中身を、自分がやりたい処理に差し替えて使ってください。

/**
 * 定期実行の見張り(Google Apps Script)
 *
 * トリガーで動かす処理は、失敗しても画面に何も出ません。
 * 「動いているつもりで、先月から止まっていた」を防ぐための土台です。
 *
 * できること:
 *   1. 本体を withGuard_() で包むと、二重起動を止め、成功も失敗も実行ログへ1行残す
 *   2. 失敗したら自分宛にメールを出す(同じ失敗の連投は抑える)
 *   3. 最後に成功した時刻を保存し、別トリガーの checkHeartbeat() が
 *      「一定時間 成功していない」「トリガーが消えている」を検知する
 *
 * 設置手順:
 *   1. スプレッドシートの「拡張機能 > Apps Script」に貼る
 *      (スタンドアロンで使う場合は GUARD.spreadsheetId にIDを入れる)
 *   2. runDailyJob() の中身を、自分がやりたい処理に差し替える
 *   3. setupTriggers() を1回だけ手で実行して承認する
 *   4. 実行ログシートに OK の行が増えることを確認する
 */

const GUARD = {
  // 空文字ならバインド先のスプレッドシートを使う。
  // スタンドアロンのスクリプトでは getActiveSpreadsheet() が null になるので、必ずIDを入れる
  spreadsheetId: '',

  logSheetName: '実行ログ',
  logMaxRows: 3000,

  // 前の実行がまだ動いているとき、何ミリ秒まで待つか
  lockWaitMs: 20 * 1000,

  // 同じ失敗を何分に1通までにするか
  notifyCooldownMinutes: 180,

  // 空文字なら「トリガーを作ったアカウント」宛に送る
  notifyTo: '',

  // 監視したい関数名と、「何時間 成功が無ければ異常とみなすか」
  // 1日1回のジョブなら 26 くらい(実行時刻は前後するため、24ちょうどにしない)
  heartbeat: {
    runDailyJob: 26,
  },
};

// ---------------------------------------------------------------------------
// 1. 本体(ここを自分の処理に差し替える)
// ---------------------------------------------------------------------------

/** トリガーから呼ばれる入口。中身は withGuard_ に包んで渡す */
function runDailyJob() {
  return withGuard_('runDailyJob', function () {
    // ここに、毎日やらせたい処理を書く。
    // 戻り値の文字列がそのまま実行ログの「内容」列に入る
    const book = openBook_();
    const sheet = book.getSheetByName('日報');
    if (!sheet) {
      throw new Error('「日報」シートがありません');
    }
    const rows = Math.max(0, sheet.getLastRow() - 1);
    return rows + '件を処理しました';
  });
}

// ---------------------------------------------------------------------------
// 2. 見張りの本体
// ---------------------------------------------------------------------------

/**
 * 処理を包んで、二重起動の防止・実行ログ・失敗通知・成功時刻の記録をまとめて行う。
 * 例外は握りつぶさずに投げ直す(Apps Script 側の実行履歴にも「失敗」として残すため)。
 */
function withGuard_(name, body) {
  const lock = LockService.getScriptLock();

  // tryLock は取れなければ false を返すだけ(waitLock は例外を投げる)
  lock.tryLock(GUARD.lockWaitMs);
  if (!lock.hasLock()) {
    writeLog_(name, 'SKIP', 0, '前の実行がまだ終わっていないため見送りました');
    return null;
  }

  const startedAt = Date.now();
  try {
    const result = body();
    SpreadsheetApp.flush();
    PropertiesService.getScriptProperties()
      .setProperty('guard.lastSuccess.' + name, new Date().toISOString());
    writeLog_(name, 'OK', Date.now() - startedAt, result == null ? '' : String(result));
    return result;
  } catch (err) {
    const detail = err && err.stack ? err.stack : String(err);
    writeLog_(name, 'ERROR', Date.now() - startedAt, detail);
    notifyOnce_(
      'error.' + name + '.' + shortHash_(String(err && err.message ? err.message : err)),
      '[GAS] ' + name + ' が失敗しました',
      [
        '関数: ' + name,
        '発生: ' + nowText_(),
        '',
        detail,
        '',
        '実行ログ: ' + bookUrl_(),
      ].join('\n')
    );
    throw err;
  } finally {
    lock.releaseLock();
  }
}

/**
 * 別トリガー(1日1回で十分)から呼ぶ。
 * 「最後に成功した時刻」と「トリガーが実在するか」の2点を見る。
 */
function checkHeartbeat() {
  const props = PropertiesService.getScriptProperties();
  const installed = ScriptApp.getProjectTriggers().map(function (t) {
    return t.getHandlerFunction();
  });
  const names = Object.keys(GUARD.heartbeat);
  const problems = [];

  names.forEach(function (name) {
    if (installed.indexOf(name) === -1) {
      problems.push(name + ': トリガーが登録されていません');
    }
    const raw = props.getProperty('guard.lastSuccess.' + name);
    if (!raw) {
      problems.push(name + ': 成功した記録が1件もありません');
      return;
    }
    const hours = (Date.now() - new Date(raw).getTime()) / 3600000;
    if (hours > GUARD.heartbeat[name]) {
      problems.push(
        name + ': 最後の成功から ' + hours.toFixed(1) + ' 時間経過(上限 ' + GUARD.heartbeat[name] + ' 時間)'
      );
    }
  });

  if (problems.length === 0) {
    writeLog_('checkHeartbeat', 'OK', 0, '監視対象 ' + names.length + ' 件はすべて動いています');
    return;
  }

  writeLog_('checkHeartbeat', 'STALLED', 0, problems.join(' / '));
  notifyOnce_(
    'heartbeat.' + shortHash_(problems.join('|')),
    '[GAS] 定期実行が止まっています',
    [
      '次の処理が動いていません。',
      '',
      problems.join('\n'),
      '',
      '実行ログ: ' + bookUrl_(),
    ].join('\n')
  );
}

// ---------------------------------------------------------------------------
// 3. トリガーの登録
// ---------------------------------------------------------------------------

/**
 * トリガーを作り直す。同じハンドラのトリガーを先に全部消すので、
 * 何度実行しても増えない(=毎回2通届く事故が起きない)。
 */
function setupTriggers() {
  const wanted = [
    {
      handler: 'runDailyJob',
      build: function () {
        // atHour() を使うときは everyDays() などの頻度指定が必須(公式リファレンス)
        // 7 を指定すると 7:00〜8:00 のどこかで動く。分単位の約束はできない
        ScriptApp.newTrigger('runDailyJob').timeBased().everyDays(1).atHour(7).create();
      },
    },
    {
      handler: 'checkHeartbeat',
      build: function () {
        // 本体より後の時刻に置く。本体が動くはずの時刻より前だと毎日誤検知する
        ScriptApp.newTrigger('checkHeartbeat').timeBased().everyDays(1).atHour(12).create();
      },
    },
  ];

  const handlers = wanted.map(function (w) { return w.handler; });
  let removed = 0;
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (handlers.indexOf(t.getHandlerFunction()) !== -1) {
      ScriptApp.deleteTrigger(t);
      removed++;
    }
  });
  wanted.forEach(function (w) { w.build(); });

  const message = '既存 ' + removed + ' 件を削除し、' + wanted.length + ' 件を登録しました';
  writeLog_('setupTriggers', 'OK', 0, message);
  return message;
}

/** 今このスクリプトに何のトリガーが付いているかを実行ログへ出す(診断用) */
function listTriggers() {
  const rows = ScriptApp.getProjectTriggers().map(function (t) {
    return t.getHandlerFunction() + '(' + t.getEventType() + ')';
  });
  const message = rows.length ? rows.join(', ') : 'トリガーは1件も登録されていません';
  writeLog_('listTriggers', 'OK', 0, message);
  return message;
}

// ---------------------------------------------------------------------------
// 4. 実行ログ
// ---------------------------------------------------------------------------

function writeLog_(name, status, elapsedMs, message) {
  try {
    const sheet = getLogSheet_();
    sheet.appendRow([
      new Date(),
      name,
      status,
      Math.round(elapsedMs / 100) / 10,
      truncate_(String(message == null ? '' : message), 900),
    ]);
    const over = sheet.getLastRow() - 1 - GUARD.logMaxRows;
    if (over > 0) {
      sheet.deleteRows(2, over);
    }
  } catch (e) {
    // ログを書けないこと自体で本体を落とさない。実行履歴には残る
    console.error('実行ログを書けませんでした: ' + e);
  }
}

function getLogSheet_() {
  const book = openBook_();
  let sheet = book.getSheetByName(GUARD.logSheetName);
  if (!sheet) {
    sheet = book.insertSheet(GUARD.logSheetName);
    sheet.appendRow(['日時', '関数', '結果', '所要秒', '内容']);
    sheet.setFrozenRows(1);
    sheet.setColumnWidth(1, 150);
    sheet.setColumnWidth(5, 520);
  }
  return sheet;
}

function openBook_() {
  if (GUARD.spreadsheetId) {
    return SpreadsheetApp.openById(GUARD.spreadsheetId);
  }
  const book = SpreadsheetApp.getActiveSpreadsheet();
  if (!book) {
    throw new Error(
      'スプレッドシートを開けません。スタンドアロンのスクリプトでは GUARD.spreadsheetId にIDを入れてください。'
    );
  }
  return book;
}

function bookUrl_() {
  try {
    return openBook_().getUrl();
  } catch (e) {
    return '(スプレッドシートを開けませんでした)';
  }
}

// ---------------------------------------------------------------------------
// 5. 通知
// ---------------------------------------------------------------------------

/**
 * 同じ内容の通知を、一定時間に1通だけ送る。
 * key ごとに最後の送信時刻をスクリプトプロパティへ持つ。
 */
function notifyOnce_(key, subject, body) {
  const props = PropertiesService.getScriptProperties();
  const propKey = 'guard.notified.' + key;
  const last = props.getProperty(propKey);
  const cooldownMs = GUARD.notifyCooldownMinutes * 60 * 1000;

  if (last && Date.now() - new Date(last).getTime() < cooldownMs) {
    return false;
  }

  const to = notifyAddress_();
  if (!to) {
    console.error('通知先が空です。GUARD.notifyTo にアドレスを入れてください。');
    return false;
  }

  // 通知でメールの1日上限を使い切ると、本来送るはずのメールが送れなくなる
  if (MailApp.getRemainingDailyQuota() <= 0) {
    console.error('メール送信の残り枠が0です。通知は送れません: ' + subject);
    return false;
  }

  MailApp.sendEmail(to, subject, body);
  props.setProperty(propKey, new Date().toISOString());
  return true;
}

function notifyAddress_() {
  if (GUARD.notifyTo) {
    return GUARD.notifyTo;
  }
  // トリガー実行中は getActiveUser() が空文字になることがある。
  // getEffectiveUser() は「トリガーを作ったアカウント」を返す
  return Session.getEffectiveUser().getEmail();
}

// ---------------------------------------------------------------------------
// 6. 小物
// ---------------------------------------------------------------------------

/** 通知をまとめるためのごく短いハッシュ。衝突しても実害が無い用途にだけ使う */
function shortHash_(text) {
  const bytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, text || '');
  return bytes
    .slice(0, 4)
    .map(function (b) {
      // computeDigest は符号付きバイトを返すので、& 0xff で 0〜255 に戻す
      return ('0' + (b & 0xff).toString(16)).slice(-2);
    })
    .join('');
}

function truncate_(text, max) {
  return text.length > max ? text.slice(0, max) + '…' : text;
}

function nowText_() {
  return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');
}

設置手順

  1. スプレッドシートを開き、拡張機能 > Apps Script をクリック
  2. 既定の Code.gs の中身を全部消して、上のコードを貼る
  3. runDailyJob() の中身を自分の処理に差し替える
  4. エディタ上部の関数名を setupTriggers に切り替えて実行し、承認画面で許可する
  5. 左の トリガー を開き、runDailyJob と checkHeartbeat の2件が登録されていることを確認する
  6. 関数名を runDailyJob に切り替えて1回手で実行し、「実行ログ」シートに OK の行が出ることを確認する

手順6まで通れば、翌朝からは黙って動きます。1週間後に実行ログを開いて、行が7本増えていれば正常です。

つまずいた箇所と、その原因

1. atHour() だけではトリガーが作れない

// これは失敗する
ScriptApp.newTrigger('runDailyJob').timeBased().atHour(7).create();

公式リファレンスの atHour(hour) のサンプルコードに、コメントとしてこう書かれています。

Frequency is required if you are using atHour() or nearMinute() — ClockTriggerBuilder | Google for Developers

everyDays(1) や everyWeeks(1) のような頻度の指定が必須です。正しくはこうなります。

ScriptApp.newTrigger('runDailyJob').timeBased().everyDays(1).atHour(7).create();

2. everyMinutes() は好きな数字を入れられない

everyMinutes(n) の n は 1, 5, 10, 15, 30 のいずれかだけです(同リファレンス)。everyMinutes(3) や everyMinutes(20) は通りません。「3分ごとにしたい」は仕様上できないので、1分ごとにして処理側で間引くか、5分ごとにします。

3. getDocumentLock() が null になる

二重起動を止めるコードとしてよく貼られているのがこれです。

// スタンドアロンのスクリプトでは落ちる
LockService.getDocumentLock().tryLock(10000);

getDocumentLock() は、公式リファレンスに「ドキュメントの文脈の外(スタンドアロンのスクリプトやウェブアプリなど)から呼ばれた場合は null を返す」と書かれています。スプレッドシートに貼っている間は動き、スクリプトを独立させた瞬間に null へのアクセスで落ちます。

スクリプト全体で1つだけ動かしたいなら getScriptLock() を使ってください。今回のコードもそちらです。

4. waitLock() は例外を投げ、tryLock() は false を返す

同じロックの取得でも挙動が違います。waitLock() はタイムアウトすると例外になるので、そのまま使うと**「前の実行がまだ終わっていない」だけで失敗通知が飛びます**。

今回は「取れなければ今回は見送って、ログに SKIP と残す」ほうが実務に合うので、tryLock() と hasLock() を使っています。

lock.tryLock(GUARD.lockWaitMs);
if (!lock.hasLock()) {
  writeLog_(name, 'SKIP', 0, '前の実行がまだ終わっていないため見送りました');
  return null;
}

5. 通知先に getActiveUser() を使うと空になることがある

通知の宛先を Session.getActiveUser().getEmail() で取ると、空文字が返ってメールが送られないことがあります。Session のリファレンスには、ユーザーの承認なしに動く文脈ではメールアドレスが取得できない、と書かれています。

インストール型のトリガーで使うなら getEffectiveUser() です。こちらは「トリガーを作ったアカウント」を返すと明記されています。

return Session.getEffectiveUser().getEmail();

なお、この記事のコードでは GUARD.notifyTo にアドレスを直接書けるようにしてあります。確実なのはこちらです。

6. try / catch で握りつぶすと、Google側の通知まで消える

失敗を自分でメール通知するなら catch で受け止めることになりますが、そこで終わらせてはいけません。

例外を投げ直さないと、Apps Script の実行履歴には「完了」と記録されます。ダッシュボードで失敗を絞り込んでも出てこなくなり、Googleからの失敗通知メールも来なくなります。 自作の通知が届かなかったとき(メール枠切れなど)に、頼るものが何も無くなります。

なので withGuard_() は、ログを書いて通知を出したあとで必ず投げ直します。

} catch (err) {
  writeLog_(name, 'ERROR', Date.now() - startedAt, detail);
  notifyOnce_( /* ... */ );
  throw err;   // ← ここを消さない
} finally {
  lock.releaseLock();
}

7. トリガーを作り直すたびに増えて、同じ通知が何通も届く

ScriptApp.newTrigger(...) を実行するたびにトリガーは増えます。セットアップ関数を2回実行すれば2件、3回で3件になり、毎回同じ処理が2回・3回と走ります。請求書の発行やメール送信をやらせている場合、これは事故です。

対策は、作る前に同じハンドラのトリガーを全部消すことです。

ScriptApp.getProjectTriggers().forEach(function (t) {
  if (handlers.indexOf(t.getHandlerFunction()) !== -1) {
    ScriptApp.deleteTrigger(t);
  }
});

上限は 20 / ユーザー / スクリプト です。増え続けると、あるとき突然トリガーを追加できなくなります。

この問題は、フォーム送信トリガーでも同じ形で起きます。フォーム受付の自動化についてはGoogleフォームの受付を自動化するに、二重送信になったときの実際の症状を書いています。

8. 通知メールで、本来のメールの枠を使い切る

個人アカウントのメール送信は 100通 / 日 です。1分ごとのトリガーが失敗し続けると、通知だけで1日の枠を使い切ります。そうなると、請求書の送付や問い合わせへの自動返信といった本来送りたいメールが送れなくなります。

今回のコードでは2つ入れています。

  • 同じ失敗は3時間に1通まで(エラーメッセージのハッシュをキーにして、スクリプトプロパティへ最後の送信時刻を保存)
  • 送る前に残量を確認する(MailApp.getRemainingDailyQuota()。0なら送らずログにだけ残す)
if (MailApp.getRemainingDailyQuota() <= 0) {
  console.error('メール送信の残り枠が0です。通知は送れません: ' + subject);
  return false;
}

9. 共有シートで、他人が作ったトリガーは自分の画面に出ない

複数人で使うスプレッドシートで一番混乱するのがこれです。公式リファレンスにこう書かれています。

あるアカウントが 2 つ目のアカウントからインストールされたトリガーを表示することはできません。ただし、最初のアカウントはそれらのトリガーを有効にできます。 — インストール可能なトリガー

つまり、自分のトリガー画面は空なのに処理は動いている、という状態が普通に起こります。そして止めたいときに止められません。作った本人にしか消せないからです。

さらに、トリガーは作った人のアカウントで動きます。通知メールもその人宛に飛びます。担当者が退職してアカウントが消えれば、処理も静かに止まります。

業務で使うものは、誰のアカウントで登録したかを必ずどこかに残してください。今回のコードに listTriggers() を入れてあるのはそのためです。実行すると、いま自分から見えているトリガーが実行ログに書き出されます。

この仕組みで気づけないこと

正直に書いておきます。checkHeartbeat() 自身のトリガーが消えたら、この仕組みは何も言いません。 監視する側が止まったことは、監視する側からは分からないからです。

同じ理由で、スクリプトプロジェクトごと消えた場合、アカウントが停止した場合も検知できません。

これを完全に塞ぐには、別のアカウントか別のサービスから外側で見張る必要があります。そこまでやるかは、止まったときの損害次第です。私は月に1回、実行ログの最終行の日付を見るところで止めています。それだけでも「先月から止まっていた」は防げます。

どこまで自動化するか

ここで作ったのは土台であって、成果物ではありません。runDailyJob() の中身が空のままでは何も起きません。

中に入れる処理として実際に効くのは、毎月くり返していて、手順が決まっていて、間違えると気づきにくいものです。私の場合は請求書の発行がそれでした。明細を入力してメニューを押すとPDFがDriveに保存されるところまでを、スプレッドシートで請求書を自動発行する仕組みの作り方に書いています。

そちらの仕組みを毎月1日に自動で走らせるなら、この記事の withGuard_() で包んでください。請求書のような、止まったことに月末まで気づけない処理こそ、実行ログと通知が要ります。

← 記事一覧へ