PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

Slack の Event Subscription と GAS を使ってエラーを整理し 65% 解消した話

こんにちは。OTTサービス技術部 開発第5グループの松本です。

「アラートが多すぎて、どれを直せばいいか分からない…」私たちITエンジニアが日常的に抱えるこのモヤモヤを解消した取り組みのレポートです。本プロジェクトでは、Slack APIGoogle App Script (GAS)、そしてGoogleスプレッドシートという、身近なツールを連携させ、エラーアラートの発生状況を自動で記録・集計する仕組みを構築しました。この仕組みのおかげで、「勘」ではなく「データ」に基づいた合理的な優先順位付けが可能に。最も頻繁に発生するエラーを狙い撃ちで根本解決した結果、なんとアラートの総量を導入前の 35%まで激減(つまり65%の削減)させることに成功しました。これは、特別な費用をかけずに、チームの生産性を大幅に向上させた、インパクトの大きな実例として、同じ悩みを抱える皆さんの参考になれば嬉しいです!

背景:「アラート地獄」をなんとかしたい

課題:疲弊の原因、「アラート疲れ」

開発チームの日常は、Slackにひっきりなしに流れてくるエラーアラートとの戦いでした。

なぜアラートが多いと良くないのか?

  • 集中力の低下と「見落とし」: アラートが鳴りすぎると、脳が「どうせ大したことない」と判断し、緊急性の高いエラーを見過ごしやすくなります。これが、俗に言う「アラート疲れ」です。エンジニアの集中力はどんどん削られていきました。
  • 「とりあえず対応」の悪循環: 「今、目の前にあるアラート」から対応を始めるため、「最も直すべきエラー」ではなく「たまたま目についたエラー」に時間を費やしてしまう非効率な状態でした。まるで、水漏れしているバケツの穴を、小さいものから適当に塞いでいるような状態でした。

目標:基本に忠実に、発生件数を見える化する

この状況を打破するためにシンプルな目標を立てました。

  1. アラートを「手作業」ではなく「自動」で、全部正確に記録する。
  2. 「どれが一番多いのか?」という疑問に、数値で答えられるようにする。
  3. 発生頻度ランキングを見て、一番多いエラーに絞って根本的に直す。

目的:低コストで「解決すべきエラー」をあぶり出す!

目的は、既存のツール(Slack、GAS、スプレッドシート)を連携させ、お金をかけずにエラーアラートを自動で集計・可視化する仕組みを作ることです。 そして、そのデータを使って「直せば一番効果が出るエラー」を特定し、限られたチームのリソースを効率的に投下して根本対応することでした。


方法:身近なツールを使い「見える化」する

設定した「アラート自動集計」は、特別なサーバーは使わず、以下の3つのツールを連携させたシンプルな構造です。

構成とツールの選定理由

役割 使用技術 なぜこれを選んだの?
通知元 Slack バックエンドのアラートに気付けるようにSlack通知は既存処理で行っていたため
連携ロジック Google App Script (GAS) Googleスプレッドシートとの相性が抜群で、このためのインフラ環境の構築が一切必要ないのが魅力でした
データ永続化・集計 Googleスプレッドシート エンジニアだけでなく、誰でもすぐに開いて確認でき、グラフ化や集計が手軽にできるため

GASによる「正確な記録」へのこだわり

特にこだわったのは、集計が不正確にならないようにするための工夫です。

  1. Slackからの通知フック: Slackの「Event Subscriptions」で、新しいメッセージが来たら、GASのURLにデータを送る設定をします。
  2. 【超重要】二重発火防止のワザ:
    • Slackからの通知は、稀に同じ内容が2回届いてしまうことがありました。これを放置すると、集計データが2倍になってしまい、データがデタラメになります。
    • そこで、GASのCacheService を使いました。メッセージのチャンネルIDタイムスタンプを組み合わせた「鍵」を3分間だけ覚えておきます。もし3分以内に同じ鍵のメッセージが来たら処理済みとしてスキップすることで、データの水増しを防ぎました。
  3. Knowledgeシートで「エラーの本質」を集計:
    • エラーメッセージは日時やリクエストIDなど、毎回変わる情報を含んでいます。そのまま比較しても「いつもと異なるエラー」と判断されてしまいます。
    • なぜ正規表現を使うのか? エラーの「本質的な原因」だけを抜き出すパターン(正規表現)をKnowledgeシートに登録しておき、メッセージの一部が変わっても、「既知のエラーの仲間」 と判断し、そのエラーの発生回数だけを正確にカウントできるようにしたのです。

結果:アラートを65%削減

