PagerDuty Events API v2を利用してエラー前後の処理内容も含めたインシデントを作成する

この記事は PagerDuty Advent Calendar 2023 | Advent Calendar 2023 - Qiita 4日目の記事です。

はじめに

アノテーション の中野です。

私たちのチームでは、Lambda上で発生したアプリケーションエラーを検知することを目的として、CloudWatch Logsのメトリクスフィルター機能を使って特定の文字列を検出した際に、CloudWatch Alarm -> SNS経由でSlackへアラートとして発報するようにしています。

一方で、Slackへ通知した内容の詳細はSlack上では確認できないため、毎回AWSコンソールに入ってCloudWatch Logs Insightsでログの中身を確認する運用にしています。

ここを、もう少し効率化できないかと考えている最中、PagerDutyで解決できないかと思いつきました。

アラートで検知したログの詳細をPagerDutyのインシデントに含めて連携させることで、アラートの原因調査が効率よくできるのでは、というのが本エントリの趣旨です。

やりたいこと

  • CloudWatch Logsのメトリクスフィルターで検知したエラーの前後数分間のメッセージを、CloudWatch Logsのログストリームから取得して一つのメッセージに整形
  • 整形されたメッセージを、PagerDuty Events API v2 を利用してインシデント作成

構成図

本エントリでは、Lambdaで発生したエラーのインシデント作成をおこないます。

構成は、以下のようなエラー発生をトリガーにしたイベントドリブンのアーキテクチャにします。

利用するLambdaのランタイムとしては、Node.js v20とします。

利用する技術

PagerDuty Events API v2

Lambdaで発生したエラーをPagerDutyに連携するために、PagerDutyのEvents APIを利用します。

公式のAPI仕様を参照しながら実装します。

API Reference | PagerDuty Developer Documentation

以下のURLに対してPOSTリクエストを送ることで、インシデントとして起票することが可能です。

POST https://events.pagerduty.com/v2/enqueue

必須のリクエストボディのパラメータは以下です。

  • summary
    • インシデントのサマリ情報(タイトルのようなもの)として使われます
  • severity
    • インシデントの重大度。critical,warning, error,infoの4つの重要度から選択
  • source
    • インシデントによって影響を受ける固有の値。可能であれば、ホスト名やFQDNを利用とのことです
  • routing_key
    • PagerDutyコンソール上のService Directory内Events API v2 のインテグレーションで生成される「Integration Key」のこと
  • event_action
    • イベントの種類。trigger,acknowledge,resolveから選択

上記に加えてPagerDutyへエラー前後のメッセージも含めるために、以下のパラメータも利用します。

  • custom_details
    • イベントと影響を受けるシステムに関する追加の詳細。オブジェクトとして定義

custom_detailsのオブジェクト内にCloudWatch Logsのエラー前後のメッセージをテキストとして含めて、PagerDutyのEvents APIを叩きます。

この方法によって、1インシデントにエラー前後のメッセージを紐付けてインシデント作成が可能になっています。

なお、注意点として商用で利用する場合、Events API v2のAPI仕様の制限にかからないか、以下のドキュメントをみながらAPIのエラーハンドリングやペイロード最大値などを考慮した実装にしてください。

場合によってはアラートを検知したのにPagerDutyに連携されず本番エラーを見落としてしまうなど発生してしまう可能性もあるため、これらの考慮やPagerDutyに連携できない場合のバックアップ案(APIリトライ、PagerDuty以外の通知先等)も考えておく必要があるかと思います。

Events API v2 Overview

前提となる設定

  • PagerDutyのService作成
  • ServiceのIntegrationで、Events API v2を追加してIntegration Keyを払い出す

まずは、PagerDutyでServiceをつくっておきます。ここで作成したServiceに対してインシデントが連携されます。

作成したServiceでEvents API v2を連携できるように追加します。

以下のような画面になればPagerDutyのEvents API v2を利用するための準備完了です。

