🗒️

任意の CloudWatch Logs ログメッセージを、一発で表示できるURLを組み立てたい

2024/05/28に公開

直面した問題

プロダクトにて、Subscription Filter & Lambda 構成でエラーログを捕捉しています。

overview

  1. コンピューティングリソースが、CloudWatch Logs へログを配信する
  2. CloudWatch Logs の Subscription Filter にて、配信されたログが、特定の文字列を含むか監視する
  3. 特定の文字列を含むログを捕捉したら、 EventLogConsumer Lambda関数を呼び出す
  4. Lambda関数を使用して、ユーザー(Slack)へ通知する

Slackへ投下するテキストは、以下の情報を含んでいます。

  • エラーログに記録されたメッセージ
  • 該当メッセージを含む CloudWatch Logs ログストリームへのリンク

リンクからログストリームの画面は表示できるのですが、そこからエラーログを頑張って探している状況でした。

このエラーログを探す作業がなかなか面倒で、運用作業で度々消耗していました。

やりたいこと

エラーログを探す手間を省きたい。

より具体的には、

特定の、
CloudWatch Logs のログメッセージを、
一発で表示できるURLを組み立てたい。

want-to-do

結論

以下のようにURLを構成します。

// URLの組み立てに必要な情報
log_group="ロググループ名"
log_stream="ログストリーム名"
log_timestamp_ISO_format="yyyy-MM-ddTHH:mm:ss.fffZ"
log_id="ログメッセージに割り当てられるID情報"

// 組み立て
cwl_url = f"https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#logsV2:log-groups/log-group/{log_group}/log-events/{log_stream}?start={log_timestamp_ISO_format}&refEventId={log_id}"

ポイント

  • start= で、表示するログメッセージの先頭時刻を絞り込む
    • => 該当ログメッセージの表示時刻を設定することで、先頭に表示されるようになる
  • refEventId= の値を設定することで、該当メッセージが色付けされ、少し目立つようになる
  • 以下は、全て Lambda の event 変数から取得できる
    • URLの組み立てに必要な情報
    • Subscription Filter に引っかかったログメッセージ

Lambda 関数 の event 変数から、URL組み立てに必要な情報を取り出す

Lambda関数の event 変数から、ログデータを取り出し、jsonへ変換します。

def handler(event, context):
    text = gzip.decompress(base64.b64decode(event['awslogs']['data']))
    text_json = json.loads(text)

jsonへ変換したログデータは、以下のフォーマットで定義されています。

{
    "owner": "123456789012",
    "logGroup": "CloudTrail",
    "logStream": "123456789012_CloudTrail_us-east-1",
    "subscriptionFilters": [
        "Destination"
    ],
    "messageType": "DATA_MESSAGE",
    "logEvents": [
        {
            "id": "31953106606966983378809025079804211143289615424298221568",
            "timestamp": 1432826855000,
            "message": "{\"eventVersion\":\"1.03\",\"userIdentity\":{\"type\":\"Root\"}"
        },
        {
            "id": "31953106606966983378809025079804211143289615424298221569",
            "timestamp": 1432826855000,
            "message": "{\"eventVersion\":\"1.03\",\"userIdentity\":{\"type\":\"Root\"}"
        },
        {
            "id": "31953106606966983378809025079804211143289615424298221570",
            "timestamp": 1432826855000,
            "message": "{\"eventVersion\":\"1.03\",\"userIdentity\":{\"type\":\"Root\"}"
        }
    ]
}

参考情報 : https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/SubscriptionFilters.html#LambdaFunctionExample

  • "logGroup" ... ロググループ名
  • "logStream" ... ログストリーム名
  • "logEvents" ... Subscription Filter に引っかかった、ログメッセージ情報
    • "id" ... ログメッセージに割り当てられるID情報
    • "timestamp" ... ログ配信時刻。エポック時間がミリ秒単位で格納されている
    • "message" ... Subscription Filter に引っかかったログメッセージ

URLの組み立てに必要なデータを、取り出します。

log_group = text_json['logGroup']
log_stream = text_json['logStream']
log_id = text_json['logEvents'][0]['id']
log_timestamp_msec = text_json['logEvents'][0]['timestamp']

今回は logEvents の先頭要素だけ欲しいので、配列の先頭要素を直で参照しています。

データを取り出すにあたって、以下の点に注意が必要です。

  1. / 記号を事前にURLエンコーディング
  2. timestamp 情報をISOフォーマットへ変換

注意点1 : / 記号を事前にURLエンコーディング

ロググループ名およびログストリーム名は、名前に / が入る可能性があります。

そのままURLを組み立ててしまうと、ブラウザが / をディレクトリ階層と勘違いし、意図しない画面表示に繋がります。

そのため、事前にURLエンコーディングしておくと安全です。

encoded_log_group = urllib.parse.quote(log_group).replace('/', '%252F')
encoded_log_stream = urllib.parse.quote(log_stream).replace('/', '%252F')

注意点2 : timestamp 情報をISOフォーマットへ変換

url に必要な時刻情報は ISOフォーマットです。
ログ情報はエポック時間がミリ秒単位で格納されているため、変換が必要です。

dt = datetime(1970, 1, 1) + timedelta(milliseconds=timestamp_msec)
encoded_timestamp_ISO_format =  "{}Z".format(dt.isoformat(timespec='milliseconds'))

該当ログメッセージに直飛びする URL を組み立てる

取り出したデータを使って、 結論 に記載した通りに、URLを組み立てます。

cwl_url = "https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#logsV2:log-groups/log-group/{}/log-events/{}?start={}&refEventId={}".format(
        encoded_log_group, encoded_log_stream, encoded_timestamp_ISO_format, log_id)

以上。

さいごに

やればできるものですね。

付録に、pythonで記述した実装のサンプルを掲載しています。
必要に応じて、こちらも併せてご確認ください。

参考文献

付録

実装例(python)

import json
import base64
import gzip
import urllib
from datetime import datetime, timedelta


def _msec_to_isoformat(timestamp_msec: int):
    dt = datetime(1970, 1, 1) + timedelta(milliseconds=timestamp_msec)
    return "{}Z".format(dt.isoformat(timespec='milliseconds'))


def handler(event, context):
    text = gzip.decompress(base64.b64decode(event['awslogs']['data']))
    text_json = json.loads(text)

    # https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/ValidateLogEventFlow.html
    log_group = text_json['logGroup']
    log_stream = text_json['logStream']
    log_id = text_json['logEvents'][0]['id']
    log_timestamp_msec = text_json['logEvents'][0]['timestamp']
    log_msg = text_json['logEvents'][0]['message']

    encoded_log_group = urllib.parse.quote(log_group).replace('/', '%252F')
    encoded_log_stream = urllib.parse.quote(log_stream).replace('/', '%252F')
    encoded_timestamp_ISO_format = urllib.parse.quote(
        _msec_to_isoformat(log_timestamp_msec))

    cwl_url = "https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#logsV2:log-groups/log-group/{}/log-events/{}?start={}&refEventId={}".format(
        encoded_log_group, encoded_log_stream, encoded_timestamp_ISO_format, log_id)

    # 以下、必要に応じて、Slackへ投下したり他のLambdaを呼び出したり。
    print(cwl_url)

    return {
        'statusCode': 200,
        'body': {
            'log_msg':log_msg,
            'url':cwl_url
        }
    }

Discussion