自動集計のスプレッドシートにデータが貯まり、「発生回数ランキング」が明確になりました。 ランキング上位のエラーが、アラート総数の大部分を占めていることが判明したので(パレートの法則)、チームで協力し、最も多いエラーから順番にコードを修正したり、設定を見直したりする根本対応を進めました。 その結果、システム導入前と比べて、エラーアラートの総量を驚異の35%まで抑え込む(つまり65%の削減!)ことに成功しました。 これで「アラート対応」という緊急性の判断が難しいタスクからかなり解放され、「新しい機能開発」や「システムの安定化」というより大事な仕事に時間を使えるようになりました。


考察:データがもたらす「安心感」と「合理性」

「データで判断する」安心感

このプロジェクトの最大の成功は、「感情や勘」ではなく「データ」で動けるようになったことです。

  • 以前: 「これはあまり見たことがないのでまずそう」
  • 改善後: 「データによると、このエラーが全アラートの40%を占めています。これを直せば、一気に負荷が減りますよ!」

このように、根拠を持ってチームの合意形成ができるようになり、エンジニアリングリソースの配分が非常にスムーズになりました。

低コストで始める「改善の連鎖」

GASやスプレッドシートといった無料で使える身近なツールを組み合わせるだけで、大きな効果(65%削減)を出せました。 これは、「お金がないから改善できない」という理由で諦めなくてもいい成功事例になったと考えています。


まとめ

今回の対応は、日常的な「アラート疲れ」という課題に対し、Slack、GAS、スプレッドシートというシンプルな組み合わせで向き合い、アラート総量を65%削減という成果を出すことができました。 チーム全体で協力して取り組めるように優先度判断できるようにしたことがアラート数の削減につながり、対応は無駄ではなかったと感じています。

皆さんにお伝えしたいことは、次の3つです。

  1. 「なんとなく」ではなく、特定のエラーがどれだけ起きているのかを可視化すれば、解決の道筋と優先度がつけられます。
  2. 特別なツールは不要で、普段利用しているツールを使って改善につながる仕組みが作れます。
  3. 二重発火防止のような地味な工夫こそが、集計データの「信頼性」を担保する鍵となります。

以上です。今後もハードルが高いと感じても、できることから取り組んで行きたいと思います。 最後に使用したGASを添付しておきます。

// ログ出力のときにシートオブジェクト作り直すと遅いのでグローバルスコープで定義
const bookUrl = "https://docs.google.com/spreadsheets/d/スプレッドシートID";
const debugLogSheet = SpreadsheetApp.openByUrl(bookUrl).getSheetByName('debug')

// POSTリクエストで呼び出される処理
function doPost(e) {
  const params = JSON.parse(e.postData.getDataAsString());

  // challenge auth
  if (params?.type === 'url_verification') {
    debugLog("challenge auth");
    return ContentService.createTextOutput(params.challenge);
  }
  else {
    // Slack通知でフックされる処理
    // 1度のメッセージのポストに複数回発火しているのでキャッシュしているキーと一致する場合は処理しない
    var channel = params.event.channel;
    var ts = params.event.ts;
    var cache = CacheService.getScriptCache();
    // 発言されたチャネルID、タイムスタンプをキーにしてキャッシュ
    var cacheKey = channel + ':' + ts;
    var cached = cache.get(cacheKey);
    if (cached != null) {
      return;
    }
    cache.put(cacheKey, true, 180); // 3分キャッシュする

    // メイン処理
    main(e)
  }
}

