デプロイ時に Bugsnag にソースマップをアップロードして人に優しい Stacktrace にする

こんにちは、 Misoca 開発の @lulu-ulul です。

ニュースを見ていると各地で例年以上の積雪を記録しているみたいですね。皆様も引き続き御気を付けください。

私もそれなりに雪が降る場所に住んでいて当然積雪しているのですが、今年は雪が積もっても早起きして車が出られるまで行う雪かき・倍以上になる通勤時間から解放されてほぼ普段通りの生活ができています。リモート勤務の良さを改めて実感しています。

背景

Misoca ではクラッシュレポートの蓄積・解析サービスとして Bugsnag を使っています。

www.bugsnag.com

Rails のエラーログだけでなく、JavaScript のエラーログも対象にしています。

しかし JavaScript ファイルは minify されていますので、 bugsnag の stacktrace はデフォルトではこんな感じになってしまいます。

f:id:lulu-ulul:20180126104231p:plain

つらい(つらい)。

解決方法

Bugsnag 側でソースマップをアップロードする機能が提供されています。

Bugsnag docs › API › JS source map upload

デプロイ時にソースマップファイルを自動的にアップロードさせれば幸せになれそうですね。

方針

Misoca では本番環境には map ファイルを出力しない様にしていました。

また、デプロイ処理は CodeBuild を利用しています。

それを踏まえると以下の様なステップで進める事になります。

  1. 本番環境でのビルド時にソースマップ(以下 map ファイル)を生成させる様にする
  2. CodeDeploy で実行させる以下の処理を行うスクリプトを実装する
    1. map ファイルのパス一覧を取得する
    2. map ファイルを Bugsnag にアップロードする
    3. map ファイルを削除する

1. 本番環境でのビルド時にソースマップを生成させる様にする

Misoca では現在 Webpacker で管理されているものと、 gulp で管理されているものの2つが共存しています。

Webpacker

Misoca では環境毎に分割しているので本番環境の設定ファイルを変更するだけです。

ソースマップの生成に関しては いくつか種類がありますが、今回は map ファイルを別ファイルとして生成したいので source-map を指定する様にしました。

gulp

gulp の方は Browserify のファイルストリームを vinyl オブジェクトにして扱っています。 構成は少し違うのですが、 gulp が提供している recipe があるので参考にして設定します。 ソースマップ生成にあたってストリームを一度バッファに貯める必要がある様でなるほど!という感じです。

gulp/browserify-uglify-sourcemap.md at master · gulpjs/gulp · GitHub

実際は Babel 使う様にしたり条件に応じて watchify で wrap したりと色々いい感じにしてるのですが、煩雑になるので省くと以下の様な形になります。

var gulp = require('gulp');
var util = require('gulp-util');
var browserify = require('browserify');
var source = require('vinyl-source-stream');
var buffer = require('vinyl-buffer');
var sourcemaps = require('gulp-sourcemaps');

function build_js(development) { // devlopment でどの環境かを管理
  var bundler = browserify({
    // options
  });; 

  return bundler
    .bundle()
    .pipe( source( 'main.js' )) // vinyl のオブジェクトに変換
    .pipe(development ? util.noop() : buffer()) // buffer を使う
    .pipe(development ? util.noop() : sourcemaps.init())
    .pipe(development ? util.noop() : sourcemaps.write('./'))
    .pipe( gulp.dest( '../public/js/' ));
}

gulp.task('build:js', function() {
  return build_js(false);
});
 
gulp.task('build:js:dev', function() {
  return build_js(true);
});

gulp-utilnoop() を使うと「何もしない」という処理で chain できるよ、と教えてもらったのですっきり書けました。

2. CodeDeploy で実行させるスクリプトを実装する

EC2インスタンスで実行させるための準備がほぼ不要ですし、既存のスクリプトと統一したかったのでシェルスクリプトで記述する事にしました。

ここで気をつけたのは以下の点です。

  • ソースマップのアップロードで失敗してもデプロイ自体は失敗させない
    • AppSpec 側の設定では難しそうなので、シェルスクリプト側で例外処理を行い失敗時に非常終了のシグナルを外に投げない様にします
  • 上記の場合に map ファイルを残したままアプリケーションを立ち上げない様にする
    • 例外処理の周りで必ず削除される様にする
  • CodeDeploy で複数ノードを立ち上げるがその内の一つでのみ実行する様にする
    • EC2 のインスタンスのタグを使い特定のノードのみでアップロード処理が行われる様にする

以上を踏まえて作成したシェルスクリプトは以下のようになります。マスクしたり削ったりしている場所もあります。

#!/bin/bash

source ~/.bash_profile
export AWS_CONFIG_FILE=AWS_CONFIG_FILE_PATH
cd APPLICATION_ROOT_PATH

# trap で終了のシグナルを拾って map ファイルを削除する
trap 'find ./public/{js,packs} -name "*.js.map" | xargs -n 1 rm -f --' EXIT

