「エラーなし」で止まるGASトリガーは、原因が2通りに分かれる

relmea で踏んだ「エラーが出ないのに止まる」原因は、次の2つでした。

  • 承認(OAuth)の失効:プロジェクト全体の時間主導トリガーが一斉に止まる
  • トリガー関数の第1引数にイベントオブジェクトが入る:特定の1本だけ結果が残らない

どちらも、画面上は「トリガーが設定されている」状態のままです。見た目が正常なところは共通しています。

違いは範囲です。承認の失効では全部が止まり、引数の問題では該当する関数だけが結果を残しません。止まっているのが全部か1本かが、切り分けの最初の手がかりになります。

なお、「動いてはいるが、直したはずの古い挙動のまま」という3つ目の症状もあります。これはトリガーの導入がバージョン固定になっている場合の話で、別記事で書きました。この記事では扱いません。

原因1:承認(OAuth)の失効。3回起きた実例

承認の失効は、2026年7月から9月までに3回起きました。

  • 1回目 2026-07-25:約5日半停止
  • 2回目 2026-08-23 20:46 〜 08-24 14:04:約17時間
  • 3回目 2026-09-13 09:26 〜 約16:00(前回から20日)

承認の失効だと判定する4点

実行数の画面とトリガーの挙動を見て、次の4点がそろったら承認の失効と判断しています。

  1. 実行数の画面で、時間主導型の最後の実行が「0秒・失敗・この実行に関するログはありません」になっている。関数が起動する前に落ちている
  2. それ以降、時間主導型の実行が1件も無い
  3. 承認が要らないシンプルトリガー(onOpen など)だけが成功し続けている
  4. 読み取りだけの関数を手動実行すると「承認が必要です」が出る

1から3は状況証拠で、4点目で確定します。手動実行で承認を求められた時点で、原因はコードではなく承認だと分かります。

承認の失効が見つけにくい理由

この原因が厄介なのは、正常に見える材料が多いからです。

  • トリガー一覧は全部残ったままで、設定は正常に見える
  • Google からの失敗通知メールは来ない。別のプロジェクトの失敗通知は届いていたので、メールが無いことは正常の証拠になりません
  • ログ用のシートも同時に止まる。実行そのものが無いので、書き込みも発生しません
  • catch で ERROR を書く作りの関数でも、1行も残らない
  • GCP プロジェクトを紐付けていないと、Cloud Logging は空のまま

エラーログがきれいに空であること自体が手がかりでした。関数が起動する前に落ちているので、関数の中の try/catch は一度も走りません。

承認失効を速く判定する方法と、復旧時の注意

ログシートを日ごとに畳む

2回目と3回目で使った判定は、ログシートを「日ごとの実行件数と、最初と最後の時刻」に畳むことでした。

relmea の正常な日は、0:02、0:40、3:02、3:46、5:25、23:20、23:54 と、終日にわたって実行が並びます。ここが途中から消えていれば、その時刻に何かが起きています。

2回目は、独立した5本のトリガーが同じ時間帯で一斉に消えていました。個別の関数のバグなら、互いに関係のない5本が同時に消えることはありません。個別の関数ではなく承認側の問題だと、この並びで読めました。

Webアプリへの問い合わせで、画面を開かずに判定する

3回目は、同じプロジェクトの Webアプリ(doGet)への問い合わせが、Google の HTML で 403 を返しました。表示は「アクセスが拒否されました/ドライブ アクセス権が必要です」です。前日には同じ URL が通っていて、再承認の直後には 200 に戻りました。

エディタの画面を開かなくても、HTTP の応答だけで承認の失効を判定できたことになります。

復旧の手順

復旧は、誰かが関数を1つ実行し、「権限を確認」から承認し直すことです。人の OAuth 同意が必要なので、relmea では AI に代行させない領域として扱っています。

既存のトリガーはそのまま動き出し、作り直しは要りませんでした。1回目は承認の5分後に、時間主導の処理が「完了」に戻ったことを実行数の画面で確かめています。2回目は、メニューから関数を手動実行して承認し直した直後から、全トリガーが復活しました。

承認する前に確認しておくこと

  • 停止中に発火しなかった予約は戻りません。復旧後に自動で追いついてはくれません
  • 逆に、溜まった予約が復旧時に一斉に投稿されないかは、コードで先に確かめます。relmea の配信処理は「予約の日付が今日と一致する」ことを条件にしているので、古い予約は弾かれます。これをコードで確認してから承認しました

欠けた日の埋め方

停止中のデータは、指標の性質で扱いを分けました。

  • 日付を指定して取り直せる外部データ(アクセス解析)は、後から埋める
  • 実行した時点の値しか取れない指標は、空けたままにする

後者を 0 で埋めると「その日の数字が0だった」という記録になり、後から今日の値で埋めると別の日の数字が混ざります。どちらも事実と違うので、欠けた日は欠けたままにしています。

見張りの穴

見張りには穴が残っています。現在の見張りは「ログが6時間無音なら鳴る」仕組みなので、発見が最大6時間遅れます。3回目は、停止から5.7時間が経った時点でまだ通知が出ていませんでした。

