Web ブラウザ向けの JavaScript 開発を行っていると、利用者のブラウザ上で発生しているエラー情報を収集したくなってきます。
開発時には入念にテストはしているものの、利用者のブラウザ環境は多種多様であり、全ての問題を事前に検知して対処することは不可能なため、実際に発生しているエラー情報を早期に検知して収集することには重要な価値があります。
世の中には Sentryのような SaaS のプラットフォームも存在しており、多くの方が活用していますが、自分が開発している JavaScript は自サイトでの利用ではなく、他の Web サイトに組み込んで利用してもらう類のものであるため、以下の制約を満たす必要があります。
- Global 汚染をしてはいけない
- 自分のサイトでもだめなんですが、他のサイトで使われるものは汚染厳禁
- Sentry のようなサービスはエラー収集のために標準のオブジェクトやメソッド(
console.error
等)を wrap したりしている
- 自分のコードに関わるエラー以外を収集してはいけない
- 自分のコード以外のエラーを収集してしまうとノイズが混ざる
- セキュリティ的な観点からもマズい
このような制約を満たす既存のサービスが見当たらなかったため、以下のような仕組みで自力でデータ収集を行い、Kinaba で可視化して運用しています。
ざっくりとした構成イメージは以下の通りです。
- エンドユーザーのブラウザ内 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分程度はかかりますので注意。
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 を選択、
General タブに表示される Domain Name (xxxxxxxx.cloudfront.net) を控えておきます。
さらに Behaviors タブで Default を選択して編集し、
以下のように Lambda Function Associations の「Include Body」にチェックを入れておきます。
本来は 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 のリンクが取得できます。
Management メニューを選択、Index Patterns から、新規に index を作成します。
うまくデータが送信できて、Lambda -> Firehose -> Elasticsearch までたどり着いていれば、js-error-YYYY-MM-DD
のような日付毎の index が生成されていますので、index pattern に js-error-
と入力して、Next step に進みます。
Time filter field に request.body.time
を選択して、index pattern の作成を完了します。
Discover メニューを開くと以下のようにデータが表示されていれば OK です。
Kibana のカスタマイズ
POST で送信したデータが request.body
配下に入っており、その他にも request.uri
や request.headers.*
等、解析に必要な情報が一通り Lambda から収集されていますので、好きな項目をフィルタして表示させると便利です。
JavaScript からのデータ送信
毎回 try-catch して $.ajax
で飛ばすのは面倒なので、自分は error
と observe
いうメソッドを作成して使用しています。
以下、サンプルのコードです。
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 のコストを考慮する必要はあるものの、色々な使い方ができると考えています。