見出し画像

GAS開発で地味に助かるデバッグ&エラーハンドリングTips

この記事で得られること

GASはサクッと書けるのが魅力ですがその分デバッグ環境は貧弱です。
ブラウザのDevToolsは使えないしブレークポイントの挙動も独特。

エラーメッセージが不親切で何が原因かわからないまま時間を溶かす。
GASを書いたことがある人なら経験があるはずです。

この記事では自分がGAS開発で「これを最初から知っていれば……」と思ったデバッグとエラーハンドリングのTipsをまとめます。

  • Logger.logとconsole.logの使い分け

  • 実行ログの読み方と落とし穴

  • muteHttpExceptionsを使ったAPI系エラーの正しい捕まえ方

  • try-catchの実践パターン(リトライ・通知・ログ記録)

  • 型に起因するバグの見つけ方

地味ですがここを押さえておくと開発スピードが目に見えて変わりますよ!



Logger.log vs console.log

GASにはログ出力の方法が2つあります。

function logComparison() {
  Logger.log('これはLogger.logの出力');
  console.log('これはconsole.logの出力');
}

どちらも「ログを出す」という点では同じですが、挙動が違います。

Logger.log

  • スクリプトエディタの「実行ログ」に表示される

  • 実行ごとにリセットされる(前回の実行ログは消える)

  • オブジェクトを渡すと自動でJSON文字列化してくれる

  • トリガー実行時には確認しにくい

console.log

  • Cloud Loggingに出力される(GCPのログ基盤)

  • ログが永続化される(後から確認できる)

  • トリガーで自動実行したときのログも残る

使い分けの結論としては開発中はLogger.log、本番(トリガー実行)はconsole.logです。

特にトリガーで定期実行するスクリプトではconsole.logで書いておかないと「昨晩の自動実行でエラーが出たかどうか」を確認する手段がなくなります。

/**
 * 本番スクリプトでの推奨ログパターン
 */
function productionTask() {
  console.log(`[${new Date().toISOString()}] タスク開始`);

  try {
    // メイン処理
    const result = doSomething();
    console.log(`[${new Date().toISOString()}] 成功: ${result.count}件処理`);
  } catch (e) {
    console.error(`[${new Date().toISOString()}] エラー: ${e.message}`);
    console.error(e.stack);
  }
}

タイムスタンプを自分でつけるのがポイントです。
Cloud Loggingにもタイムスタンプはありますがログの中に明示しておくほうが後から追いかけるときに格段に楽です。


実行ログの読み方と落とし穴

実行ログの確認方法

スクリプトエディタの左メニュー「実行数」(再生ボタンアイコン)から過去の実行履歴を確認できます。

各実行についてステータス(完了/失敗/タイムアウト)、実行時間、ログ出力が確認できます。

落とし穴:「完了」でもエラーが起きている

実行ステータスが「完了」と表示されていても内部でエラーが起きているケースがあります。

function sneakyError() {
  const items = fetchFromApi(); // ここで空配列が返ってきている

  items.forEach(item => {
    // 0件なのでforEachが回らない → エラーにならない → 「完了」になる
    processItem(item);
  });

  Logger.log('処理完了'); // これが出力される。一見成功に見える
}

対策は処理件数を必ずログに出すことです。

function reliableTask() {
  const items = fetchFromApi();
  console.log(`取得件数: ${items.length}`);

  if (items.length === 0) {
    console.warn('データが0件です。APIレスポンスを確認してください');
    return;
  }

  let successCount = 0;
  let errorCount = 0;

  items.forEach(item => {
    try {
      processItem(item);
      successCount++;
    } catch (e) {
      errorCount++;
      console.error(`処理失敗 [${item.id}]: ${e.message}`);
    }
  });

  console.log(`処理結果: 成功${successCount}件, 失敗${errorCount}件 / 全${items.length}件`);
}

「何件取得して何件成功して何件失敗したか」
この3つの数字がログにあるだけで問題の切り分けが簡単になります。


muteHttpExceptionsの正しい使い方

API連携のエラーハンドリングで特に重要なのがmuteHttpExceptionsの使い方です。
ここで詳しく解説します。

デフォルト動作の問題

muteHttpExceptionsを設定しないとAPIが4xxや5xxを返した時点でGASが例外を投げます。

// ❌ レスポンスボディ(エラー詳細)が取得できない
function badExample() {
  try {
    const response = UrlFetchApp.fetch('https://api.example.com/data');
    // 401が返ると、ここに到達する前に例外が飛ぶ
  } catch (e) {
    Logger.log(e.message);
    // → "Request failed for https://api.example.com/data returned code 401."
    // これだけでは何が間違っているのかわからない
  }
}

正しいパターン

