ブラウザ上のJSエラー情報をAWS Elasticsearch+Kinabaで収集・可視化する

<img width="1300" alt="2019-12-07 18-29-13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/6b3b164b-cef6-d47d-dc3d-5b61b37c315e.png">
<img width="1069" alt="2019-12-08 00-39-55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/9d5f72d2-552c-5df2-03a0-2084932fa749.png">

Web ブラウザ向けの JavaScript 開発を行っていると、利用者のブラウザ上で発生しているエラー情報を収集したくなってきます。
開発時には入念にテストはしているものの、利用者のブラウザ環境は多種多様であり、全ての問題を事前に検知して対処することは不可能なため、実際に発生しているエラー情報を早期に検知して収集することには重要な価値があります。

世の中には [Sentry](https://sentry.io/)のような SaaS のプラットフォームも存在しており、多くの方が活用していますが、自分が開発している JavaScript は自サイトでの利用ではなく、他の Web サイトに組み込んで利用してもらう類のものであるため、以下の制約を満たす必要があります。

- Global 汚染をしてはいけない
    - 自分のサイトでもだめなんですが、他のサイトで使われるものは汚染厳禁
    - Sentry のようなサービスはエラー収集のために標準のオブジェクトやメソッド（ `console.error` 等）を wrap したりしている
- 自分のコードに関わるエラー以外を収集してはいけない
    - 自分のコード以外のエラーを収集してしまうとノイズが混ざる
    - セキュリティ的な観点からもマズい

このような制約を満たす既存のサービスが見当たらなかったため、以下のような仕組みで自力でデータ収集を行い、Kinaba で可視化して運用しています。

ざっくりとした構成イメージは以下の通りです。

<img width="758" alt="2019-12-07 18-03-58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/6db979fd-3ea4-410f-b769-ecaae532238d.png">

- エンドユーザーのブラウザ内 JS から CloudFront のエンドポイントに POST でエラー情報を送信する
- CloudFront はダミーの S3 Origin に接続されており、Custom Error Document 403 を 200 に書き換え
- POST されたデータを Lambda＠Edge で処理、エラー情報を Kinesis Firehose に流す
- Kinesis Firehose と Elasticsearch を接続し、データが溜まるようにする
- AWS Elasticsearch に付属の Kibana で可視化を行う

## 環境構築

### スタック構築

以下の CloudFormation Stack テンプレートを使って上記の環境を構築します。
Lambda＠Edge は us-east-1 (N.Virginia) でしか作成できないため、us-east-1 でスタック作成してください。

> パラメータの `KibanaAccessIPList` には、Kibana へのアクセスを許可する IP アドレスを入力してください。複数ある場合はカンマ区切りで列挙できます。

状況にもよりますが、CloudFront 等を作成するため、30分程度はかかりますので注意。

```stack.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Log Keeper

Parameters:
  ElasticsearchInstanceType:
    Description: Instance type for Elasticsearch
    Type: String
    Default: t2.small.elasticsearch

  ElasticsearchVolumeSize:
    Description: Volume size for Elasticsearch
    Type: Number
    Default: 10

  KibanaAccessIPList:
    Description: IP address list for Kibana access
    Type: List<String>

Resources:
  Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain

  DeliveryStream:
    Type: AWS::KinesisFirehose::DeliveryStream
    Properties:
      DeliveryStreamType: DirectPut
      ElasticsearchDestinationConfiguration:
        BufferingHints:
          IntervalInSeconds: 60
          SizeInMBs: 50
        CloudWatchLoggingOptions:
          Enabled: true
          LogGroupName: !Sub /aws/kinesisfirehose/${AWS::StackName}
          LogStreamName: elasticsearchDelivery
        DomainARN: !GetAtt Elasticsearch.DomainArn
        IndexName: js-error
        IndexRotationPeriod: OneDay
        RetryOptions:
          DurationInSeconds: 60
        RoleARN: !GetAtt RoleFirehose.Arn
        S3BackupMode: AllDocuments
        S3Configuration:
          BucketARN: !GetAtt Bucket.Arn
          BufferingHints:
            IntervalInSeconds: 60
            SizeInMBs: 50
          CompressionFormat: UNCOMPRESSED
          Prefix: firehose/
          RoleARN: !GetAtt RoleFirehose.Arn
          CloudWatchLoggingOptions:
            Enabled: true
            LogGroupName: !Sub /aws/kinesisfirehose/${AWS::StackName}
            LogStreamName: s3Delivery
        TypeName: fromFirehose

  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Comment: Log Keeper
        CustomErrorResponses:
          - ErrorCode: 403
            ResponseCode: 200
            ResponsePagePath: /index.json
        DefaultCacheBehavior:
          AllowedMethods:
            - DELETE
            - GET
            - HEAD
            - OPTIONS
            - PATCH
            - POST
            - PUT
          Compress: true
          DefaultTTL: 300
          ForwardedValues:
            QueryString: false
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !Ref LambdaVersion
          TargetOriginId: S3Origin
          ViewerProtocolPolicy: redirect-to-https
        Enabled: true
        HttpVersion: http2
        Logging:
          Bucket: !Sub ${Bucket}.s3.amazonaws.com
          IncludeCookies: false
          Prefix: cloudfront/
        Origins:
          - Id: S3Origin
            DomainName: !Sub ${Bucket}.s3.amazonaws.com
            OriginPath: /cf-origin
            S3OriginConfig: {}
        PriceClass: PriceClass_200

  Elasticsearch:
    Type: AWS::Elasticsearch::Domain
    Properties:
      AccessPolicies:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: '*'
            Action: es:*
            Condition:
              IpAddress:
                aws:SourceIp: !Ref KibanaAccessIPList
            Resource: arn:aws:es:*
      EBSOptions:
        EBSEnabled: true
        VolumeSize: !Ref ElasticsearchVolumeSize
        VolumeType: gp2
      ElasticsearchClusterConfig:
        InstanceCount: 1
        InstanceType: !Ref ElasticsearchInstanceType
        ZoneAwarenessEnabled: false
      ElasticsearchVersion: '6.8'

  Lambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: !Sub |
          const AWS = require('aws-sdk')

          const firehose = new AWS.Firehose({region: '${AWS::Region}'})

          const preflight = () => ({
            headers: {
              'access-control-allow-headers': [{ key: 'Access-Control-Allow-Headers', value: 'Content-Type' }],
              'access-control-allow-methods': [{ key: 'Access-Control-Allow-Methods', value: 'POST,OPTIONS' }],
              'access-control-allow-origin': [{ key: 'Access-Control-Allow-Origin', value: '*' }],
              'access-control-max-age': [{ key: 'Access-Control-Max-Age', value: '300' }],
            },
            status: '200'
          })

          const response = (status, message = 'OK') => ({
            body: JSON.stringify({ message: message }),
            headers: {
              'access-control-allow-origin': [{ key: 'Access-Control-Allow-Origin', value: '*' }],
              'cache-control': [{ key: 'Cache-Control', value: 'private, no-cache, no-store, must-revalidate' }],
              'content-encoding': [{ key: 'Content-Encoding', value: 'UTF-8' }],
              'content-type': [{ key: 'Content-Type', value: 'application/json' }]
            },
            status: status.toString()
          })

          exports.handler = async (event, context, callback) => {
            // Get request and request headers
            const request = event.Records[0].cf.request

            if (request.method === 'OPTIONS') {
              // preflight request
              callback(null, preflight())
              return
            }

            if (request.method !== 'POST') {
              // invalid method
              callback(null, response(403, 'Forbidden'))
              return
            }

            if (!request.body || !request.body.data) {
              // no body
              callback(null, response(400, 'Body is empty'))
              return
            }

            try {
              if (request.headers['content-type'][0].value !== 'application/json') {
                callback(null, response(400, 'Invalid body type'))
                return
              }
            } catch (err) {
              callback(null, response(400, 'Invalid body type'))
              return
            }

            try {
              request.body = JSON.parse(Buffer.from(request.body.data, 'base64'))
            } catch (err) {
              callback(null, response(400, 'Invalid body data'))
              return
            }

            for (let key of Object.keys(request.headers)) {
              request.headers[key] = request.headers[key].map(obj => obj.value).join('\n')
            }

            console.log(JSON.stringify(event.Records[0].cf))
            const params = {
              DeliveryStreamName: '${DeliveryStream}',
              Record: {
                Data: JSON.stringify(event.Records[0].cf)
              }
            }

            try {
              await firehose.putRecord(params).promise()
            } catch (err) {
              console.error(err)
            }
            callback(null, response(200))
          }
      Description: Log Keeper - logging function
      Handler: index.handler
      Role: !GetAtt RoleLambdaExecution.Arn
      Runtime: nodejs10.x
      Timeout: 5

  LambdaVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref Lambda

  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/kinesisfirehose/${AWS::StackName}

  LogStreamESDelivery:
    Type: AWS::Logs::LogStream
    Properties:
      LogGroupName: !Ref LogGroup
      LogStreamName: elasticsearchDelivery

  LogStreamS3Delivery:
    Type: AWS::Logs::LogStream
    Properties:
      LogGroupName: !Ref LogGroup
      LogStreamName: s3Delivery

  RoleFirehose:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                sts:ExternalId: !Ref AWS::AccountId
            Principal:
              Service: firehose.amazonaws.com
      Path: /
      Policies:
        - PolicyName: CustomPolicy
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - s3:AbortMultipartUpload
                  - s3:GetBucketLocation
                  - s3:GetObject
                  - s3:ListBucket
                  - s3:ListBucketMultipartUploads
                  - s3:PutObject
                Resource:
                  - !Sub ${Bucket.Arn}
                  - !Sub ${Bucket.Arn}/*
              - Effect: Allow
                Action:
                  - es:DescribeElasticsearchDomain
                  - es:DescribeElasticsearchDomains
                  - es:DescribeElasticsearchDomainConfig
                  - es:ESHttpPost
                  - es:ESHttpPut
                Resource:
                  - !Sub ${Elasticsearch.DomainArn}
                  - !Sub ${Elasticsearch.DomainArn}/*
              - Effect: Allow
                Action: es:ESHttpGet
                Resource:
                  - !Sub ${Elasticsearch.DomainArn}/_all/_settings
                  - !Sub ${Elasticsearch.DomainArn}/_cluster/stats
                  - !Sub ${Elasticsearch.DomainArn}/index-name*/_mapping/type-name
                  - !Sub ${Elasticsearch.DomainArn}/_nodes
                  - !Sub ${Elasticsearch.DomainArn}/_nodes/stats
                  - !Sub ${Elasticsearch.DomainArn}/_nodes/*/stats
                  - !Sub ${Elasticsearch.DomainArn}/_stats
                  - !Sub ${Elasticsearch.DomainArn}/index-name*/_stats
              - Effect: Allow
                Action: logs:PutLogEvents
                Resource: !GetAtt LogGroup.Arn

  RoleLambdaExecution:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action: sts:AssumeRole
            Principal:
              Service:
                - edgelambda.amazonaws.com
                - lambda.amazonaws.com
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Path: /
      Policies:
        - PolicyName: custom
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action: firehose:PutRecord
                Resource: !GetAtt DeliveryStream.Arn
```

### 追加の設定

スタックが構築できたら、CloudFront の管理画面に行き Distribution を選択、

<img width="834" alt="2019-12-07 23-12-42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/890c3a17-57aa-c6f5-552b-48c245f120b6.png">

General タブに表示される Domain Name (xxxxxxxx.cloudfront.net) を控えておきます。

<img width="746" alt="2019-12-07 23-14-20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/886fdbba-54a5-6cd1-3aaf-7bb720749828.png">

さらに Behaviors タブで Default を選択して編集し、

<img width="723" alt="2019-12-07 23-15-58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/f0490118-0f23-4eeb-d826-c2d14fd13342.png">

以下のように Lambda Function Associations の「Include Body」にチェックを入れておきます。

<img width="958" alt="2019-12-07 22-54-58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/b3781daf-9210-edb1-c350-115fde1424c0.png">

> 本来は CloudFormation でやりたいところなのですが、いつまで経ってもこのオプション設定が CloudFormation に反映されず。。

## Kibana の設定

### 設定前のダミーデータ送信

Kibana で可視化の設定を行うためには何かしらデータが飛んでいないとだめなので、以下のように仮のエラーログを送信しておきます。

手っ取り早く jQuery の ajax で送信するために、[jQuery 本家](https://jquery.com/) のサイトに行き、ブラウザの開発者ツールを表示して、console に以下の JavaScript を入力してデータを送信しておきます。

> `YOUR_CLOUDFRONT_HOST` の部分は、先ほど控えた CloudFront の Domain Name (xxxxxxxx.cloudfront.net) を指定します。

```javascript
$.ajax({
  url: `https://YOUR_CLOUDFRONT_HOST/foo/bar`,
  method: 'POST',
  contentType: 'application/json',
  data: JSON.stringify({
    time: new Date().toISOString(),
    level: 'error',
    message: 'message',
    filename: 'example.js',
    lineno: 1,
    colno: 10,
    stack: 'stack info'
  })
})
```

### Kibana の index 設定

AWS の Elasticsearch の管理画面に行くと、Kibana のリンクが取得できます。

<img width="1016" alt="2019-12-07 23-19-18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/c3cc1e1f-f6ed-cac0-26f4-059e7b333f98.png">

Management メニューを選択、Index Patterns から、新規に index を作成します。

うまくデータが送信できて、Lambda -> Firehose -> Elasticsearch までたどり着いていれば、`js-error-YYYY-MM-DD` のような日付毎の index が生成されていますので、index pattern に `js-error-` と入力して、Next step に進みます。

<img width="808" alt="2019-12-07 23-59-16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/5233483b-2849-2850-fa49-b3c68401b975.png">

Time filter field に `request.body.time` を選択して、index pattern の作成を完了します。

<img width="812" alt="2019-12-07 23-59-59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/06797442-ce53-692b-2e9a-5a99f42115bf.png">

Discover メニューを開くと以下のようにデータが表示されていれば OK です。

<img width="1273" alt="2019-12-08 00-03-40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15611/6e7d395f-8f54-ed93-ea96-5dd889dbe381.png">

### Kibana のカスタマイズ

POST で送信したデータが `request.body` 配下に入っており、その他にも `request.uri` や `request.headers.*` 等、解析に必要な情報が一通り Lambda から収集されていますので、好きな項目をフィルタして表示させると便利です。

## JavaScript からのデータ送信

毎回 try-catch して `$.ajax` で飛ばすのは面倒なので、自分は `error` と `observe` いうメソッドを作成して使用しています。

以下、サンプルのコードです。

```javascript
import $ from 'jquery'
import params from './params'

