こんにちは、エンジニアの Bugfire です。
自分の前の記事 でも Rollbar について書きましたが、今回はフロントエンド編です。
無法地帯
バックエンド(Rails)では、Rollbar による通知は便利に活用されており、対応したりみなかったことにしていたわけですが、フロントエンドでは以下の状況にありました。
- あまりにもエラーが多い
- エラーが多すぎて課金がすごいことになるのでかなり厳しめの RateLimit をかけていた
- エラーが多すぎて slack 通知していない
- 結局エラーを誰もみていない
- SourceMap が登録されていないので、いざ登録されたエラーをみても分析が難しい
問題は、エラーが多すぎる件と SourceMap がないの二点です。それぞれ対応していきます。
目次
Rollbar の設定
まず、公式サイト rollbar.com Source Maps を参照して、設定をしていきます。
まずは以下の様な形で設定します。このファイルについては後の章で追加してきます。
var _rollbarConfig = { accessToken: "{ CLIENT ACCESS TOKEN }", captureUncaught: true, captureUnhandledRejections: true, payload: { environment: "<%= Rails.env %>", client: { javascript: { source_map_enabled: true, // true by default code_version: "{ DEPLOY VERSION }", } } }, enabled: true }; // Rollbar Snippet from https://docs.rollbar.com/docs/browser-js "{ ここに上のURLのSnippetを貼ります }"
上のURLにありますが、ブラウザのコンソールから以下を実行してエラー送信を確認できます。
window.onerror("TestRollbarError: testing window.onerror", window.location.href)
setTimeout(function() {notThere();}, 1000);
Rollbar.error('Error Message')
SourceMap
SourceMap とは JavaScript ファイルが何らかのコンパイルが行われた時、コンパイル後の指定箇所がソースファイル上のどの部分に該当するか、調べるためにあります。
- AltJS (TS, CoffeeScript), ESバージョン間変換などの JavaScript のトランスパイル
- 最適化、難読化のための Minify, Uglify
- 複数のファイルをまとめる Bundle (Webpack)
Webpack の生成する SourceMap には複数のモードがあります
Rollbar では、SourceMap をアップロードすることで、問題発生時のスタックトレースをコンパイル元ファイルに対応づけて表示することができます。
なお、クラウドワークスでは歴史的経緯により、Sprockets と Webpacker 両方を使っています。残念ですが、両方対応していきます。
Webpacker 編
まずは、webpack 側の設定です。
webpack の設定変更
config/webpack/production.js
... environment.config.merge({ devtool: 'hidden-source-map' }) ...
「SourceMap ファイル生成 (コンパイル前の完全なファイルなし)」 にあたる hidden-source-map を選択します。
AssetSync の設定変更
AssetSync.configure do |config| # -- 省略 -- # Webpackerで生成されるファイルをアップロードするようにする # https://github.com/AssetSync/asset_sync#config-method-add_local_file_paths config.add_local_file_paths do # Any code that returns paths of local asset files to be uploaded # Like Webpacker public_root = Rails.root.join("public") Dir.chdir(public_root) do packs_dir = Webpacker.config.public_output_path.relative_path_from(public_root) Dir[File.join(packs_dir, '/**/**')].select { |filename| File.extname(filename) != ".map" } end end end
SourceMap を AssetSync の同期対象から外します。
Rollbar へのアップロード
rake task を追加して CI から呼び出す様にしました。 この記事では省略しましたが、エラー処理・リトライ処理・ログ出力等があります。
namespace :rollbar_source_map do def register_rollbar_sourcemap(payload:) rollbar_end_point = 'https://api.rollbar.com/api/1/sourcemap' uri = URI.parse(rollbar_end_point); req = Net::HTTP::Post.new(uri.path) req["Content-Type"] = "application/x-www-form-urlencoded" req.set_form(payload, "multipart/form-data") res = Net::HTTP.start(uri.host, uri.port, :use_ssl => true) do |http| http.request(req) end end desc "upload source-maps to rollbar" task :rollbar_upload, ['revision'] => :environment do |task, args| public_root = Rails.root.join('public') Dir.chdir(public_root) do sourcemap_files = Dir['**/**.js.map'] sourcemap_files.each do |map_path| manified_url = "#{Rails.application.config.action_controller.asset_host}/#{map_path}".delete_suffix('.map') map_data = File.open(map_path, 'rb') begin payload = [ [ 'access_token', "{ SERVER SIDE TOKEN }" ], [ 'version', "{ DEPLOY VERSION }" ], [ 'minified_url', manified_url ], [ 'source_map', map_data, { filename: map_path, content_type: "application/json" } ] ] register_rollbar_sourcemap(payload: payload) ensure map_data.close end end end end end
これで、SourceMap を用いたオリジナルのファイル名・行数・関数名等を表示できるようになります。
Source linking
上の設定だけでは Rails 側の様にスタックトレースのリンクから GitHub 上の該当箇所に飛びません。
さりげなく 公式サイト rollbar.com Source Maps の FAQ に書かれています。
Can I still set up source linking when using source maps?
Yes, you just have to set your server.root in the _rollbarConfig object to match the prefix of your stack traces. For example, if your stack traces are prefixed by webpack:///./, try setting server.root = 'webpack:///./'.
設定ファイルを変更します。
var _rollbarConfig = { // -- 省略 --- payload: { server: { root: "webpack:///./" },
これで、webpack されているものはクリックでコードジャンプすることができるようになりました。副作用として、webpack 以外のものは non-project frames
となりデフォルトで畳まれます。
Sprockets 編
SourceMap 生成部分の追加
Sprocket のことは本当によく知らないのですが、SourceMap の生成に関しては先人の知恵を参考にしました。
Javascript sourcemaps in Rails with Sprockets 3 and Uglifier.
元々の設定で、コピーライトを残すために、rails + sprockets + uglifierでCopyrightを残したままcompressするを参考に
config.assets.js_compressor = Uglifier.new(comments: :copyright)
となっていました。
両者を合わせ uglify_with_sourcemaps を定義しました。
config.assets.js_compressor = :uglify_with_source_maps
lib/sprockets/uglifier_compressor.rb
使用している Sprocket では以下の定義になっています。
def initialize(options = {}) # Feature detect Uglifier 2.0 option support if Autoload::Uglifier::DEFAULTS[:copyright] # Uglifier < 2.x options[:copyright] ||= false else # Uglifier >= 2.x options[:comments] ||= :none end @options = options @cache_key = "#{self.class.name}:#{Autoload::Uglifier::VERSION}:#{VERSION}:#{DigestUtils.digest(options)}".freeze end def call(input) @uglifier ||= Autoload::Uglifier.new(@options) @uglifier.compile(input[:data]) end
uglify_with_source_maps
Sprocket の実装と上の二つの記事を参考に作成しました。 数点だけ変更があります
- digest のソースの変更 (sourcemap_jsonからcompressed_data)
- 出力 compresssed_data の SourceMap へのリンクを削除 (非公開なのでURIは存在しない)
require 'sprockets/digest_utils' require 'sprockets/uglifier_compressor' module Sprockets class UglifierWithSourceMapsCompressor < Sprockets::UglifierCompressor def initialize(options = {}) @options = { comments: :copyright } @cache_key = "#{self.class.name}:#{Autoload::Uglifier::VERSION}:#{VERSION}:#{DigestUtils.digest(options)}".freeze @uglifier = Autoload::Uglifier.new(@options) end def call(input) data = input.fetch(:data) name = input.fetch(:name) compressed_data, sourcemap_json = @uglifier.compile_with_map(input[:data]) sourcemap_digest = Sprockets::DigestUtils.pack_hexdigest Sprockets::DigestUtils.digest(compressed_data) sourcemap = JSON.parse(sourcemap_json) sourcemap['sources'] = ["#{name}-#{sourcemap_digest}.js"] sourcemap['sourceRoot'] = ::Rails.application.config.assets.prefix sourcemap['sourcesContent'] = [data] sourcemap_json = sourcemap.to_json sourcemap_filename = File.join( ::Rails.application.config.assets.prefix, "#{name}-#{sourcemap_digest}.js.map" ) sourcemap_path = File.join(::Rails.public_path, sourcemap_filename) FileUtils.mkdir_p File.dirname(sourcemap_path) File.open(sourcemap_path, 'w') { |f| f.write sourcemap_json } compressed_data end end end Sprockets.register_compressor( 'application/javascript', :uglify_with_source_maps, Sprockets::UglifierWithSourceMapsCompressor )
Rollbar へのアップロード
これは、ファイルさえ生成すれば Webpacker 側で記述した SourceMap をアップロードするためのコードで同時にアップロードされるので何もする必要はありません。
問題点
Sprockets の bundle システム (require コメントを用いたグローバル include) の後でこのソースを通るので、SourceMap は bundle されたものになります。
具体的には、JavaScript もしくは CoffeeScript から変換された多数の JavaScript を一つに合成した、数万行の JavaScript ファイルになっています。
幸い、bundle されたファイルは 「SourceMap ファイル生成 (コンパイル前の完全なファイルあり)」 のため SourceMap ファイル上にシリアライズされているため、Rollbar のサイトから SourceMap ファイル名を元に検索を行いダウンロードして抽出することができます。
$ jq -r '.sourcesContent[0]' 巨大.js.map > 巨大.js
コードの断片から元のファイルを推定して、対応する元ファイルの問題箇所を特定する必要がありますが、ノーヒントよりは楽です。
「エラーが多すぎる件」対策
強力な RateLimit がかかっていて、バックエンドの数十倍の課金が発生することが予想されるため、RateLimit はうかつに変更できません。
Rollbar に登録されているエラーは、ランダムにサンプリングされた一部ですので、分布は全体と近似していると予想します。つまり、多いものから対処をする。いつもと同じです。
XXX is not defined
よくあるやつです。大抵はいわゆるぬるぽです。多用されている jQuery は、クエリ結果へのオペレーションは無効果になることが多いですが、プロパティをみている部分はすぐ死にます。
よくある修正は、順を追うと
- 検索した DOM が見つかっていないのにプロパティをみているところ
- 可能なら、コード上から再現する状況を推定し、エラーを起こす
- そのプロパティが存在しないことが正常だと確認が取れた上で、そのコードパスを回避する
のような形になります。
外部ライブラリに起因するケース
jQuery の Plugin や、トラッキング用のコードなどです。 動作上の問題が報告されていなければ、ファイルパスを検査してエラーを無視する方向にします。
ブラウザ側のプラグイン等に起因するケース
スタックトレース上のファイル名が http, https でないスキーマでエラーが飛んでくることがあります。やはり無視します。
Rollbar で特定エラーを無視する設定
無視をするには複数の方法があります。
- Rollbar.js のコード上でフィルタを行う
- Rollbar.com で Mute を行う
後者に関しては、前回の記事の方法が中心になりますが、Rollbar の課金に影響を及ぼすので、できる限り前者で対応を行います。
Reduce Noisy Javascript Errors に Rollbar のフロントエンド側でのフィルタリングについて記述があります。
関数 checkIgnore(isUncaught, args, payload) を定義し、引数に応じて true を返すことで、エラー報告はサーバに報告されず無視されます。引数の意味は以下の通りです。
- isUncaught: catch されていない例外の場合に true
- args: handle されていない Promise の場合の Promise オブジェクト
- payload: Rollbar API で送信されるオブジェクト
var _rollbarConfig = { checkIgnore: function(isUncaught, args, payload) { return false; }
以下は、ユーティリティ関数です。特定の文字列が配列中に含まれているか、配列中のどれかの要素と完全一致するか、の関数です。
var containsInArray = function (arr, txt) { for (var i = 0; i < arr.length; i++) { if (txt.indexOf(arr[i]) >= 0) { return true; } } return false; }; var matchInArray = function (arr, txt) { return arr.indexOf(txt) >= 0; };
botにより発生したエラーのケース
エラーを発生しないbotであれば良いのですが、エラーが発生する場合は特定UserAgentを持つ全ての報告を例外かどうかを問わず無視するようにします。
var isIgnoredUserAgents = function () { var ignoreUserAgents = [ 'xxxx', ]; var userAgent = window.navigator.userAgent; if (userAgent && containsInArray(ignoreUserAgents, userAgent)) { return true; } return false; }; if (isIgnoredUserAgents()) { return true; }
例外以外のエラーのケース
例外で飛んできたエラーではなく、コード上の報告は基本的に無視しません。
コード上の報告でも無視したい場合は、上のbotのエラーのように、この行より前に判断を行います。
if (!isUncaught) { return false; }
手がかりが全くないケース
スタックトレースがすべてファイル名・行数が取れないなら諦めて捨てます。発生 URL はわかりますが、流石にそれだけだとどうしようもないので無視します。
雰囲気から、ソース中にない投入されたスクリプトである可能性があるケースもあります。
また、普通に発生するなら、きっちりスタックトレースが取れるエラーもあるはずなので、そちらを参照すれば済むと思っています。
var message = payload.body.trace.exception.message; var errorClass = payload.body.trace.exception.class; var frames = payload.body.trace.frames; var isUnknownError = function () { for (var i = 0; i < frames.length; i++) { var frame = frames[i]; if (frame.filename === '(unknown)' || frame.filename === 'Unknown script code') { continue; } if (frame.method === '[anonymous]' && (frame.lineno === null || frame.lineno === -1)) { continue; } return false; } return true; };
未知の Schema が存在するケース
safari-extension://
, chrome-extension://
, file://
等、我々のサービスから提供されていない Schema が入っている例外は全て無視します。
var isInjectError = function () { var validSchemata = [ 'http:', 'https:', ]; for (var i = 0; i < frames.length; i++) { var m = frames[i].filename.match(/^[^/:]+:/) if (m && validSchemata.indexOf(m[0]) < 0) { return true; } } return false; }
外部スクリプトのケース
各種外部 snippet は意外にエラーを吐きます。
var containsIgnoreFilename = function () { var ignoreFilenames = [ 'https://www.googletagmanager.com/', ]; for (var i = 0; i < frames.length; i++) { var filename = frames[i].filename; if (containsInArray(ignoreFilenames, filename)) { return true; } } return false; };
スクリプトロードエラーのケース
広告ブロック等でカジュアルにロードエラーが発生します。もし一般的に発生するロードエラーであれば、他の箇所での報告やE2Eテスト等で判明すると思うので無視します。
var matchIgnoreMessageOnSingle = function () { var ignoreMessages = [ 'Error loading script', ]; if (frames.length === 1) { if (matchInArray(ignoreMessages, message)) { return true; } } return false; };
謎の Injection エラーのケース
var containsIgnoreMessage = function () { var ignoreMessages = [ 'MyApp_RemoveAllHighlights', ]; if (containsInArray(ignoreMessages, message)) { return true; } return false; };
ぐぐると、Highlight the text in web viewでドンピシャな例が存在するので、どこかの WebView での Injection のサンプルでコードのゴミが生き残っているのではないかと思われます。
エラー監視に使われる Sentryの JavaScript クライアント raven.js のサンプル設定にも言及があります。 https://raven-js.readthedocs.io/en/stable/tips.html
最後に
バックエンド側は長期間 Rollbar での監視が継続されており、いま発生しているエラーは解消が難しいもの、もしくは最近発生したものです。
フロントエンド側は監視が行われていなかったため、一つ一つは些細なバグなのですが、とにかく量が多く、メンテされていないライブラリ、古いブラウザに起因するもの等があり、大変です。
今回の変更にともない、フロントエンド側のコードの更新と併走して整備してきました。協力してくれた方に感謝しています!
道は途上ですが、クラウドワークスのフロントエンドをあらゆる面から強化していきたいと思っています。
We Are Hiring!
クラウドワークスでは DevOps が好きなエンジニアを募集しています!