// ✅ ステータスコードとレスポンスボディの両方を確認できる
function goodExample() {
  const options = {
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch('https://api.example.com/data', options);
  const statusCode = response.getResponseCode();
  const body = response.getContentText();

  if (statusCode !== 200) {
    console.error(`APIエラー [${statusCode}]: ${body}`);
    // → "APIエラー [401]: {"error":"invalid_token","message":"Token has expired"}"
    // トークン期限切れだとわかる → 対処できる
    return null;
  }

  return JSON.parse(body);
}

API側が返すエラーレスポンスにはたいてい原因の詳細が含まれています。
muteHttpExceptions: trueにしておかないとそれが読めないのでAPI連携のスクリプトでは常にtrueを設定する。

これは鉄則として覚えてしまって問題ないです。


try-catchの実践パターン

パターン①:リトライ付き

一時的なネットワークエラーやAPI側の過負荷に対応するパターンです。

/**
 * リトライ付きfetch
 * @param {string} url
 * @param {Object} options
 * @param {number} maxRetries
 * @return {Object} パース済みレスポンス
 */
function fetchWithRetry(url, options = {}, maxRetries = 3) {
  options.muteHttpExceptions = true;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = UrlFetchApp.fetch(url, options);
      const statusCode = response.getResponseCode();

      // 成功
      if (statusCode >= 200 && statusCode < 300) {
        return JSON.parse(response.getContentText());
      }

      // リトライしても無意味なエラー(認証エラー等)
      if (statusCode === 401 || statusCode === 403) {
        throw new Error(`認証エラー (${statusCode}): リトライ不要`);
      }

      // リトライ対象のエラー(429, 5xx)
      if (attempt < maxRetries) {
        const waitMs = Math.pow(2, attempt) * 1000; // 2秒, 4秒, 8秒
        console.log(`リトライ ${attempt}/${maxRetries}(${waitMs}ms待機)`);
        Utilities.sleep(waitMs);
      }
    } catch (e) {
      // 認証エラー等リトライ不要なエラーはそのまま投げる
      if (e.message.includes('リトライ不要') || attempt === maxRetries) throw e;
      Utilities.sleep(Math.pow(2, attempt) * 1000);
    }
  }

  throw new Error(`${maxRetries}回リトライしましたが失敗しました`);
}

ポイントは、リトライすべきエラーとすべきでないエラーを分けることです。
401(認証エラー)を何回リトライしても意味がありません。

429(レート制限)や5xx(サーバーエラー)は時間を空ければ成功する可能性があるのでリトライ対象です。

パターン②:エラー時にSlack通知

トリガーで自動実行しているスクリプトはエラーが出ても気づけないことがあります。
重要な処理にはSlack通知を仕込んでおくと安心です。

まずSlack Incoming Webhookへの通知関数を用意します。
WebhookのURLはPropertiesServiceに保存しておきます(設定方法はGASのPropertiesServiceドキュメントを参照)。

/**
 * Slack Incoming Webhookに通知を送る
 * PropertiesServiceに SLACK_WEBHOOK_URL を設定しておくこと
 */
function sendSlackNotification(message) {
  const webhookUrl = PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_URL');
  if (!webhookUrl) return; // 未設定の場合はスキップ

  UrlFetchApp.fetch(webhookUrl, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: message }),
    muteHttpExceptions: true
  });
}

この関数を使って、エラー時に通知するラッパーを作ります。

/**
 * エラー時にSlackに通知するラッパー
 */
function withErrorNotification(taskName, taskFn) {
  try {
    taskFn();
    console.log(`[${taskName}] 正常終了`);
  } catch (e) {
    const errorMsg = `[${taskName}] エラー発生\n${e.message}\n${e.stack}`;
    console.error(errorMsg);

    try {
      sendSlackNotification(`⚠️ GASエラー通知\n${errorMsg}`);
    } catch (notifyError) {
      console.error('Slack通知にも失敗: ' + notifyError.message);
    }
  }
}

// 使い方
function dailyTask() {
  withErrorNotification('日次データ取得', () => {
    const data = fetchWithRetry('https://api.example.com/data');
    writeToSheet(data);
  });
}

通知処理自体もtry-catchで囲むのがポイントです。
エラー通知の送信に失敗して元のエラーが握りつぶされるという二重事故を防ぎます。

パターン③:エラーログをシートに記録

Slack通知が使えない環境ではエラーをスプレッドシートに記録する方法もあります。

/**
 * エラーをログシートに記録する
 */
function logError(functionName, error) {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const logSheet = ss.getSheetByName('エラーログ') || ss.insertSheet('エラーログ');

    // ヘッダーがなければ作成
    if (logSheet.getRange('A1').getValue() === '') {
      logSheet.getRange('A1:D1').setValues([['日時', '関数名', 'エラーメッセージ', 'スタックトレース']]);
    }

    const nextRow = logSheet.getLastRow() + 1;
    logSheet.getRange(nextRow, 1, 1, 4).setValues([
      [new Date(), functionName, error.message, error.stack || '']
    ]);
  } catch (e) {
    // ログ記録自体が失敗した場合はconsole.logに逃がす
    console.error('エラーログ記録に失敗: ' + e.message);
  }
}