PagerDutyインシデント連携用Lambdaソースコード

本コードは上述の構成図に記載した「AWS Lambda(PagerDutyインシデント連携用)」の実装部分です。

import { CloudWatchLogsClient, DescribeMetricFiltersCommand, FilterLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'
import axios from "axios";

// 抽出するログデータの最大件数
const OUTPUT_LIMIT=10

// 何分前までを抽出対象期間とするか
const TIME_FROM_MIN=3

// ERRORの何秒前後のログを出力するか
const TIME_ERROR_AROUND=30

const client = new CloudWatchLogsClient();

export const handler = async (event) => {
  console.info("Event: " + JSON.stringify(event));
  const message = JSON.parse(event.Records[0].Sns.Message);
  console.info("Message: " + JSON.stringify(message));
  
  const describeMetricFiltersCommand = new DescribeMetricFiltersCommand({
    metricName: message.Trigger.MetricName,
    metricNamespace: message.Trigger.Namespace,
  });
  const metricfilters = await client.send(describeMetricFiltersCommand);
  console.info("responseMetricFilters: " + JSON.stringify(metricfilters))
  
  // ログストリームの抽出対象時刻をUNIXタイムに変換。終了時刻はアラーム発生時刻の1分後
  const searchEndAt = new Date(message.StateChangeTime).getTime() + 60000;
  // 開始時刻は終了時刻のTIME_FROM_MIN分前
  const searchStartAt = new Date(searchEndAt).getTime() - TIME_FROM_MIN * 60 * 1000;

  const filterLogEventsCommand = new FilterLogEventsCommand({
    logGroupName: metricfilters.metricFilters[0].logGroupName,
    filterPattern: metricfilters.metricFilters[0].filterPattern,
    startTime: searchStartAt,
    endTime: searchEndAt,
    limit: OUTPUT_LIMIT,
  });
  const responseFilterLogEvents = await client.send(filterLogEventsCommand);
  console.info("responseFilterLogEvents: " + JSON.stringify(responseFilterLogEvents));
  
  // PagerDut連携用メッセージの整形
  let messageForIncident = "";
  for (const event of responseFilterLogEvents.events) {
    const errorTime = new Date(event.timestamp).getTime();
    // エラーのログから前後X秒のログを取得
    const errorStartAt = errorTime - TIME_ERROR_AROUND * 1000;
    const errorEndedAt = errorTime + TIME_ERROR_AROUND * 1000;

    // エラーのログの前後のログを取得
    const errorLogsRetriveCommand = new FilterLogEventsCommand({
      logGroupName: metricfilters.metricFilters[0].logGroupName,
      startTime: errorStartAt,
      endTime: errorEndedAt,
      limit: OUTPUT_LIMIT,
    });
    const responseErrorLogsRetrive = await client.send(errorLogsRetriveCommand);

    for (const errorEvent of responseErrorLogsRetrive.events) {
      messageForIncident += errorEvent.message + "\n";
    }
  }
  console.log(JSON.stringify(messageForIncident));

  try {
    // PagerDutyインシデント通知したときにわかりやすいサマリを定義
    const summary = `[FilterPattern: ${metricfilters.metricFilters[0].filterPattern}] ${event.Records[0].Sns.Subject}`;
    const source = message.AlarmName;
    
    const headers = {
      "Content-Type": "application/json",
    };

    const data = {
      "payload": {
        "summary": summary,
        "severity": "critical",
        "source": source,
        "custom_details": {
          "message": messageForIncident
        },
        "timestamp": event.Records[0].Sns.Timestamp
      },
      "routing_key": process.env.INTEGRATION_KEY,
      "event_action": "trigger",
    };

    // PagerDutyへ通知
    const dataObj = JSON.stringify(data);
    const response = await axios.post(process.env.INTEGRATION_URL, dataObj, headers);
    console.log(response.text);
  } catch (err) {
    console.log("Error Exception.");
    console.log(err);
  }
};

上記のソースコードを解説します。

CloudWatch Alarmより検知したSNSの内容をもとに、CloudWatch Logsからエラーログを取得します。

処理の冒頭部で、ログデータの最大件数、抽出対象期間、エラーログの前後何秒のログを取得するかなどの定数を定義します。

このあたりの設定はプロダクションのコードによって抽出したい期間が異なってくるかと思いますので、実際に検証しながら設定してください。

中盤部では、DescribeMetricFiltersCommandでSNS経由で連携された情報をもとに、メトリクスフィルターのフィルタリング情報や対象のロググループ情報を取得します。

その後、CloudWatch Logsのログの中からFilterLogEventsCommandでメトリクスフィルターでエラーとして検知したログイベントを取得します。

ログイベントは配列で得られるため、FilterLogEventsCommandでエラーとして検知したログのログイベント前後のメッセージを取得して、メッセージを整形します。

後半部では、PagerDutyのEvents API v2へCloudWatch LogsのメッセージをPOSTで連携する処理です。

なお、環境変数として

  • INTEGRATION_KEY:PagerDuty Events API v2のインテグレーションで生成したIntegration Keyを指定
  • INTEGRATION_URL:Events API v2のIntegration URL(https://events.pagerduty.com/v2/enqueue)を指定

をLambdaに設定してください。

検証結果

以下2つの状況が発生した場合を例として、PagerDutyでインシデントを作成します。

  • Lambdaでタイムアウトエラー
  • LambdaでERRORが発生

LambdaでERRORが発生

上述の構成図で示した「Lambda(Production Application Code)」のソースコードを作成します。

簡易的にですが、エラーが発生することだけを確認するために、以下のようにしています。

export const handler = async (event) => {
  console.log('test')
  console.error('errorが発生しました!!')
};

次に、Lambdaのメトリクスフィルターを設定します。
ERRORという文字列をフィルターパターンに指定しました。

メトリクスフィルターに基づくCloudWatch Alarmには、PagerDuty連携用のLambdaを指定してください。

Lambdaのテストタブより、手動にてLambdaを実行させてエラーを起こします。

数秒後にPagerDutyへ以下のようなインシデントが報告されることを確認しました。

インシデントのAlertsタブを開いてCUSTOM_DETAILSの詳細を開くと、エラーログを含む前後の情報が記録されています。

PagerDutyのコンソールからインシデントの詳細を確認できます。Events APIのリクエストで渡した情報が確認できます。

インシデントのタイムラインを確認すると、API実行からPagerDutyのEscalation Policyに基づく担当者アサインまでの時間経過が記録されています。

Lambdaでタイムアウトエラー

ソースコードは以下のようにしています。

export const handler = async (event) => {
  const sleep = (time) => {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve()
        }, time)
    })
  }
  await sleep(10000)
};

setTimeoutをラップしたsleep関数を作り、sleep関数を使って10秒ほど待機させます。

その間にLambdaのデフォルトのタイムアウト値である3秒が経過するとアラートが発報します。

メトリクスフィルターは以下にします。

Lambdaのタイムアウトが発生した場合は、Task timed out after 3.00 secondsという文字列がCloudWatch Logsに出力されることがわかっているので、その前半にあるTask timed outを利用します。

テスト用のLambdaを実行すると、数秒後にPagerDutyへ以下のようなインシデントが報告されることを確認しました。

さいごに

PagerDutyにエラー前後のログ詳細まで含めてインシデントを作成できるようになりました。

PagerDutyには、Intelligent Alert Groupingという機械学習により類似のアラートをグルーピングして通知を減少させる機能もあります。

こういった機能もあわせて使うと、より効率的にアラート対応できるようになるのではと思ってます(機会があれば利用してみたいです)。

参考情報

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。
「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。
少しでもご興味あれば、アノテーション株式会社 WEB サイトをご覧ください。