# 環境変数からBugsnag の API key が取得できない場合はスキップ
BUGSNAG_JS_API_KEY=$(bundle exec dotenv bash -c 'echo $BUGSNAG_JS_API_KEY')
if [ -z "${BUGSNAG_JS_API_KEY}" ];then exit 0;fi


# 指定のROLE-DEPLOYMENT_ROLEの時だけ実行する
# EC2 のインスタンスメタデータの中からタグの情報を取得する
INSTANCE_ID=`curl -s http://169.254.169.254/latest/meta-data/instance-id`

TAG_ROLE=` aws ec2 describe-instances --instance-ids ${INSTANCE_ID}  --query 'Reservations[].Instances[].Tags[?Key==\`Role\`].Value' --output text`
TAG_DEPLOYMENT_ROLE=` aws ec2 describe-instances --instance-ids ${INSTANCE_ID}  --query 'Reservations[].Instances[].Tags[?Key==\`DeploymentRole\`].Value' --output text`

if [ "${TAG_ROLE}" == "Job" ] && [ "${TAG_DEPLOYMENT_ROLE}" == "Utils" ]
then
  # 別のシェルスクリプトでアップロード処理を行う
  # タイムアウトもしくは他の例外が発生すると 0 以外のシグナルが飛ぶ
  timeout -sKILL 300 bash ./script/codedeploy/upload_sourcemaps_to_bugsnag.sh

  # 0 以外のシグナルの時 = タイムアウト等による失敗した時
  if [ $? != 0 ]
  then
    # slack にエラー通知
    MESSAGE="BugsnagへのSourceMapアップロードに失敗しました…"
    RAILS_ENV=production bundle exec thor utils:slack:ping --channel "#target_channel" --message "${MESSAGE}"
    # 0 でシグナルを投げる事で例外発生時も trap で拾える様にする
    exit 0
  fi
fi

アップロード処理部分はタイムアウト処理を行いたいため別ファイルに逃がしています。

EC2 インスタンス内から http://169.254.169.254/latest/meta-data/instance-id を叩く事で INSTANCE_ID を取得しています。 それを元に ec2 にリクエストを投げ、クエリにより指定したタグの値を取得します。

$? で直前のコマンドの終了シグナルを取得できるので、正常終了の 0 以外の場合は Slack への通知を行い、 exit 0 で正常終了のシグナルを投げることで trap で検知される様にしました。 これにより Ruby における ensure の様な処理を実現し、このスクリプト内で完結する様にしています。


次に実際にアップロード処理を行っているスクリプトです。

upload_sourcemaps_to_bugsnag.sh

#! /bin/bash

source ~/.bash_profile
export AWS_CONFIG_FILE=AWS_CONFIG_FILE_PATH
cd APPLICATION_ROOT_PATH

# dotenv で管理している API KEY を取得
# dotenv が引数にコマンドかファイル以外を想定していないため `bash -c` により実行
API_KEY=$(bundle exec dotenv bash -c 'echo $BUGSNAG_JS_API_KEY') 

# EC2 インスタンスのIDを取得
INSTANCE_ID=`curl -s http://169.254.169.254/latest/meta-data/instance-id`

# EC2 インスタンスのタグから現在のインスタンスのサービス名等を取得
SERVICE_NAME=` aws ec2 describe-instances --instance-ids ${INSTANCE_ID}  --query 'Reservations[].Instances[].Tags[?Key==\`ServiceName\`].Value' --output text`
URL_PREFIX="https://${SERVICE_NAME}/"
SOURCE_DIR="./public/"

for ORIG_PATH in $(find ./public/js ./public/packs -name "*.js.map")
do
  MAP_PATH=${ORIG_PATH#*./public/}
  JS_PATH=${MAP_PATH%.map}
  # fingerprint の部分をワイルドカード指定に変更
  MINIFIED_URL=$URL_PREFIX${JS_PATH/-*.js/-*.js}

  curl https://upload.bugsnag.com/ \
   -F apiKey="$API_KEY" \
   -F minifiedUrl="$MINIFIED_URL" \
   -F sourceMap="@$SOURCE_DIR$MAP_PATH" \
   -F minifiedFile="@$SOURCE_DIR$JS_PATH" \
   -F overwrite=true
done

find コマンドで map ファイルを検索し、それぞれを以下のドキュメントに従ってアップロードしています。 ローカルのファイルパスから URL を生成しパラメータを用意します。

https://docs.bugsnag.com/api/js-source-map-upload/

URL にはワイルドカードが指定できますので、fingerprint の部分は置換えてすっきりさせています。こうすることで bugsnag の Uploaded source maps に fingerprint 違いのファイルが別々に登録されていくのを防ぐ事ができます。

ステージング環境等複数の環境から同じ Bugsnag の JSプロジェクトを使いまわしている場合は fingerprint を残しておくとそれぞれに対応した map ファイルが参照されます。

結果

f:id:lulu-ulul:20180126104259p:plain

上がソースマップが反映されたもので、下がソースマップが適用されてない状態のものです。

人間に優しい表示にすることができました!


Misoca 社では、日々開発環境を改善していく事に興味があるエンジニアを募集しています。