CloudWatch のエラーログを Slack 通知


# はじめに

CloudWatch に出力されたエラーログを Slack 通知したいと思います。  
CloudWatch の通知と言えば、以下の様なアラームの状態変更を通知するパターンもあるかと思います。

![2023-12-06_8.20.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/20e33385-5a10-9bf8-66a3-938217d4338e.png)

この場合、パッと見、エラーの内容が分からず、コンソールなどで詳細を確認しに行く必要があるため少し面倒です。  

今回は、エラーの内容を Slack に通知できる様にします。

# 完成イメージ

![2023-12-05_20.47.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/9edea962-7ccd-405c-1c1e-c3ae1c37d24e.png)

エラーログやステータス、発生日時や該当 API の情報を通知しています。  
上部の `api-error` は、CloudWatch ログストリームへのリンクとなっています。

# 構成

![architecture.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/2eb0c631-917e-9660-fa26-2f9ad50a035c.png)

- CloudWatch
  - ログの収集
  - メトリクスフィルターで特定用語を含んだログの検知
  - アラームで SNS トピックに通知を送信
- SNS
  - Pub/Sub
  - CloudWatch から受信
  - Lambda へ送信
- Lambda
  - SNS トリガーで実行
  - 通知された情報を元に、CloudWatch のログ情報を取得する
    - CloudWatch の通知は、あくまでエラーが発生したことを通知するものであり、詳細な内容は取得する必要がある
  - ログデータから Slack 用メッセージを生成
  - Slack へ投稿
- Systems Manager
  - 機密情報管理
  - Slack のトークンなど

# 全体の流れ

※ CloudWatch にログが出力されている前提で進めます

1. Slack App の作成
2. Systems Manager パラメータの作成
3. SNS の作成
4. CloudWatch メトリクスフィルターの作成
5. CloudWatch アラームの作成
6. Lambda の作成（TypeScript）

# Slack App の作成