「エラーログ」シートを作っておけばいつ何が起きたかをスプレッドシート上で追跡できます。
フィルタをかけて特定のエラーだけ絞り込んだり関数ごとのエラー頻度を集計したりするのにも便利です。


型に起因するバグの見つけ方

GAS(JavaScript)は動的型付けなので型に起因するバグが頻発します。
特にスプレッドシートとのやりとりで厄介なパターンがいくつかあります。

getValues()は常に二次元配列

// ❌ よくある間違い
function wrongPattern() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const value = sheet.getRange('A1:A10').getValues();

  // valueは [[1], [2], [3], ...] であって [1, 2, 3, ...] ではない
  if (value.includes(5)) {
    // → 永遠にtrueにならない
  }
}

// ✅ flat()で一次元配列にする
function correctPattern() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const values = sheet.getRange('A1:A10').getValues().flat();

  // valuesは [1, 2, 3, ...] になる
  if (values.includes(5)) {
    // → 正しく動く
  }
}

セルの値が数値か文字列かわからない問題

スプレッドシートのセルに「100」と入力されていても、GASで取得すると数値の100になる場合と文字列の"100"になる場合があります。

function typeCheck() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const val = sheet.getRange('A1').getValue();

  // 型を確認するクセをつける
  console.log(`値: ${val}, 型: ${typeof val}`);

  // 比較時は明示的に型変換する
  if (Number(val) === 100) {
    // 数値として比較
  }

  if (String(val) === '100') {
    // 文字列として比較
  }
}

Date型の罠

スプレッドシートの日付セルをGASで読むとJavaScriptのDateオブジェクトとして返ってきます。
これは便利ですがタイムゾーンの扱いで事故が起きやすいポイントです。

function dateCheck() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const dateVal = sheet.getRange('A1').getValue();

  // Dateオブジェクトかどうかを確認
  if (dateVal instanceof Date) {
    // スプレッドシートのタイムゾーン設定に依存する
    console.log(`日付: ${dateVal.toISOString()}`);

    // 日付文字列として扱いたい場合はUtilities.formatDateが安全
    const formatted = Utilities.formatDate(dateVal, 'Asia/Tokyo', 'yyyy-MM-dd');
    console.log(`フォーマット済み: ${formatted}`);
  } else {
    console.log(`日付ではない値: ${dateVal} (型: ${typeof dateVal})`);
  }
}

Utilities.formatDate()にタイムゾーンを明示的に渡すのが安全です。
GASのスクリプトタイムゾーンとスプレッドシートのタイムゾーン設定が異なると日付が1日ずれるバグが起きます。


デバッグ効率を上げる小技

テスト用の関数を分けておく

本番の関数に直接Logger.logを埋め込むのではなくテスト用の関数を別に作るのが楽です。

// 本番用(きれいに保つ)
function fetchItems() {
  const items = callApi();
  writeToSheet(items);
  return items;
}

// テスト用(ここで好きなだけログを出す)
function test_fetchItems() {
  const items = fetchItems();
  console.log('件数: ' + items.length);
  console.log('最初の1件: ' + JSON.stringify(items[0], null, 2));
  console.log('最後の1件: ' + JSON.stringify(items[items.length - 1], null, 2));
}

関数名にtest_プレフィックスをつけておけば、本番の関数と混ざりません。

JSON.stringifyの第3引数

APIレスポンスのデバッグで地味に役立つのがJSON.stringifyの第3引数です。

const obj = { name: 'test', nested: { a: 1, b: [2, 3] } };

// 読みにくい
Logger.log(JSON.stringify(obj));
// → {"name":"test","nested":{"a":1,"b":[2,3]}}

// 読みやすい
Logger.log(JSON.stringify(obj, null, 2));
// → {
//      "name": "test",
//      "nested": {
//        "a": 1,
//        "b": [2, 3]
//      }
//    }

第3引数にインデント数を渡すだけでネストしたオブジェクトが見やすくなります。


まとめ

GASのデバッグは環境が限られている分基本を押さえておくかどうかで開発効率に大きな差が出ます。

この記事のポイントを振り返ると、以下の通りです。

  • 開発中はLogger.log、本番はconsole.logを使い分ける

  • 実行ステータス「完了」を信用しない。処理件数をログに出す

  • API連携ではmuteHttpExceptions: trueを常にセットする

  • try-catchはリトライすべきエラーとすべきでないエラーを分けて設計する

  • 型の問題はtypeof / instanceof で確認するクセをつける

これらのTipsはAPI連携を含むより複雑なGAS開発で特に効いてきます。

外部API連携の実践的なハマりポイント(OAuth認証のトークン管理、レート制限対策、URLエンコーディングの罠など)については、こちらの記事で実際のコード付きで詳しく解説しています。



このnoteでは、エンジニアの副業・自動化の実践過程を発信しています。

いいなと思ったら応援しよう!