はじめに
- 毎朝届くはずの集計メールが、気づいたら2週間止まっていた
- cronで回していたスクリプトが、サーバーを移行した日から一度も動いていなかった
- エラー通知は入れていたのに、そもそも起動していなかったので何も届かなかった
業務の自動化は、作ったときより止まったときに気づけるかのほうが大事です。止まったことに誰も気づかなければ、手作業に戻すことすらできません。
この記事では、GASとcronのジョブをまとめて見張る仕組みを、Googleスプレッドシート+GASだけで作ります。有料の監視サービスは使いません。
完成イメージ
エラー通知だけでは足りない理由
ジョブの止まり方は1つではありません。
| 止まり方 | よくある原因 | エラー通知で気づける? | 死活監視で気づける? |
|---|---|---|---|
| 処理中にエラーが出る | API仕様の変更、認証切れ、データ不正 | ○ | ○ |
| そもそも起動しない | トリガーが消えた、cronの設定ミス、サーバー移行 | × | ○ |
| 途中で固まって終わらない | 外部APIの応答待ち、無限ループ | △ | ○(タイムアウト設定で検知) |
| 正常終了したのに中身が空 | 取り込み元が0件、条件の指定ミス | × | × → ジョブ側で件数をチェックする |
「失敗したら知らせる」だけでは、「動いていない」ことには気づけません。
そこで、次の3つを組み合わせます。
- エラー通知:失敗したらSlackかメールに通知する
- 死活監視:「最後に成功した時刻」を記録しておき、一定時間更新がなければ通知する
- ログ:いつ・何が・何秒で終わったかを残し、後から追えるようにする
全体の構成
[GASのジョブ] ── runJob() で包む ─────────────┐
├─> [監視スプレッドシート]
[cronのジョブ] ── monitor_run.py ──(HTTP)────┘ ↑
checkHeartbeats()(毎時)
↓
Slack / メール通知
ポイントは、監視をする側(GAS)と監視される側(サーバー)を別の場所で動かすことです。サーバーが丸ごと落ちても、Google側の監視は動き続けるので「止まった」ことに気づけます。
届く通知の例
🚨 [失敗] order-import
exit=1
PHP Fatal error: Uncaught Exception: API returned 401 ...
⏰ [停止の疑い] 日次売上集計
最終成功: 2026/10/05 07:00(許容間隔 1500分)
✅ [復旧] order-import
通知は状態が変わったときだけ送ります。10分おきに失敗するジョブがあっても、通知は「失敗」と「復旧」の2通で済みます。
監視シートのイメージ
| ジョブ名 | 許容間隔(分) | 最終成功 | 最終実行 | 状態 | 連続失敗 | 最終メッセージ |
|---|---|---|---|---|---|---|
| 日次売上集計 | 1500 | 2026/10/07 07:00 | 2026/10/07 07:00 | OK | 0 | |
| order-import | 90 | 2026/10/07 09:00 | 2026/10/07 10:00 | ERROR | 1 | exit=1 ... |
準備 / 前提
- Googleアカウント(無料アカウントで動きます)
- 通知先(どちらか)
- Slackの Incoming Webhook URL
- メールアドレス
- cronのジョブも監視する場合
- Python 3.6以上が使えるサーバー(標準ライブラリだけで動くので
pip installは不要です)
- Python 3.6以上が使えるサーバー(標準ライブラリだけで動くので
Slackの Incoming Webhook URL は、https://api.slack.com/apps で「Create New App」→「Incoming Webhooks」を有効化→「Add New Webhook to Workspace」で通知先のチャンネルを選ぶと発行されます。
Step 1:監視用のスプレッドシートとGASを用意する
- 新しいGoogleスプレッドシートを作成する(名前は「ジョブ監視」など)
- URLの
https://docs.google.com/spreadsheets/d/【ここ】/editの部分がIDになるので、控えておく - 「拡張機能」→「Apps Script」を開き、最初からある
コード.gsの中身を以下にすべて置き換える -
★の付いた設定値を書き換える
/**
* 自動化ジョブの監視(エラー通知 + 死活監視 + ログ)
*/
const CONFIG = {
SPREADSHEET_ID: 'ここにスプレッドシートIDを貼る', // ★
SLACK_WEBHOOK_URL: '', // ★Slackに通知する場合はWebhook URL。空ならメールで通知
NOTIFY_EMAIL: 'you@example.com', // ★通知先(Slackへの送信に失敗したときの予備にも使う)
TOKEN: 'ここに推測されにくい長い文字列', // ★cron側から報告するときの合言葉
DEFAULT_ALLOWED_MIN: 1500, // 新しいジョブの許容間隔(分)。1500分 = 25時間
LOG_KEEP_ROWS: 5000, // ログシートに残す行数
MONITOR_SHEET: '監視',
LOG_SHEET: 'ログ',
};
// ===== 初期設定(最初に1回だけ手動で実行する) =====
function setup() {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
ensureSheet_(ss, CONFIG.MONITOR_SHEET,
['ジョブ名', '許容間隔(分)', '最終成功', '最終実行', '状態', '連続失敗', '最終メッセージ']);
ensureSheet_(ss, CONFIG.LOG_SHEET, ['日時', 'ジョブ名', '結果', '所要秒', 'メッセージ']);
// 何度実行してもトリガーが重複しないよう、既存のものを消してから作り直す
ScriptApp.getProjectTriggers()
.filter(t => ['checkHeartbeats', 'weeklySummary'].includes(t.getHandlerFunction()))
.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('checkHeartbeats').timeBased().everyHours(1).create();
ScriptApp.newTrigger('weeklySummary').timeBased()
.onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9).create();
}
// ===== GAS内のジョブを包む =====
function runJob(jobName, fn) {
const start = Date.now();
try {
fn();
report_(jobName, 'OK', elapsed_(start), '');
} catch (e) {
report_(jobName, 'ERROR', elapsed_(start), (e && e.stack) ? e.stack : String(e));
throw e; // GASの「実行数」画面にも失敗として残す
}
}
// ===== cronなど外部からの報告を受け付ける =====
function doPost(e) {
let body;
try {
body = JSON.parse(e.postData.contents);
} catch (err) {
return json_({ ok: false, error: 'invalid json' });
}
if (body.token !== CONFIG.TOKEN) {
return json_({ ok: false, error: 'unauthorized' });
}
const job = String(body.job || '').slice(0, 50);
if (!job || /^[=+\-@]/.test(job) || !['OK', 'ERROR'].includes(body.status)) {
return json_({ ok: false, error: 'bad request' });
}
report_(job, body.status, Number(body.seconds) || 0, body.message || '');
return json_({ ok: true });
}
// ===== 死活監視(毎時トリガーで実行) =====
function checkHeartbeats() {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const sheet = ss.getSheetByName(CONFIG.MONITOR_SHEET);
const alerts = [];
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const last = sheet.getLastRow();
if (last >= 2) {
const range = sheet.getRange(2, 1, last - 1, 7);
const rows = range.getValues();
const now = Date.now();
rows.forEach(r => {
const [name, allowedMin, lastOk, , status] = r;
if (!name || !allowedMin || !(lastOk instanceof Date)) return; // 一度も成功していないジョブは、初回の成功から監視する
if (['STALE', 'ERROR', 'PAUSED'].includes(status)) return; // 通知済み、または一時停止中
if (now - lastOk.getTime() > allowedMin * 60 * 1000) {
r[4] = 'STALE';
alerts.push(`⏰ [停止の疑い] ${name}\n最終成功: ${fmt_(lastOk)}(許容間隔 ${allowedMin}分)`);
}
});
if (alerts.length) range.setValues(rows);
}
trimLogs_(ss);
} finally {
lock.releaseLock();
}
if (alerts.length) notify_(alerts.join('\n\n'));
}
// ===== 週次レポート(監視そのものが動いている証拠になる) =====
function weeklySummary() {
const sheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID).getSheetByName(CONFIG.MONITOR_SHEET);
const last = sheet.getLastRow();
const rows = last >= 2 ? sheet.getRange(2, 1, last - 1, 7).getValues() : [];
const lines = rows
.filter(r => r[0])
.map(r => `[${r[4]}] ${r[0]}(最終成功: ${r[2] instanceof Date ? fmt_(r[2]) : '-'})`);
notify_(`📋 週次監視レポート(監視は稼働中です)\n${lines.join('\n') || '登録されたジョブはありません'}`);
}
// ===== 動作確認用 =====
function testOk() {
runJob('テスト', () => Logger.log('正常に終わる処理'));
}
function testError() {
runJob('テスト', () => { throw new Error('わざと失敗させています'); });
}
// ===== 内部処理 =====
function report_(jobName, status, seconds, message) {
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
const msg = safe_(message);
let alert = '';
const lock = LockService.getScriptLock();
lock.waitLock(30000);
try {
const now = new Date();
ss.getSheetByName(CONFIG.LOG_SHEET).appendRow([now, jobName, status, seconds, msg]);
const sheet = ss.getSheetByName(CONFIG.MONITOR_SHEET);
const range = sheet.getRange(findOrCreateRow_(sheet, jobName), 1, 1, 7);
const v = range.getValues()[0];
const prev = v[4];
v[3] = now;
v[4] = status;
if (status === 'OK') {
v[2] = now;
v[5] = 0;
} else {
v[5] = (Number(v[5]) || 0) + 1;
}
v[6] = msg;
range.setValues([v]);
// 状態が変わったときだけ通知する(同じエラーを何十通も送らない)
if (status === 'ERROR' && prev !== 'ERROR') {
alert = `🚨 [失敗] ${jobName}\n${msg}`;
} else if (status === 'OK' && (prev === 'ERROR' || prev === 'STALE')) {
alert = `✅ [復旧] ${jobName}`;
}
} finally {
lock.releaseLock();
}
if (alert) notify_(alert);
}
function findOrCreateRow_(sheet, jobName) {
const last = sheet.getLastRow();
if (last >= 2) {
const names = sheet.getRange(2, 1, last - 1, 1).getValues();
for (let i = 0; i < names.length; i++) {
if (names[i][0] === jobName) return i + 2;
}
}
sheet.appendRow([jobName, CONFIG.DEFAULT_ALLOWED_MIN, '', '', 'NEW', 0, '']);
return sheet.getLastRow();
}
function notify_(text) {
try {
if (CONFIG.SLACK_WEBHOOK_URL) {
UrlFetchApp.fetch(CONFIG.SLACK_WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ text }),
});
} else {
MailApp.sendEmail(CONFIG.NOTIFY_EMAIL, '[監視] ' + text.split('\n')[0], text);
}
} catch (e) {
// Slackに送れなかったときはメールで知らせる
MailApp.sendEmail(CONFIG.NOTIFY_EMAIL, '[監視] 通知の送信に失敗しました', text + '\n\n' + e);
}
}
function ensureSheet_(ss, name, headers) {
const sheet = ss.getSheetByName(name) || ss.insertSheet(name);
if (sheet.getLastRow() === 0) {
sheet.appendRow(headers);
sheet.setFrozenRows(1);
}
}
function trimLogs_(ss) {
const sheet = ss.getSheetByName(CONFIG.LOG_SHEET);
const excess = sheet.getLastRow() - 1 - CONFIG.LOG_KEEP_ROWS;
if (excess > 0) sheet.deleteRows(2, excess);
}
function safe_(s) {
const str = String(s || '').slice(0, 1000);
return /^[=+\-@]/.test(str) ? "'" + str : str; // 先頭が「=」などだと数式として扱われるのを防ぐ
}
function elapsed_(start) {
return Math.round((Date.now() - start) / 100) / 10;
}
function fmt_(date) {
return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy/MM/dd HH:mm');
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
タイムゾーンを日本時間にする
通知に出る時刻と週次レポートの送信時刻は、プロジェクトのタイムゾーンに従います。
- 左メニューの「⚙ プロジェクトの設定」→「
appsscript.jsonマニフェスト ファイルをエディタで表示する」にチェックを入れる - エディタに出てきた
appsscript.jsonのtimeZoneを"Asia/Tokyo"にする
setup を実行する
関数の選択欄で setup を選んで「実行」します。初回は権限の承認画面が出るので許可してください。
スプレッドシートに「監視」と「ログ」のシートができ、トリガーが2つ(毎時の死活監視と、月曜9時の週次レポート)登録されれば完了です。
Step 2:GASのジョブで動作を確認する
testOk → testError → testError → testOk の順に実行してみてください。
| 実行する関数 | 監視シートの状態 | 通知 |
|---|---|---|
testOk |
NEW → OK | なし |
testError |
OK → ERROR | 🚨 失敗 |
testError(2回目) |
ERROR のまま(連続失敗 2) | なし(同じ状態なので送らない) |
testOk |
ERROR → OK | ✅ 復旧 |
testError は実行画面でも赤いエラーになりますが、これは runJob がエラーを投げ直しているためで、意図どおりの動きです。
Step 3:許容間隔を設定する
監視シートのB列「許容間隔(分)」には、最後に成功してから何分経ったら「止まっている」とみなすかを入れます。新しいジョブは初期値の1500分(25時間)で登録されます。
| ジョブの実行間隔 | 許容間隔(分)の目安 | 考え方 |
|---|---|---|
| 10分ごと | 30 | 2回続けて抜けたら異常 |
| 毎時 | 90 | 1回抜けたら異常 |
| 毎日 | 1500 | 25時間。実行時刻の多少のズレを吸収する |
| 平日のみ毎日 | 4380 | 金曜の朝から月曜の朝までの73時間 |
| 毎月1日 | 46080 | 32日 |
死活監視は1時間ごとに動くので、検知が最大1時間ほど遅れる点は織り込んでおいてください。
意図的に止めているジョブは、E列の状態を PAUSED に書き換えると監視の対象から外れます。ジョブから再び報告が届くと、自動で監視が再開されます。
トリガーの種類や設定方法の詳細は、GASのトリガー完全ガイドにまとめています。
Step 4:Webアプリとしてデプロイする(cronのジョブ用)
cronで動かしているジョブからも報告を受けられるように、doPost を公開します。
- 右上の「デプロイ」→「新しいデプロイ」
- 種類の選択(⚙)で「ウェブアプリ」を選ぶ
- 次のように設定してデプロイする
- 次のユーザーとして実行:自分
- アクセスできるユーザー:全員
- 表示された「ウェブアプリのURL」(
https://script.google.com/macros/s/.../exec)を控える
「全員」に公開しても、TOKEN が一致しない報告は受け付けないようにしてあります。TOKEN は推測されにくい長い文字列にしてください。
curlで動作を確認する
サーバーのターミナルから、次のコマンドで報告を送れるか試します。
curl -L \
-H "Content-Type: application/json" \
-d '{"token":"ここにTOKENと同じ値","job":"curl-test","status":"OK","seconds":0}' \
"https://script.google.com/macros/s/xxxxxxxx/exec"
{"ok":true} が返り、監視シートに curl-test の行が増えれば成功です。
Step 5:cronのジョブを監視ラッパー経由で動かす
どんなコマンドでも包めるラッパーをPythonで書きます。PHPでもシェルスクリプトでも、終了コードで成功か失敗かを判定します。
monitor_run.py として保存してください。
#!/usr/bin/env python3
"""
任意のコマンドを実行して、結果をGASの監視APIに報告するラッパー
使い方:
python3 monitor_run.py ジョブ名 -- 実行するコマンド [引数...]
例:
python3 monitor_run.py order-import -- /usr/bin/php /home/USER/app/import_orders.php
"""
import json
import os
import subprocess
import sys
import time
import urllib.request
from datetime import datetime
CONFIG = {
"ENDPOINT": "https://script.google.com/macros/s/xxxxxxxx/exec", # ★GASのWebアプリのURL
"TOKEN": "ここにTOKENと同じ値", # ★GAS側のCONFIG.TOKENと同じ値
"TIMEOUT_SEC": 1800, # ★ジョブの最大実行時間(秒)。超えたら失敗として扱う
"LOG_FILE": "/home/USER/logs/monitor_run.log", # ★ローカルログの保存先
}
def log(line):
stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
os.makedirs(os.path.dirname(CONFIG["LOG_FILE"]), exist_ok=True)
with open(CONFIG["LOG_FILE"], "a", encoding="utf-8") as f:
f.write(f"[{stamp}] {line}\n")
except OSError as e:
print(f"[{stamp}] ログを書き込めません: {e} / {line}", file=sys.stderr)
def report(job, status, seconds, message):
payload = json.dumps({
"token": CONFIG["TOKEN"],
"job": job,
"status": status,
"seconds": seconds,
"message": message,
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
CONFIG["ENDPOINT"],
data=payload,
headers={"Content-Type": "application/json"},
)
for attempt in range(1, 4):
try:
with urllib.request.urlopen(req, timeout=30) as res:
body = json.loads(res.read().decode("utf-8"))
if body.get("ok"):
return True
log(f"報告が拒否されました: {body}")
return False
except Exception as e:
log(f"報告に失敗しました({attempt}/3): {e}")
time.sleep(5 * attempt)
return False
def main():
if len(sys.argv) < 4 or sys.argv[2] != "--":
print("usage: monitor_run.py JOB_NAME -- COMMAND [ARGS...]", file=sys.stderr)
sys.exit(2)
job = sys.argv[1]
cmd = sys.argv[3:]
start = time.time()
try:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=CONFIG["TIMEOUT_SEC"],
)
code = proc.returncode
output = (proc.stderr or proc.stdout).decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
code = -1
output = f"タイムアウトしました({CONFIG['TIMEOUT_SEC']}秒)"
except FileNotFoundError as e:
code = 127
output = f"コマンドが見つかりません: {e}"
seconds = round(time.time() - start, 1)
status = "OK" if code == 0 else "ERROR"
message = "" if code == 0 else f"exit={code}\n{output[-800:]}"
log(f"{job} {status} {seconds}s exit={code}")
if code != 0:
log(output[-2000:])
report(job, status, seconds, message)
sys.exit(code if code > 0 else (0 if code == 0 else 1))
if __name__ == "__main__":
main()
crontab に登録する
既存のcron行の先頭に、ラッパーを挟むだけです。
# 変更前
0 * * * * /usr/bin/php /home/USER/app/import_orders.php
# 変更後(flockで多重起動を防ぎ、cron自体の出力もログに残す)
0 * * * * /usr/bin/flock -n /home/USER/tmp/order-import.lock /usr/bin/python3 /home/USER/bin/monitor_run.py order-import -- /usr/bin/php /home/USER/app/import_orders.php >> /home/USER/logs/cron.log 2>&1
-
python3やphpのパスはサーバーごとに違うので、which python3、which phpで確認してください -
/home/USER/tmpのようなロック用のディレクトリは、あらかじめ作っておいてください
ジョブを報告に乗せたら、初回の実行後に監視シートの許容間隔をそのジョブに合わせて直します(この例は毎時なので 90)。
GAS・cron・GitHub Actions のどこでジョブを動かすか迷っている場合は、定期実行どこで動かす?GAS・cron・GitHub Actionsの使い分けも参考にしてください。
実用例
1. 既存のGASを包み、「0件」も失敗として扱う
監視用のGASプロジェクトの中で、既存の処理を runJob で包みます。冒頭の表にあった「正常終了したのに中身が空」を拾うため、件数のチェックを入れておくのがおすすめです。
function dailySalesReport() {
runJob('日次売上集計', () => {
const sheet = SpreadsheetApp.openById('売上シートのID').getSheetByName('売上'); // ★
const rowCount = sheet.getLastRow() - 1;
if (rowCount <= 0) {
throw new Error('売上データが0件です。取り込み元を確認してください');
}
const values = sheet.getRange(2, 3, rowCount, 1).getValues(); // ★C列が金額の場合
const total = values.reduce((sum, [v]) => sum + (Number(v) || 0), 0);
MailApp.sendEmail(CONFIG.NOTIFY_EMAIL, '日次売上集計', `合計: ${total.toLocaleString()}円(${rowCount}件)`);
});
}
トリガーには dailySalesReport を登録します。
2. 別のGASプロジェクトから報告する
監視対象のGASが別のプロジェクトにある場合は、そのプロジェクトに次のコードを追加し、Webアプリに報告します。
const MONITOR = {
ENDPOINT: 'https://script.google.com/macros/s/xxxxxxxx/exec', // ★監視用GASのWebアプリのURL
TOKEN: 'ここにTOKENと同じ値', // ★
};
function withMonitor(jobName, fn) {
const start = Date.now();
try {
fn();
sendReport_(jobName, 'OK', start, '');
} catch (e) {
sendReport_(jobName, 'ERROR', start, (e && e.stack) ? e.stack : String(e));
throw e;
}
}
function sendReport_(jobName, status, start, message) {
UrlFetchApp.fetch(MONITOR.ENDPOINT, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({
token: MONITOR.TOKEN,
job: jobName,
status: status,
seconds: Math.round((Date.now() - start) / 100) / 10,
message: message,
}),
muteHttpExceptions: true,
});
}
// 使い方
function myDailyJob() {
withMonitor('請求データ取り込み', () => {
Logger.log('ここで既存の処理を呼び出す');
});
}
3. 監視そのものが止まったときに気づく
監視の仕組みも、いつかは止まります。そこで二重に備えておきます。
- 週次レポート:毎週月曜9時に「監視は稼働中です」というレポートが届きます。届かない週があれば、監視が止まっていると判断できます
-
GASのトリガー失敗通知:Apps Scriptの「トリガー」画面で
checkHeartbeatsを開き、「エラー通知設定」を「すぐに通知を受け取る」にしておくと、監視自体がエラーになったときにGoogleからメールが届きます
つまずきやすいポイント
コードを直したのに、Webアプリの動きが変わらない
Webアプリはデプロイした時点のコードで動きます。コードを修正したら「デプロイ」→「デプロイを管理」→ 鉛筆アイコン →「バージョン:新バージョン」で更新してください。
「新しいデプロイ」を作るとURLが変わってしまうため、cron側の設定も書き換えが必要になります。
curlでエラーページのHTMLが返ってくる
GASのWebアプリは、POSTを受けると別のURLへリダイレクトして結果を返します。
-
-Lを付けないと、リダイレクトをたどらずに終わってしまう -
-X POSTを付けると、リダイレクト先にもPOSTしてしまい、エラーになる
Step 4のように、-L と -d だけを付けてください。Pythonの urllib とGASの UrlFetchApp は、リダイレクトを自動で正しく処理します。
{"ok":false,"error":"unauthorized"} が返る
GAS側の CONFIG.TOKEN と、Python側の CONFIG["TOKEN"] が一致していません。前後に空白が入っていないかも確認してください。
毎週月曜に「停止の疑い」が届く
平日だけ動くジョブの許容間隔が短すぎます。金曜から月曜までの空きを考慮して、Step 3の表を目安に広げてください。
通知が届かない
- Slack:Webhook URLが正しいか確認してください。Slackへの送信に失敗した場合は
NOTIFY_EMAIL宛てにメールが届きます - メール:無料のGoogleアカウントでは、GASから送れるメールは1日100通前後が上限です。通知を「状態が変わったときだけ」にしているのは、この上限に引っかからないためでもあります
- どちらも届かない:Apps Scriptの「実行数」画面で
checkHeartbeatsがエラーになっていないか確認してください
Lock timeout のエラーが出る
多くのジョブが同じ時刻(0 * * * * など)に一斉に報告すると、ロック待ちが発生します。cronの実行時刻を数分ずつずらしてください。
cronのジョブ名が文字化けする、cronが動かない
- crontabでは、ジョブ名は
order-importのように半角英数字とハイフンにしておくと安全です - crontabの中で
%は特別な意味を持ちます。date +%Y%m%dなどを書く場合は\%とエスケープしてください
トークンをコードに直書きしたくない
CONFIG.TOKEN や SLACK_WEBHOOK_URL は、スクリプトプロパティ(「プロジェクトの設定」→「スクリプト プロパティ」)に保存し、PropertiesService.getScriptProperties().getProperty('TOKEN') で読み込む形にもできます。Gitで管理する場合は、こちらにしてください。
まとめ
- エラー通知だけでは**「起動していない」ことに気づけない**。死活監視(最後に成功した時刻のチェック)とセットで考える
- 監視する側(GAS)と監視される側(サーバー)を別の場所で動かすと、サーバーが丸ごと落ちても気づける
- 通知は状態が変わったときだけ送る。通知が多すぎると、そのうち誰も見なくなる
- 「0件で正常終了」はジョブ側で件数をチェックして、失敗として扱う
- 監視そのものの停止には、週次レポートとトリガー失敗通知で備える
- cronのジョブは、既存の行の先頭にラッパーを挟むだけで監視に乗せられる
業務自動化のご相談
「作った自動化が止まっていないか不安」「既存のスクリプトに監視の仕組みを入れたい」などのご相談を受け付けています。