function main(params) {
  const postedSlackMessage = JSON.parse(params.postData.contents);
  const output = ContentService.createTextOutput(JSON.stringify({result:"Ok"}));
  output.setMimeType(ContentService.MimeType.JSON);

  if (postedSlackMessage?.event?.text) {
    // const splitedMessages = postedSlackMessage?.event?.text.split("\n")
    // attachmentを取得。attachmentについて https://api.slack.com/reference/messaging/attachments
    const attachments = postedSlackMessage?.event?.attachments
    // 通知されたパラメタを分割してカラム出力するためのヘッダ定義
    const HEADERS = [
      {"key": /Error message/, "colPos": 3 },
      {"key": /Occurred on/, "colPos": 4 },
      {"key": /Host/, "colPos": 5 },
      {"key": /HTTP Method/, "colPos": 6 },
      {"key": /Request Url/, "colPos": 7 },
      // {"key": /Rails.env/, "colPos": 8 },
      {"key": /Request path/, "colPos": 9 },
      {"key": /Request parameters/, "colPos": 10 },
      {"key": /User Agent/, "colPos": 11 },
    ]

    // 環境の設定などでログ出力自体を止められないが集計に含めたくないログを定義
    // 基本的にはいらない、通知されてるのを見ても何もしないログはアプリ側で止めるべき
    const ignoreMessages = [
      { 'environments': 'test', 'string': /テスト用の通知です/ },
    ]

    // チャンネルIDから環境を判断する
    const env_infos = getEnvironment(postedSlackMessage?.event?.channel);
    const env = env_infos.env;
    const sheet = SpreadsheetApp.openByUrl(bookUrl).getSheetByName(env_infos.sheetName);
    const row = sheet.getLastRow() + 1;
    const timestamp = convertUnixtime(postedSlackMessage.event.ts);

    // アタッチメントが存在するSlack通知フォーマットに該当する場合
    if (attachments) {
      // 無視するメッセージかチェック
      var isIgnore = ignoreMessages.find(m => {
        var environments = m.environments.split(',').map(env => env.trim()); // 空白のトリム
        var stringTestResult = m.string.test(attachments[0]?.fallback);
        var envIncludeResult = environments.includes(env);
        // debugLog(`{env: ${env}, fallback: ${attachments[0]?.fallback}, mString: ${m.string}, mStringTest: ${stringTestResult}, envInclude: ${envIncludeResult}, both: ${stringTestResult && envIncludeResult}}`);
        return stringTestResult && envIncludeResult;
      })

      if (isIgnore) {
        return output;
      }

      sheet.getRange(row, 1).setValue(timestamp);
      sheet.getRange(row, 2).setValue(attachments[0].title);
      // sheet.getRange(row, 8).setValue(env);

      var header = null;
      attachments[0]?.fields.forEach((message, index) => {
        header = HEADERS.find(h => h.key.test(message.title));
        if (header) {
          sheet.getRange(row, header.colPos).setValue(message.value);
        }

        // Error messageアタッチメントがある場合
        if (/Error message/.test(message.title)) {
          // knowledgeシートに出力判定
          checkKnowledgeAndRegist(attachments[0].title + " " + message.value, timestamp)
        }
      })
    }
    else {
      // ExceptionNotifierのフォーマットではない場合の処理
      sheet.getRange(row, 1).setValue(timestamp);
      // 一度参照されるとブランクになるようなので変数保管
      const message = postedSlackMessage?.event?.text
      // とりあえずメッセージをすべて出す
      sheet.getRange(row, 2).setValue(message);

      // knowledgeシートに出力判定
      checkKnowledgeAndRegist(message, timestamp)
    }
  }

  return output;
}

// UNIXタイムスタンプをDateTime型にする
function convertUnixtime(unixTimestamp) {
  const date = new Date(unixTimestamp * 1000);
  // JSTで表示する
  return date.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" });
}

// debugシートへログ出力
function debugLog(message) {
  var row = debugLogSheet.getLastRow() + 1;
  debugLogSheet.getRange(row, 1).setValue((new Date).toLocaleString('ja-JP'));
  debugLogSheet.getRange(row, 2).setValue(message);
}

// チャンネルIDから環境を判断する
function getEnvironment(channelId) {
  const channel_prod = 'CXXXXXXXX';

  var env_infos = {};
  switch (channelId) {
    case channel_prod:
      env_infos.env = 'production'
      env_infos.sheetName = 'prod_log'
      break;
    default:
      env_infos['env'] = 'unknown'
      env_infos['sheetName'] = 'unknown_log'
  }

  return env_infos;
}

// knowledgeシートの正規表現を確認。なければknowledgeシートに出力。
function checkKnowledgeAndRegist(message, timestamp) {
  const sheet = SpreadsheetApp.openByUrl(bookUrl).getSheetByName('knowledge');
  //シートの最終列を取得
  const lastRow = sheet.getMaxRows();
  // ヘッダ行を除いて取得
  const range = sheet.getRange(2, 1, lastRow);
  const values = range.getValues();

  let pattern;
  for(var row = 0; row < range.getNumRows(); row++) {
    if (!values[row] || values[row][0] == '') {
      continue;
    }

    try {
      pattern = new RegExp(values[row][0]);
      if(pattern.test(message)) {
        // Logger.log("ヒット")
        sheet.getRange(row+2, 3).setValue(timestamp); // 最終発生日時

        // 発生回数インクリメント
        const currentCount = sheet.getRange(row+2, 4).getValue();
        const newCount = (isNaN(currentCount) || currentCount === '') ? 1 : currentCount + 1;
        sheet.getRange(row+2, 4).setValue(newCount);

        return true;
      }
    } catch(e) {
      debugLog('エラー出力開始');
      debugLog(message);
      debugLog(e.message);
      debugLog('エラー出力終了');
    }
  }

  // 最終行に追加
  sheet.getRange(lastRow+1, 1).setValue(message); // エラーメッセージ
  sheet.getRange(lastRow+1, 2).setValue(timestamp); // 初回発生日時
  sheet.getRange(lastRow+1, 3).setValue(timestamp); // 最終発生日時
  sheet.getRange(lastRow+1, 4).setValue(1); // 発生回数(初回は1)

  return
}