クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

Rollbar による快適通知生活 (フロントエンド編)

こんにちは、エンジニアの 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 には複数のモードがあります

  1. SourceMap ファイル生成 (コンパイル前の完全なファイルなし)
  2. SourceMap ファイル生成 (コンパイル前の完全なファイルあり)
  3. 元ファイルに 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 となりデフォルトで畳まれます。

f:id:bugfire:20200617184223p:plain
rollbar.com でのスタックトレース表示

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 のサンプルでコードのゴミが生き残っているのではないかと思われます。

エラー監視に使われる SentryJavaScript クライアント raven.js のサンプル設定にも言及があります。 https://raven-js.readthedocs.io/en/stable/tips.html

最後に

バックエンド側は長期間 Rollbar での監視が継続されており、いま発生しているエラーは解消が難しいもの、もしくは最近発生したものです。

フロントエンド側は監視が行われていなかったため、一つ一つは些細なバグなのですが、とにかく量が多く、メンテされていないライブラリ、古いブラウザに起因するもの等があり、大変です。

今回の変更にともない、フロントエンド側のコードの更新と併走して整備してきました。協力してくれた方に感謝しています!

道は途上ですが、クラウドワークスのフロントエンドをあらゆる面から強化していきたいと思っています。

We Are Hiring!

クラウドワークスでは DevOps が好きなエンジニアを募集しています!

www.wantedly.com

© 2016 CrowdWorks, Inc., All rights reserved.