CloudWatch Logsに書き込まれた内容をSlackとGoogle Homeで通知するAWS Lambda using dotnet

# 概要
* CloudWatch Logsのメトリクスフィルターに特定の文字列を登録してLambdaにストリーミングし、Slackに通知するようにしました
* C#（.NET）向けのライブラリの[aws/aws-lambda-dotnet](https://github.com/aws/aws-lambda-dotnet)ではCloudWatch Logsのイベントに型をつけるためのクラスがなかったので、プルリクを出してみました
* ついでにエラー時にGoogle Homeでも知らせてくれるようにしました

# 課題
このようなアーキテクチャでLambdaを運用し始めたのですが、DLQの通知先として選択したメールでしか通知を行っていません。

![DLQ.png](https://qiita-image-store.s3.amazonaws.com/0/46547/6a7c0ddf-079e-7517-036e-af902e967cab.png)

詳細はfreee developers Advent Calendar 2017 21日目[『Rubyがマジョリティな会社でC#を使ってAWS Lambdaの本番運用を開始した話』](http://developers.freee.co.jp/entry/aws-lambda-using-dotnet)に書きました。

次のような課題がありました。

* DLQ経由で通知されるのはイベントのみ。イベントの中のrequest IDでCloudWatch Logsを検索してスタックトレースにたどりつかないといけない。
* 実際にDLQに積まれてメール通知される（2回リトライに失敗する）ことはほとんどない。Lambdaのメトリクスページでエラーが発生しているのを見つけるとCloudWatch Logsを検索してエラーをたどってしまう。

エラー回数もあまり多くないので、エラーの発生を直接通知してみることにしました。

# CloudWatch Logsの監視と通知
CloudWatch Logsにログを吐き出しているLambda内に直接通知ロジックを組み込んでしまうのも1つの手だと思います。

しかし、通知先や通知手段を変えたくなったり、他でも使いまわしたりできるはずなので通知用のLambdaを準備することにしました。

アイディアはつぎの記事からいただきました。

[AWS Lambda で CloudWatch Logs のログ本文をSlack通知(2)](https://cloudpack.media/20649)

そのため、自社で運用しているLambdaは最終的にこんなアーキテクチャになるイメージです。

![cloudwatchlogs.png](https://qiita-image-store.s3.amazonaws.com/0/46547/aedb786c-e2fc-33e9-3a71-cf1a112ae32e.png)
※DLQの通知も残します

最初のLambdaをC#で書いたので、せっかくなので通知用のLambdaもC#で実装することにしました。

# C#で実装
出来上がったものはこちらにおいてあります。フォルダ構成等はGitHubのソースコードを参照してください。

[toshi0607/ErrorNotificationLambda](https://github.com/toshi0607/ErrorNotificationLambda)

## Lambda本体
まずコードです。

```csharp:Function.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Text;
using System.IO;
using System.IO.Compression;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

using Amazon.Lambda.Core;

[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.Json.JsonSerializer))]

namespace ErrorNotificationLambda
{
    public class Function
    {

		/// <summary>
        /// Notify contents waritten out on CloudWatch Logs
		/// </summary>
		/// <param name="logEvent"></param>
		/// <param name="context"></param>
		/// <returns></returns>
		public async Task<bool> FunctionHandler(LogEvent logEvent, ILambdaContext context)
		{
			var slackWebhookUrl = Environment.GetEnvironmentVariable("SLACK_WEBHOOK_URL");
			var cloudWatchLogGroupUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_LOG_GROUP_URL");
			var cloudWatchMetricsUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_METRICS_URL");

			var payload = new
			{
				channel = "dev",
				username = "CloudWatch Notification",
                text = $"{Decode(logEvent.Awslogs.Data)}"+ Environment.NewLine +
                       $"Logs: <{cloudWatchLogGroupUrl}|Click here>" + Environment.NewLine +
                       $"Metrics: <{cloudWatchMetricsUrl}|Click here>",
			};

			var jsonString = JsonConvert.SerializeObject(payload);

			var content = new FormUrlEncodedContent(new Dictionary<string, string>
			{
				{ "payload", jsonString}
			});

			try
			{
				using (var client = new HttpClient())
				{
					await client.PostAsync(slackWebhookUrl, content);
				}
			}
			catch (Exception e)
			{
				context.Logger.LogLine("error!!!!" + Environment.NewLine + $"{e.Message}" + Environment.NewLine + $"{e.StackTrace}");
				throw;
			}

			return true;
		}

        private string Decode(string encodedString)
        {
            var decodedString = "";

            byte[] data = Convert.FromBase64String(encodedString);

            using (GZipStream stream = new GZipStream(new MemoryStream(data), CompressionMode.Decompress))
            {
                const int size = 4096;
                byte[] buffer = new byte[size];
                using (MemoryStream memory = new MemoryStream())
                {
                    int count = 0;
                    do
                    {
                        count = stream.Read(buffer, 0, size);
                        if (count > 0)
                        {
                            memory.Write(buffer, 0, count);
                        }
                    }
                    while (count > 0);

                    var messages = JObject.Parse(Encoding.UTF8.GetString(memory.ToArray())).GetValue("logEvents");
                    foreach (var item in messages)
                    {
                        decodedString += item["message"].Value<string>() + Environment.NewLine;
                    }
                }
            }

            return decodedString;
        }

    }

	public class LogEvent
	{
		public  Log Awslogs { get; set; }
		public class Log
		{
			public  string Data { get; set; }
		}
	}
}
```

### イベントのデシリアライズ
メトリクスフィルタを設定し、LambdaにストリーミングするとLambdaにはつぎのようなイベントが通知されます。

```json
{
  "awslogs": {
    "data": "H4sIAAAAAAAAAHWPwQqCQBCGX0Xm7EFtK+smZBEUgXoLCdMhFtKV3akI8d0bLYmibvPPN3wz00CJxmQnTO41whwWQRIctmEcB6sQbFC3CjW3XW8kxpOpP+OC22d1Wml1qZkQGtoMsScxaczKN3plG8zlaHIta5KqWsozoTYw3/djzwhpLwivWFGHGpAFe7DL68JlBUk+l7KSN7tCOEJ4M3/qOI49vMHj+zCKdlFqLaU2ZHV2a4Ct/an0/ivdX8oYc1UVX860fQDQiMdxRQEAAA=="
   }
 }
```

[Amazon CloudWatch Logs のイベント例](http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/eventsources.html#eventsources-cloudwatch-logs) より

> Lambda レコードの [Data] 属性は、Base64 でエンコードされており、gzip 形式で圧縮されています

という説明もあります。

[例 2: AWS Lambda のサブスクリプションフィルタ](http://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/SubscriptionFilters.html)

これを`FunctionHandler`で受け取る際は`Amazon.Lambda.Serialization.Json.JsonSerializer`を使ってデシリアライズされるため、デシリアライズされる結果（クラス）を定義する必要があります。

その結果が`LogEvent`です。

[aws-lambda-dotnet](https://github.com/aws/aws-lambda-dotnet)では[S3用のイベントクラス](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.S3Events)や[DynamoDB用のイベントクラス](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DynamoDBEvents)が定義されており、今回のように定義する必要はありません。

CloudWatch Logsの情報がそれほど複雑でないからでしょうか。
※この記事のあとに出したPRがマージされたため、2017.12.23時点ではCloudWatch Logs用のイベントクラスは存在します。
[Amazon.Lambda.CloudWatchLogsEvents](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.CloudWatchLogsEvents)

### SlackのIncoming Webhook
`FunctionHandler`の最初の数行で環境変数から3つのURLを読み込んでいます。

```csharp
var slackWebhookUrl = Environment.GetEnvironmentVariable("SLACK_WEBHOOK_URL");
var cloudWatchLogGroupUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_LOG_GROUP_URL");
var cloudWatchMetricsUrl = Environment.GetEnvironmentVariable("CLOUDWATCH_METRICS_URL");
```

SLACK_WEBHOOK_URLはつぎのページに説明があります。

[Incoming Webhooks](https://api.slack.com/incoming-webhooks)

Slackの設定ページから取得できます。

---

<img width="1442" alt="slack1.png" src="https://qiita-image-store.s3.amazonaws.com/0/46547/f532fbcf-4c1b-71ef-a809-91c2e9c41607.png">

---

<img width="1436" alt="slack2.png" src="https://qiita-image-store.s3.amazonaws.com/0/46547/cc2f2b89-dedd-0c08-83ce-3528ebc0e08e.png">

---

通知メッセージ内のリンク用のURLも2つ追加しています。

エラーが発生したとき、Lambdaのログを吐き出すロググループのページとメトリクスのページ（時間ごとのLambda呼び出し回数やエラー数が確認できるダッシュボードのページ）も見たくなるかなぁくらいの意味合いです。

Slackへの通知は次のようになり、アイコンやユーザ名も上書きできます。

<img width="304" alt="slack3.png" src="https://qiita-image-store.s3.amazonaws.com/0/46547/763a85c7-4faa-36fa-cc6f-86c7dd8d334f.png">

### データのデコード
先ほども触れたように、Amazon CloudWatch Logs のイベントはエンコードされています。

それをデコードするためのメソッドが`Decode`です。

ます、`data`の中身をBase64デコードしてバイト配列にしています。（`Convert.FromBase64String(encodedString)`）

次に、unzipして`JObject`にパースし、必要なメッセージだけを取得しています。

json全体は次のようになっています。

```json
{
  "messageType":"DATA_MESSAGE",
  "owner":"123456789123",
  "logGroup":"testLogGroup",
  "logStream":"testLogStream",
  "subscriptionFilters":["testFilter"],
  "logEvents":
    [
      {
        "id":"eventId1",
        "timestamp":1440442987000,
        "message":"[ERROR] First test message"
      },
      {
        "id":"eventId2",
        "timestamp":1440442987001,
        "message":"[ERROR] Second test message"
      }
    ]
}
```

1つの`logEvents`にはそれほど多くの`message`は詰め込まれない（？？？）はずなので`StringBuilder`でなく`+`演算子で`decodedString`を組み立てています。

例外処理まったくないのでだいぶ脆いですね。

※[Amazon.Lambda.CloudWatchLogsEvents](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.CloudWatchLogsEvents)が追加されたときにデコード後のデータも`CloudWatchLogsEvent`クラスでプロパティとして持つようになりました。

## Lambdaテスト
.NETでLambda開発を行う場合、ローカルで動作確認を行うときはテストを利用すると便利です。

[AWS Toolkit for Visual Studio](https://aws.amazon.com/jp/visualstudio/)をインストールしたじょうたいでLambdaプロジェクトを作成するとき、テスト付きのテンプレートを選択することができます。

コードはこんな感じです。

```csharp:FunctionTest.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

using Xunit;
using Amazon.Lambda.Core;
using Amazon.Lambda.TestUtilities;

using ErrorNotificationLambda;

namespace ErrorNotificationLambda.Tests
{
    public class FunctionTest : IClassFixture<LaunchSettingsFixture>
    {
	    LaunchSettingsFixture _fixture;

	    public FunctionTest(LaunchSettingsFixture fixture)
	    {
		    _fixture = fixture;
	    }

		[Fact]
        public async void TestErrorNotificationFunction()
        {
			var function = new Function();
            var context = new TestLambdaContext();

	        var evnt = new LogEvent
	        {
		        Awslogs = new LogEvent.Log
				{
                    Data = "H4sIAAAAAAAAAHWPwQqCQBCGX0Xm7EFtK+smZBEUgXoLCdMhFtKV3akI8d0bLYmibvPPN3wz00CJxmQnTO41whwWQRIctmEcB6sQbFC3CjW3XW8kxpOpP+OC22d1Wml1qZkQGtoMsScxaczKN3plG8zlaHIta5KqWsozoTYw3/djzwhpLwivWFGHGpAFe7DL68JlBUk+l7KSN7tCOEJ4M3/qOI49vMHj+zCKdlFqLaU2ZHV2a4Ct/an0/ivdX8oYc1UVX860fQDQiMdxRQEAAA=="
				}
	        };

			var notification = await function.FunctionHandler(evnt, context);

            Assert.True(notification);
        }
    }
}
```

```csharp:LaunchSettingsFixture.cs
using System;
using System.IO;
using System.Collections.Generic;
using System.Linq;
using System.Text;

using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace ErrorNotificationLambda.Tests
{
	public class LaunchSettingsFixture : IDisposable
	{
		public LaunchSettingsFixture()
		{
			using (var file = File.OpenText("Properties" + Path.DirectorySeparatorChar + "launchSettings.json"))
			{
				var reader = new JsonTextReader(file);
				var jObject = JObject.Load(reader);

				var variables = jObject
					.GetValue("profiles")
					.SelectMany(profiles => profiles.Children())
					.SelectMany(profile => profile.Children<JProperty>())
					.Where(prop => prop.Name == "environmentVariables")
					.SelectMany(prop => prop.Value.Children<JProperty>())
					.ToList();

				foreach (var variable in variables)
				{
					Environment.SetEnvironmentVariable(variable.Name, variable.Value.ToString());
				}
			}
		}

		public void Dispose()
		{
			// ... clean up
		}
	}
}
```

### 環境変数の設定
本番実行する際、環境変数はポータルで設定したものか、Visual Studioから発行するときに設定するものを使用できます。

ローカルではテストケース実行前にxUnitのClass Fixturesを利用して環境変数を設定しています。

[Shared Context between Tests](https://xunit.github.io/docs/shared-context.html)

`Dispose`はサボってます…

これ（本番デプロイした本体の方）をCloudWatch Logsメトリックフィルタの接続先に設定するとSlackで通知を受け取ることができます。

# aws-lambda-dotnetへのコントリビューション
先ほどこんなことを書きました。

> [aws-lambda-dotnet](https://github.com/aws/aws-lambda-dotnet)では[S3用のイベントクラス](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.S3Events)や[DynamoDB用のイベントクラス](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.DynamoDBEvents)が定義されており、今回のように定義する必要はありません。

というわけで、CloudWatch Logs用のクラスを追加してみましょう。

Lambdaは次のイベントソースからのイベントで呼び出すことができ、イベントのサンプルも見ることができます。

[サポートされているイベントソース](http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/invoking-lambda-function.html)

* Amazon S3
* Amazon DynamoDB
* Amazon Kinesis Streams
* Amazon Simple Notification Service
* Amazon Simple Email Service
* Amazon Cognito
* AWS CloudFormation
* Amazon CloudWatch Logs
* Amazon CloudWatch Events
* AWS CodeCommit
* スケジュールされたイベント (Amazon CloudWatch Events を使用)
* AWS Config
* Amazon Alexa
* Amazon Lex
* Amazon API Gateway
* AWS IoT ボタン
* Amazon CloudFront
* Amazon Kinesis Firehose
* その他カスタムイベント

[イベントソースによって公開されたサンプルイベント](http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/eventsources.html#eventsources-cloudwatch-logs)

これらの中でjsonをaws-lambda-dotnetの定義するクラスにデシリアライズできるものは限られています。

2017.12.17時点でこれだけです。

<img width="1007" alt="lambdadotnet.png" src="https://qiita-image-store.s3.amazonaws.com/0/46547/6e757f46-cc00-6de6-53fc-8cb37665e04c.png">

[aws-lambda-dotnet/Libraries/src/](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src)

これらのクラスはどんなことをしてくれているのでしょうか？

たとえば、AWS Configイベントの対応関係はつぎのようになっています。




```json:イベント
{
    "invokingEvent": "{\"configurationItem\":{\"configurationItemCaptureTime\":\"2016-02-17T01:36:34.043Z\",\"awsAccountId\":\"000000000000\",\"configurationItemStatus\":\"OK\",\"resourceId\":\"i-00000000\",\"ARN\":\"arn:aws:ec2:us-east-1:000000000000:instance/i-00000000\",\"awsRegion\":\"us-east-1\",\"availabilityZone\":\"us-east-1a\",\"resourceType\":\"AWS::EC2::Instance\",\"tags\":{\"Foo\":\"Bar\"},\"relationships\":[{\"resourceId\":\"eipalloc-00000000\",\"resourceType\":\"AWS::EC2::EIP\",\"name\":\"Is attached to ElasticIp\"}],\"configuration\":{\"foo\":\"bar\"}},\"messageType\":\"ConfigurationItemChangeNotification\"}",
    "ruleParameters": "{\"myParameterKey\":\"myParameterValue\"}",
    "resultToken": "myResultToken",
    "eventLeftScope": false,
    "executionRoleArn": "arn:aws:iam::012345678912:role/config-role",
    "configRuleArn": "arn:aws:config:us-east-1:012345678912:config-rule/config-rule-0123456",
    "configRuleName": "change-triggered-config-rule",
    "configRuleId": "config-rule-0123456",
    "accountId": "012345678912",
    "version": "1.0"
}
```

```csharp:Amazon.Lambda.ConfigEvents/ConfigEvent.cs
namespace Amazon.Lambda.ConfigEvents
{
    using System;

    /// <summary>
    /// AWS Config event
    /// http://docs.aws.amazon.com/config/latest/developerguide/evaluate-config_develop-rules.html
    /// http://docs.aws.amazon.com/config/latest/developerguide/evaluate-config_develop-rules_example-events.html
    /// </summary>
    public class ConfigEvent
    {
        /// <summary>
        /// The ID of the AWS account that owns the rule.
        /// </summary>
        public string AccountId { get; set; }

        /// <summary>
        /// The ARN that AWS Config assigned to the rule.
        /// </summary>
        public string ConfigRuleArn { get; set; }

        /// <summary>
        /// The ID that AWS Config assigned to the rule.
        /// </summary>
        public string ConfigRuleId { get; set; }

        /// <summary>
        /// The name that you assigned to the rule that caused AWS Config
        /// to publish the event and invoke the function.
        /// </summary>
        public string ConfigRuleName { get; set; }

        /// <summary>
        /// A Boolean value that indicates whether the AWS resource to be
        /// evaluated has been removed from the rule's scope.
        /// </summary>
        public bool EventLeftScope { get; set; }

        /// <summary>
        /// The ARN of the IAM role that is assigned to AWS Config.
        /// </summary>
        public string ExecutionRoleArn { get; set; }

        /// <summary>
        /// If the event is published in response to a resource configuration
        /// change, the value for this attribute is a string that contains
        /// a JSON configuration item.
        /// </summary>
        public string InvokingEvent { get; set; }

        /// <summary>
        /// A token that the function must pass to AWS Config with the
        /// PutEvaluations call.
        /// </summary>
        public string ResultToken { get; set; }

        /// <summary>
        /// Key/value pairs that the function processes as part of its
        /// evaluation logic.
        /// </summary>
        public string RuleParameters { get; set; }

        /// <summary>
        /// A version number assigned by AWS.
        /// The version will increment if AWS adds attributes to AWS Config
        /// events.
        /// If a function requires an attribute that is only in events that
        /// match or exceed a specific version, then that function can check
        /// the value of this attribute.
        /// </summary>
        public string Version { get; set; }

    }
}
```

つまり、Json.NETでデシリアライズするためのクラスを定義しているだけです。

[aws-lambda-dotnet/Libraries/src/Amazon.Lambda.ConfigEvents/](https://github.com/aws/aws-lambda-dotnet/tree/master/Libraries/src/Amazon.Lambda.ConfigEvents)

一番単純なプロジェクトはクラス定義 + README.md + [テスト](https://github.com/toshi0607/aws-lambda-dotnet/blob/master/Libraries/test/EventsTests/EventTests.cs)です。

というわけで真似してPRを出してみました。

[add cloudwatch logs event #188](https://github.com/aws/aws-lambda-dotnet/pull/188)

ちょっとよくわからない部分もあったのでどうなるかわかりませんが、本番で便利に使えるよう粘ります。

※2017.12.23にマージされました🎉

# おまけ
[Echo招待来ない](https://twitter.com/search?q=%23Echo%E6%8B%9B%E5%BE%85%E6%9D%A5%E3%81%AA%E3%81%84&src=typd)です。Google Home miniが届きました。

そういうわけで、SlackだけでなくGoogle Home miniでも通知してみましょう。

特に詳細に説明することはないのですが、Google Homeに喋らせるには現状[noelportugal/google-home-notifier](https://github.com/noelportugal/google-home-notifier)を利用するのが便利そうです。

[Googleアシスタント用のIFTTT](https://ifttt.com/google_assistant)もいくつかありますが、Google Homeに**しゃべらす**ことはできません。

そこでgoogle-home-notifierをサーバで立ち上げ、それを経由してGoogle Homeで通知してもらいます。

とりあえずこの記事が最高です。

[Google Home開発入門 / google-home-notifier解説](https://qiita.com/SatoTakumi/items/c9de7ff27e5b70508066)

設定はこの記事が詳しいです。

[https://qiita.com/azipinsyan/items/db4606aaa51426ac8dac](https://qiita.com/azipinsyan/items/db4606aaa51426ac8dac)

本家のexample.jsのままでは日本語を喋ってくれないことに注意してください。

ややこしい部分はPR出してみました。

[to enable language settings #31](https://github.com/noelportugal/google-home-notifier/pull/31)


Lambdaにはこんなコードを追加すればOKです。

```csharp
var googleHomeWebhookUrl = "https://xxxxxxxx.ngrok.io/google-home-notifier";

var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
    { "text", "AWS Lambdaでエラーが発生しました。早く直してください。お大事に。"}
});

using (var client = new HttpClient())
{
    var res = await client.PostAsync(googleHomeWebhookUrl, content);
}
```

動作確認してみましょう。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Qiita用<br>/ CloudWatch Logに特定のエラーが吐かれたことをGoogle Homeに喋らせる <a href="https://t.co/wdRJno14Iw">https://t.co/wdRJno14Iw</a></p>&mdash; かえる氏の闘争 (@toshi0607) <a href="https://twitter.com/toshi0607/status/942920628302053376?ref_src=twsrc%5Etfw">2017年12月19日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


Google Homeは完全な出来心ですが、使いながら改善していこうと思います。