1時間ごとに Webアプリの 403 を検査する案は出していますが、まだ提案の段階で、実装していません。

原因2:トリガー関数の第1引数がイベントオブジェクト。2回踏んだ実例

2つ目の原因は、承認とは無関係です。時間主導トリガーは、ハンドラ関数の第1引数にイベントオブジェクトを渡します。中身は year、month、day-of-month、hour、minute、triggerUid、authMode などです。

引数なしで手動実行したときは、この引数は undefined です。だから「手動では動くのに、トリガーでは壊れる」関数ができあがります。

1回目 2026-08-11:KPI収集が例外で止まる

問題の関数は、次の形をしていました(説明のために簡略化しています)。

function collect(day) {
  day = day || new Date();
  // 以降で day.getFullYear() を使う
}

手動で実行すると day は undefined なので、new Date() が入って成功します。トリガーから呼ばれると、day にはイベントオブジェクトが入ります。オブジェクトは truthy なので day || new Date() はそのまま採用され、day.getFullYear() で例外になります。

この関数は成功ログしか書かない作りだったので、失敗しても痕跡がゼロでした。手がかりは「手動は成功するのに、トリガーの結果だけが残らない」という症状だけです。

同じ時刻に動いている他のトリガーは正常でした。ここが承認の失効との切り分け点です。承認が切れていれば、他のトリガーも一緒に止まります。

2回目 2026-08-21:流入集計が「成功」のまま0件

こちらは、もう一段見つけにくい形でした(同じく簡略化しています)。

function trafficCollect(maxDays) {
  const days = maxDays || DEFAULT_DAYS;
  // 開始日 = 今日 - days * 86400000
}

トリガーから呼ばれると maxDays にイベントオブジェクトが入り、maxDays * 86400000 が NaN になります。開始日が Invalid Date になり、日付のループが1周も回りませんでした。例外は出ず、静かに0件で終わります。

しかも集計用のタブは組み直されるので、「50日×100行を更新/今回取得0日」という INFO の成功ログが出ていました。ログ上は成功に見えます。結果として、2026-08-20 と 08-21 の2日分が欠けました。

1回目は例外で止まり、2回目は例外なしで0件のまま成功扱いになりました。原因は同じでも、現れ方が違います。Date に限らず、引数を数値や文字列として使う関数も同じところでつまずきます。

症状から原因を切り分ける早見

  • 全部のトリガーが止まる。最後の実行が「0秒・失敗・ログなし」。onOpen だけ生きている。手動実行で「承認が必要です」が出る → 承認の失効
  • 1本だけ結果が残らない。手動実行は成功する。同時刻の他のトリガーは正常 → 第1引数がイベントオブジェクトの疑い
  • 動いてはいるが、直したはずの古い挙動のまま → トリガーの版固定(別記事)

最初に見るのは、止まっているのが全部か1本かです。全部なら、ログシートを日ごとに畳んで一斉に消えた時刻を探します。1本だけなら、その関数の第1引数を見ます。この順番を飛ばしてコードを疑い始めると、承認の失効が原因だったときに、何を直しても1件も動きません。

relmea で決めている対策と、まだ空いている穴

引数は型で判定する

トリガーから呼ばれる関数では、引数に || で既定値を当てる書き方をやめ、型で判定する規則にしています。

// Date を期待する場合
const d = (v instanceof Date && !isNaN(v.getTime())) ? v : new Date();

// 数値を期待する場合
const n = (typeof v === 'number' && isFinite(v) && v > 0) ? v : DEFAULT_DAYS;

イベントオブジェクトは Date でも数値でもないので、どちらの判定でも外れて既定値に落ちます。

トリガーを張るときにハンドラを列挙する

新しくトリガーを張るときは、コードからハンドラ名を一覧にし、第1引数を持つ関数だけを点検します。

grep -hoE "newTrigger\('[A-Za-z_]+'\)" *.gs

全関数を読む必要はなく、トリガーに繋がっていて、かつ引数を取る関数だけを見れば足ります。

失敗しても必ず1行ログを残す

try/catch で、失敗したときにも必ず1行のログが残るようにします。成功ログしか書かない関数は、1回目のように失敗の痕跡がゼロになります。

「0件で正常終了」は WARN にする

「0件で正常終了」を INFO で書くと、2回目のように成功に見えます。0件は WARN にして、ログを見たときに目に留まる扱いにする規則にしています。

まだ空いている穴

  • 承認の失効は、現状の見張りでは発見が最大6時間遅れる。1時間ごとの 403 検査は提案段階で未実装
  • 承認の再同意は人の操作が要る。発見が早くなっても、復旧には人が入る
  • 停止中の「実行時点の値しか取れない指標」は、後から取り戻せない

これらの対策がどれだけ効いたかを示す数字は、まだ持っていません。エラーも通知も無いときは、まず全部が止まっているか1本だけかを見て、ログが空であることそのものを手がかりにしてください。失敗時にも1行残す作りにしておけば、次に止まったときにその手がかりが残ります。