[Slack Apps](https://api.slack.com/apps) より App を作成します。今回は Slack Bot を使います。  
「Create New App」ボタンから App の作成に進みます。「From scratch」を選択し、App の名前とインストールするワークスペースを選択し、作成。

![2023-12-06_9.25.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/b6e0e70e-3aba-7eab-7180-06333b26d0b4.png)

![2023-12-06_9.28.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/8086457b-9fda-b3bb-a064-7f1dfa4adedf.png)

App を作成後、「OAuth & Permissions」にて「Scopes」を設定します。  
Bot Token Scopes に「chat:write」を追加します。

![2023-12-06_9.33.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/909ebb3d-6874-4f29-b0fc-67a3477b9dfb.png)

設定後、ワークスペースに App をインストールします。  
「Basic Information」にて「Install to Workspace」ボタンから行います。  
インストール後に、「Install your app」にチェックが付けば完了です。

![2023-12-06_9.45.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/66c8f7d2-37e2-f1d9-c586-63b7a36899e2.png)

※ Slack 側では、通知先のチャンネルに App を追加しておきます。

最後に、Lambda から Slack を操作するための情報（チャンネル ID、Signing Secret、Bot User OAuth Token の 3 つ）を取得します。  
チャンネル ID は、Slack 側でチャンネルを右クリック > コピー > リンクをコピーから取得できます。リンクの末尾が ID です。→ `https://xxxxx.slack.com/archives/<ID>`
Signing Secret は、App 画面の「Basic Information」にて「App Credentials」から取得できます。  
Bot User OAuth Token は、App 画面の「OAuth & Permissions」にて「OAuth Tokens for Your Workspace」から取得できます。

Slack を操作するための情報は、次の「Systems Manager パラメータの作成」で使いますので、メモしておきます。

以上で、「Slack App の作成」は完了です。

# Systems Manager パラメータの作成

Slack を操作するための機密情報を管理します。ここで管理する情報を Lambda から参照し、Slack を操作します。

メモしておいた 3 つの情報それぞれのパラメータを作成します。

![2023-12-06_10.15.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/30051ba2-1b5c-0123-229e-b82b2d949d5f.png)

以上で、「Systems Manager パラメータの作成」は完了です。

# SNS の作成

トピックを作成します。  
オプション項目はデフォルトのままです。

![2023-12-06_10.21.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/b20cfea4-cee2-875a-6090-f17ce4428c9a.png)

サブスクリプションは Lambda 側から設定するため、ここでは作成しません。

以上で、「SNS の作成」は完了です。

# CloudWatch メトリクスフィルターの作成

特定の用語がログに出力された際に検知するフィルターを作成します。  
対象のロググループを選択し、メトリクスフィルターを作成します。  
フィルターパターン以外の項目はデフォルトのままです。

![2023-12-06_10.28.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/b13288c9-106c-f5a9-87c2-18f6531c3aac.png)

今回は、アプリケーションのエラーログに `ERROR` の文字を含む想定のため、フィルターパターンを `ERROR` にし、検知できる様にしています。

メトリクスの割り当てもよしなに設定します。

![2023-12-06_10.35.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/cf373d99-dca4-1259-0b6b-e86f7302ff76.png)

「Review and create」にて「メトリクスフィルターを作成」から作成します。

以上で、「CloudWatch メトリクスフィルターの作成」は完了です。

# CloudWatch アラームの作成

メトリクスフィルターで対象のログを検知した後に、アラームで SNS に通知します。  
「メトリクスの選択」にて「CloudWatch メトリクスフィルターの作成」で作成したフィルターを選択します。


トリガー条件をよしなに設定します。

![2023-12-06_10.45.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/c5bfc2b7-83fc-b350-f319-5da843dc5b5c.png)

通知先は「SNS の作成」で作成したトピックを選択します。  
その他の項目はデフォルトのままです。

![2023-12-06_10.47.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/5571d25f-8bea-6eca-2e57-8020a5cb71dd.png)

アラーム名を設定し、アラームを作成します。  
以上で、「CloudWatch アラームの作成」は完了です。

# Lambda の作成

TypeScript（Docker）ベースの関数を作成します。

## ソースコード（全体）

```tsx
import { Handler, SNSEvent } from "aws-lambda";
import { getParameter } from "@aws-lambda-powertools/parameters/ssm";
import {
  CloudWatchLogsClient,
  DescribeMetricFiltersCommand,
  FilterLogEventsCommand,
  FilteredLogEvent,
  DescribeLogStreamsCommand,
} from "@aws-sdk/client-cloudwatch-logs";
import { App } from "@slack/bolt";

const AWS_REGION = process.env.AWS_REGION;

type SNSEventMessage = {
  AlarmName: string;
  StateChangeTime: string;
  Trigger: {
    MetricName: string;
    Namespace: string;
  };
};

type Log = {
  alarmName: string;
  filterPattern: string;
  logGroupName: string;
  logStreamName: string;
  events: FilteredLogEvent[];
};

type Slack = {
  token: string;
  channel: string;
  signingSecret: string;
};

export const handler: Handler = async (event: SNSEvent) => {
  console.info("EVENT: \n" + JSON.stringify(event, null, 2));

  if (!event.Records.length) {
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: "No event records",
      }),
    };
  }

  const snsMessage: SNSEventMessage = JSON.parse(event.Records[0].Sns.Message);

  const log = await describeLogs(snsMessage);
  if (!log || !log.events.length) {
    return {
      statusCode: 200,
      body: JSON.stringify({
        message: "No logs",
      }),
    };
  }

  const result = await sendToSlack(log);
  console.info("RESULT: \n" + JSON.stringify(result, null, 2));

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: "Success",
    }),
  };
};

const describeLogs = async (message: SNSEventMessage): Promise<Log | null> => {
  const c = new CloudWatchLogsClient({ region: AWS_REGION });

  const metricFiltersOutput = await c.send(
    new DescribeMetricFiltersCommand({
      metricName: message.Trigger.MetricName,
      metricNamespace: message.Trigger.Namespace,
    })
  );
  const logGroupName = metricFiltersOutput.metricFilters?.[0].logGroupName;
  const filterPattern = metricFiltersOutput.metricFilters?.[0].filterPattern;
  if (!logGroupName || !filterPattern) {
    return null;
  }

  const logStreamOutput = await c.send(
    new DescribeLogStreamsCommand({
      logGroupName,
      descending: true,
      orderBy: "LastEventTime",
      limit: 1,
    })
  );
  const logStreamName = logStreamOutput.logStreams?.[0].logStreamName;
  if (!logStreamName) {
    return null;
  }

  const to = new Date(message.StateChangeTime);
  to.setMinutes(to.getMinutes() + 1);
  const from = new Date(to);
  from.setMinutes(from.getMinutes() - 5);

  const log = await c.send(
    new FilterLogEventsCommand({
      logGroupName,
      filterPattern,
      logStreamNames: [logStreamName],
      startTime: from.valueOf(),
      endTime: to.valueOf(),
      limit: 10,
    })
  );

  return {
    alarmName: message.AlarmName,
    filterPattern,
    logGroupName,
    logStreamName,
    events: log.events || [],
  };
};

const getSlackParameter = async (): Promise<Slack> => {
  return {
    token: (await getParameter(process.env.SLACK_TOKEN || "", {
      decrypt: true,
    })) as string,
    channel: (await getParameter(process.env.SLACK_CHANNEL || "", {
      decrypt: true,
    })) as string,
    signingSecret: (await getParameter(process.env.SLACK_SIGNING_SECRET || "", {
      decrypt: true,
    })) as string,
  };
};

const sendToSlack = async (log: Log) => {
  try {
    const parameter = await getSlackParameter();
    if (!parameter.channel || !parameter.token || !parameter.signingSecret) {
      return;
    }

    const app = new App({
      token: parameter.token,
      signingSecret: parameter.signingSecret,
    });

    return await Promise.all(
      log.events.map((event) => {
        //////////////////////////////////////////
        // get value from log message. This is a sample.
        const code = 500;
        const timestamp = new Date().toLocaleString();
        const api = "GET /users";
        const errorLog = event.message;
        //////////////////////////////////////////

        return app.client.chat.postMessage({
          channel: parameter.channel,
          attachments: [
            {
              mrkdwn_in: ["text"],
              color: code >= 500 ? "danger" : "warning",
              title: log.alarmName,
              title_link: <TITLE_LINK>,
              text: errorLog,
              fallback: errorLog,
              fields: [
                {
                  title: "Timestamp",
                  value: timestamp,
                  short: true,
                },
                {
                  title: "ErrorCode",
                  value: code.toString(),
                  short: true,
                },
                {
                  title: "API",
                  value: api,
                  short: true,
                },
                {
                  title: "Assignee",
                  value: "<!channel>",
                  short: true,
                },
              ],
            },
          ],
        });
      })
    );
  } catch (error) {
    console.error(error);
  }
};
```

以降、ソースコードの解説になります。

### ログ取得

CloudWatch から通知された情報を元に、ログの詳細を取得します。  
まずは、メトリクスフィルターの情報を取得し、エラーを検知したロググループとヒットしたフィルターパターンを特定します。  
フィルターパターンは「CloudWatch メトリクスフィルターの作成」で作成した値になるはずです。

```tsx
const metricFiltersOutput = await c.send(
  new DescribeMetricFiltersCommand({
    metricName: message.Trigger.MetricName,
    metricNamespace: message.Trigger.Namespace,
  })
);
const logGroupName = metricFiltersOutput.metricFilters?.[0].logGroupName;
const filterPattern = metricFiltersOutput.metricFilters?.[0].filterPattern;
```

次に、上記で取得したロググループを元に対象のログストリームを取得します。  
エラーが発生した直後に処理されるものと仮定し、最新のログストリームを取得しています。

```tsx
const logStreamOutput = await c.send(
  new DescribeLogStreamsCommand({
    logGroupName,
    descending: true,
    orderBy: "LastEventTime",
    limit: 1,
  })
);
const logStreamName = logStreamOutput.logStreams?.[0].logStreamName;
```

最後に、上記で取得した情報を元にログを取得します。  
`startTime` と `endTime` は unix 時刻を設定する必要があります。ログの抽出対象時刻は、アラーム発生時刻の 1 分後を終了時刻とし、終了時刻の 5 分前までを対象としています。

```tsx
const to = new Date(message.StateChangeTime);
to.setMinutes(to.getMinutes() + 1);
const from = new Date(to);
from.setMinutes(from.getMinutes() - 5);

const log = await c.send(
  new FilterLogEventsCommand({
    logGroupName,
    filterPattern,
    logStreamNames: [logStreamName],
    startTime: from.valueOf(),
    endTime: to.valueOf(),
    limit: 10,
  })
);
```

レスポンスの `events` フィールドにログの情報があります。フィルターパターンにヒットしたログが返却されます。

## Slack 通知

ログデータから Slack 用メッセージを生成して、Slack に投稿します。  
まずは、Slack を操作するための情報を取得します。「Systems Manager パラメータの作成」で作成したパラメータを取得するコードです。  
`process.env.<XXXXX>` の値は Systems Manager のパラメータ名に該当します。

```tsx
const getSlackParameter = async (): Promise<Slack> => {
  return {
    token: (await getParameter(process.env.SLACK_TOKEN || "", {
      decrypt: true,
    })) as string,
    channel: (await getParameter(process.env.SLACK_CHANNEL || "", {
      decrypt: true,
    })) as string,
    signingSecret: (await getParameter(process.env.SLACK_SIGNING_SECRET || "", {
      decrypt: true,
    })) as string,
  };
};
```

値の取得には `@aws-lambda-powertools/parameters/ssm` の `getParameter` を使います。  
これにより、Lambda の環境変数に直接値を設定するのではなく、Systems Manager を参照する形にできます。  

次に、ログデータから Slack 用メッセージを生成し、送信します。  
sample の部分をアプリケーションログの出力フォーマットに合わせて置き換えます。  
今回は、ログの数分ループをし、取得した｀全てのログを Slack に投稿していますが、内容によってまとめてしまうこともありかなと思います。

```tsx
const app = new App({
  token: parameter.token,
  signingSecret: parameter.signingSecret,
});

return await Promise.all(
  log.events.map((event) => {
    //////////////////////////////////////////
    // This is a sample. Get value from log message
    const code = 500;
    const timestamp = new Date().toLocaleString();
    const api = "GET /users";
    const errorLog = event.message;
    //////////////////////////////////////////

    return app.client.chat.postMessage({
      channel: parameter.channel,
      attachments: [
        {
          mrkdwn_in: ["text"],
          color: code >= 500 ? "danger" : "warning",
          title: log.alarmName,
          title_link: <TITLE_LINK>,
          text: errorLog,
          fallback: errorLog,
          fields: [
            {
              title: "Timestamp",
              value: timestamp,
              short: true,
            },
            {
              title: "ErrorCode",
              value: code.toString(),
              short: true,
            },
            {
              title: "API",
              value: api,
              short: true,
            },
            {
              title: "Assignee",
              value: "<!channel>",
              short: true,
            },
          ],
        },
      ],
    });
  })
);
```

`<TITLE_LINK>` の部分を CloudWatch ログストリームのリンクにする場合、↓ こちらの記事が参考になると思います。  
[Amazon CloudWatch Logs における任意のログストリームページの URL を動的に生成する](https://qiita.com/yoohya/items/3ef1124ae9dd945e3b9f)

### Dockerfile

公式が用意しているイメージを使います。

```docker
FROM public.ecr.aws/lambda/nodejs:18 as builder

WORKDIR /usr/app
COPY package.json index.ts  ./
RUN npm install
RUN npm run build

FROM public.ecr.aws/lambda/nodejs:18

WORKDIR ${LAMBDA_TASK_ROOT}
COPY --from=builder /usr/app/dist/* ./
CMD ["index.handler"]
```

## Lambda の設定

Lambda を作成します。

![2023-12-06_13.58.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/291ec46d-e926-fc8e-fbdd-bf2c50e78565.png)

実行ロールに CloudWatch と Systems Manager を参照するためのポリシーを追加します。

**CloudWatch**

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "1",
      "Effect": "Allow",
      "Action": [
        "logs:DescribeLogStreams",
        "logs:DescribeMetricFilters",
        "logs:FilterLogEvents"
      ],
      "Resource": [
        "xxxxx"
      ]
    }
  ]
}
```

**Systems Manager**

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "2",
      "Effect": "Allow",
      "Action": [
        "ssm:GetParameter",
        "ssm:GetParametersByPath"
      ],
      "Resource": [
        "xxxxx"
      ]
    }
  ]
}
```

環境変数を設定します。  
値には、「Systems Manager パラメータの作成」で作成したパラメータ名を設定します。これにより、実際の値を隠蔽することができます。

![2023-12-06_14.14.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/fdd10439-2aae-2038-553f-7e4c1fb95eb1.png)

トリガーを設定します。  
「SNS の作成」で作成したトピックを選択します。

![2023-12-06_14.26.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3125540/ad9897de-ac73-c6a7-9dd9-c8cc3c5540e7.png)

以上で、全ステップ完了です。  
エラーが発生した際、 Slack に通知されることを確認します。

# Ref

- [Slack Apps](https://api.slack.com/apps)
- [Using AWS Lambda with Amazon SNS](https://docs.aws.amazon.com/lambda/latest/dg/with-sns.html)