5
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-07

2019-12-07 18-29-13.png
2019-12-08 00-39-55.png

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

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

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

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

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

2019-12-07 18-03-58.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 を選択、

2019-12-07 23-12-42.png

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

2019-12-07 23-14-20.png

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

2019-12-07 23-15-58.png

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

2019-12-07 22-54-58.png

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

Kibana の設定

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

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

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

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

$.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 のリンクが取得できます。

2019-12-07 23-19-18.png

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

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

2019-12-07 23-59-16.png

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

2019-12-07 23-59-59.png

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

2019-12-08 00-03-40.png

Kibana のカスタマイズ

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

JavaScript からのデータ送信

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

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

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 のコストを考慮する必要はあるものの、色々な使い方ができると考えています。

5
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
4