環境情報
| 項目 | 内容 |
|---|---|
| 実行基盤 | Google Apps Script(V8 ランタイム) |
| 外部API | Google Search Console API v3(searchAnalytics.query) |
| 起動方法 | 時間主導トリガー(毎週月曜 6:00) |
| 出力先 | Google スプレッドシート → Drive に CSV を退避 |
| 対象サイト規模 | 月間表示回数 約12,000、週あたりクエリ行 約3,000 |
| 通知 | なし(これが最大の温床) |
| 監視していたもの | 実行ログの「完了」表記のみ |
結論を先に書きます。exitコード(=例外なく終わったか)は「動いた」しか保証しません。「正しく動いた」は一切保証しません。 そして私はこれに13週間気づきませんでした。
何が起きていたか
やっていたことはシンプルです。Search Console API から直近7日分のクエリを取得し、スコアリングして閾値以上のキーワードだけを「発掘候補」としてスプレッドシートに書き出す。それを週次トリガーで回していました。
毎週月曜、私は実行ログを開いて「実行完了」の行を確認し、安心して閉じていました。赤いエラーは一度も出ていません。
ある日、ふと出力ファイルを直接開きました。ヘッダ行だけでした。しかも13週連続で0件。シートには1行もデータが積まれていませんでした。
なぜ13週間気づけなかったのか
振り返ると、原因は3つに分解できます。
- ログの見方が「成功したか」だけだった(出力の中身を見ていない)
- 自動化した達成感で、成果物を開かなくなった
- 通知が一切なかった(0件でも誰も教えてくれない)
特に2番目が厄介です。自動化とは「見なくてよくなる」ことではなく「見る場所を変える」ことなのに、私は前者だと錯覚していました。
誤診:「トリガーが設定されていないのでは」
最初に疑ったのは、トリガー未設定でした。過去に別のスクリプトで設定を忘れた経験があったので、真っ先にその可能性を考え、設定画面を開く前に「たぶんそうだろう」と結論しかけました。
ここで踏みとどまって、一次データを見に行きました。GAS の「実行数」画面と Cloud Logging の履歴です。結果、13回分の実行がすべて記録され、すべて正常終了していました。
つまり、動いていた。正しく動いていなかっただけ。 推測(二次情報)とログ(一次情報)が真逆だったわけです。原因調査で一番危ないのは、自分の記憶を証拠として扱うことだと痛感しました。
真因:閾値が構造的に到達不能だった
スコアリングのコードはこうなっていました。
function scoreKeyword(row) {
const impressions = row.impressions; // 表示回数
const clicks = row.clicks;
const position = row.position;
// 別サイトでの経験値からコピーした固定値
if (impressions < 1000) return 0;
if (position > 10) return 0;
return (clicks / impressions) * (11 - position);
}
閾値 impressions >= 1000 は、以前運用していた大規模サイトでは妥当な数字でした。しかしこのサイトはロングテール中心で、大半のクエリは表示回数が二桁です。実測した分布はこうでした。
| 分位点 | 表示回数 | 旧閾値での通過 |
|---|---|---|
| p50(中央値) | 12 | 0% |
| p90 | 118 | 0% |
| p99 | 640 | 0% |
| max | 2,180 | 通過するが position > 10 で落ちる |
impressions >= 1000 を満たすのは全体の0.3%未満、さらにその大半が position > 10。積集合はほぼ空集合でした。バグではなく、設計のミスマッチです。例外は飛ばないので、どこにもエラーは出ません。
手順:実測値から閾値を逆算して再校正する
やったことを手順として残します。閾値は「経験値」ではなく「実測値」から決めます。
- 閾値なしで全行を書き出す(サンプリング用の一時シート)
- 分位点を計算する(p50 / p90 / p95 / p99)
- 目標通過件数を決める(例:週150件=上位5%)
- その件数になる閾値を逆算する
-
閾値をコードから外部化する(
PropertiesServiceに保存) - 再実行し、出力件数を目視で確認する
手順2〜4は GAS 内で完結します。
function calcThresholds(rows) {
const imps = rows.map(r => r.impressions).sort((a, b) => a - b);
const q = p => imps[Math.min(imps.length - 1, Math.floor(imps.length * p))];
return {
minImpressions: Math.max(1, q(0.50)), // 中央値あたりまで許容
maxPosition: 30, // 10 は絞りすぎだった
minScore: 0.02,
minExpectedCount: 30, // これ未満なら異常とみなす
calibratedAt: new Date().toISOString(),
sourceRows: rows.length
};
}
ポイントは minExpectedCount を持たせたことです。「何件出るはず」を閾値とセットで定義しておくと、監視条件が自然に決まります。
監視対象を exitコード から「出力件数」へ変える
比較表にすると、何を見落としていたかがはっきりします。
| 観点 | exitコード監視(Before) | 出力件数監視(After) |
|---|---|---|
| 検知できる障害 | 例外、タイムアウト、認証切れ | 左記に加えて沈黙障害全般 |
| 検知できない障害 | フィルタ後0件、閾値ミス、キー名変更 | — |
| 一次情報 | 実行ログの文字列 | 成果物そのもの |
| 通知の根拠 | なし | 件数の閾値・前週比 |
| 気づくまでの時間 | 13週間 | 翌週の通知時点 |
実装はこうなりました。「入力0件」と「フィルタ後0件」を明確に区別するのがミソです。
function main() {
const rows = fetchSearchAnalytics(); // 入力
if (rows.length === 0) {
throw new Error('入力0件: APIレスポンス自体が空。期間指定か権限を疑う');
}
const th = JSON.parse(
PropertiesService.getScriptProperties().getProperty('THRESHOLDS')
);
const picked = rows.filter(r =>
r.impressions >= th.minImpressions &&
r.position <= th.maxPosition &&
(r.clicks / Math.max(1, r.impressions)) * (31 - r.position) >= th.minScore
);
if (picked.length < th.minExpectedCount) {
throw new Error(
`出力${picked.length}件 (期待${th.minExpectedCount}件以上) / 入力${rows.length}件`
);
}
writeSheet(picked);
Logger.log(`OK: 入力${rows.length}件 → 出力${picked.length}件`);
}
例外を投げる設計にしたので、GAS のトリガー失敗通知(「失敗時の通知」設定)にそのまま乗ります。「出力件数が期待未満なら例外にする」——これだけで、通知基盤を新規に作らずに沈黙障害を検知できます。
なお picked.length === 0 ではなく < minExpectedCount にしているのは、0件だけを狙い撃ちすると「1件だけ通った」状態を見逃すからです。
ハマりポイント
- GAS の実行数画面は「失敗」しか目立たない。 成功は緑で流れていくので、ログを眺める運用は成功の確認にならない
-
Logger.logは長期保存に向かない。 後から追うならスプレッドシートか Cloud Logging に落とす -
トリガーの実行時間は6分上限。
rowLimit: 25000を素直に取ると重い。dimensionsを増やすとクォータも食う - 「データ未確定」と「0件」は区別できない。 Search Console は直近2〜3日が確定しないため、期間指定をずらすと件数が変わる。件数監視には「確定済み期間のみ対象」の条件を入れる
- 自分で出した「完了」ログは自己申告。 成果物と突き合わせて初めて証拠になる
FAQ
Q1. トリガーが動いたかどうかは、どこで確認すればいいですか?
GAS の「実行数」画面(または Cloud Logging)が一次情報です。「設定したはず」という記憶は証拠になりません。
Q2. 出力件数の閾値はどう決めますか?
過去の実測分布から分位点を出し、「上位何%を拾いたいか」から逆算します。経験値からのコピーは今回の再現に直結します。
Q3. 0件が正常なケースはどう扱いますか?
新規サイトや閑散期は0件も正常です。その場合は minExpectedCount: 0 にしたうえで、代わりに「入力0件の検知」だけを残します。監視を消すのではなく、期待値を下げて維持します。
Q4. 既存バッチに後付けする場合、優先順位は?
①入力件数と出力件数のログ出力 → ②期待件数未満で例外 → ③前週比±50%で警告、の順が費用対効果が高いです。①だけでも今回の事故は翌週に発見できました。
チェックリスト
- exitコード以外の「成果物KPI」を1つ以上監視している
- 「入力0件」と「フィルタ後0件」をログで区別している
- 閾値をコードにハードコードせず、実測から逆算して外部化している
- 期待件数を下回ったら例外を投げ、トリガー失敗通知に乗せている
- 前週比の逸脱で警告を出している
- 出力ファイルを月1回は人間が開いて実査している
まとめ
正常終了は「動いた」だけで、「正しく動いた」は保証しません。今回の13週間は、バグではなく監視設計の穴が生んだ静かな損失でした。そして厄介なのは、この種の障害がログに何も残らないことです。エラーが出ないから気づけない、気づけないから直らない、直らないから成果物が増えない——そのループに入ると、人間はむしろ安心してしまいます。
対策は地味です。成果物の件数を数える。期待値を決めて、下回ったら例外にする。たったそれだけです。あなたのバッチは今週、何件出力しましたか? 即答できないなら、それが最初のシグナルかもしれません。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中