GASのトリガーが止まっても気づけるようにする(実行ログ・失敗通知・全文コピペ配布)
定期実行が失敗しても画面には何も出ません。実行ログを1行ずつ残し、落ちたら自分にメールを出し、そもそも動かなくなったことを別トリガーで検知するGASのコードです。上限値は公式リファレンスで確認した数字を載せています。
Google Apps Script(GAS)で作った自動処理を、トリガーで毎日動かす。ここまでは多くの記事に書かれています。問題はその先です。
トリガーで動かした処理が失敗しても、画面には何も出ません。 公式リファレンスにもそう書かれています。
インストール可能なトリガーが起動しても、関数が例外をスローしたり、正常に実行されなかったりした場合、画面にエラー メッセージは表示されません。 — インストール可能なトリガー | Google for Developers
代わりに noreply-apps-scripts-notifications@google.com から「Summary of failures for Apps Script」という件名のメールが届きます。ただしこれには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');
}
設置手順
- スプレッドシートを開き、拡張機能 > Apps Script をクリック
- 既定の
Code.gsの中身を全部消して、上のコードを貼る runDailyJob()の中身を自分の処理に差し替える- エディタ上部の関数名を
setupTriggersに切り替えて実行し、承認画面で許可する - 左の トリガー を開き、
runDailyJobとcheckHeartbeatの2件が登録されていることを確認する - 関数名を
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_() で包んでください。請求書のような、止まったことに月末まで気づけない処理こそ、実行ログと通知が要ります。