const parseStack = stack => {
  const re = (stack || '').match(/(http.+?):(\d+):(\d+)/)

  if (re) {
    return {
      file: re[1],
      line: Number(re[2]) || 0,
      column: Number(re[3]) || 0
    }
  }
  return {}
}

const error = err => {
  try {
    const parsed = parseStack(err.stack)

    $.ajax({
      url: `https://${params.host}/${params.service}`,
      method: 'POST',
      contentType: 'application/json',
      data: JSON.stringify({
        time: new Date().toISOString(),
        level: 'error',
        message: err.message || '',
        filename: err.fileName || err.sourceURL || parsed.file || '',
        lineno: err.lineNumber || err.line || parsed.line || 0,
        colno: err.columnNumber || parsed.column || 0,
        stack: err.stack || ''
      })
    })
  } catch (err) {}
}

const observe = fn => function () {
  try {
    return fn.apply(this, arguments)
  } catch (err) {
    error(err)
  }
}

export { error, observe }
```

> ブラウザによって file name、line、column 等の取得方法が異なるため、stack を parse したりと多少面倒なことをしています。

### `error(errorObject)`

catch したエラーオブジェクトを渡すとロガーに送信してくれます。

### `observe(func)`

渡した関数を try-catch で wrap した関数を返します。

`setTimeout(observe(function() {...}), 100)` のように、独立したスコープのメソッド呼び出し時に使うことで、エラーをキャッチしてくれます。

## 所感

安全性を考慮して Global 汚染を避けた分、自力で try-catch もしくは上記の observe をかけないといけないので、100% 確実にエラーを取得できる、というわけではありませんが、半年以上運用してみて十分に実用的だと感じています。

今回はエラーのロギングを行いましたが、エラー以外にも任意のロギングが可能なので、Lambda@Edge のコストを考慮する必要はあるものの、色々な使い方ができると考えています。