diff --git a/README.md b/README.md index c20c808c206..1584716894b 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ We have recently changed the destination service to which these metrics are repo You can easily opt-out of metrics collection by adding `opt_out_usage` at the top of your `Fastfile` or by setting the environment variable `FASTLANE_OPT_OUT_USAGE`. Participating helps us provide the best possible support for _fastlane_, so we hope you'll consider it a plus! :heavy_plus_sign: +## Crash Reporting + +In order to continuously improve stability, _fastlane_ will record crash reports with sanitized stacktraces. Sanitization removes personal information from the stacktrace and error message (including home directories, _fastlane_ path, gem paths, environment variables, and parameters). + +You can easily opt-out of crash reporting by adding `opt_out_crash_reporting` at the top of your `Fastfile` or by setting the environment variable `FASTLANE_OPT_OUT_CRASH_REPORTING`. Just like metrics mentioned above, participating helps us provide the best possible support for _fastlane_, so we hope you'll consider it a plus! :heavy_plus_sign: + ## Need Help? Please [submit an issue](https://github.com/fastlane/fastlane/issues) on GitHub and provide information about your setup. diff --git a/fastlane/lib/fastlane/actions/opt_out_crash_reporting.rb b/fastlane/lib/fastlane/actions/opt_out_crash_reporting.rb new file mode 100644 index 00000000000..25b15fcf541 --- /dev/null +++ b/fastlane/lib/fastlane/actions/opt_out_crash_reporting.rb @@ -0,0 +1,41 @@ +module Fastlane + module Actions + class OptOutCrashReportingAction < Action + def self.run(params) + ENV['FASTLANE_OPT_OUT_CRASH_REPORTING'] = "YES" + UI.message("Disabled crash reporting") + end + + def self.description + "This will prevent reports from being uploaded when _fastlane_ crashes" + end + + def self.details + [ + "By default, fastlane will send a report when it crashes", + "The stacktrace is sanitized so no personal information is sent.", + "Learn more at https://github.com/fastlane/fastlane#crash-reporting", + "Add `opt_out_crash_reporting` at the top of your Fastfile to disable crash reporting" + ].join(' ') + end + + def self.authors + ['mpirri', 'ohayon'] + end + + def self.is_supported?(platform) + true + end + + def self.example_code + [ + 'opt_out_crash_reporting # add this to the top of your Fastfile' + ] + end + + def self.category + :misc + end + end + end +end diff --git a/fastlane/spec/cli_tools_distributor_spec.rb b/fastlane/spec/cli_tools_distributor_spec.rb index 37b16df010d..d6e9b2551ab 100644 --- a/fastlane/spec/cli_tools_distributor_spec.rb +++ b/fastlane/spec/cli_tools_distributor_spec.rb @@ -21,6 +21,10 @@ end describe "update checking" do + before do + allow(FastlaneCore::CrashReporter).to receive(:report_crash) + end + it "checks for updates when running a lane" do FastlaneSpec::Env.with_ARGV(["sigh"]) require 'fastlane/commands_generator' diff --git a/fastlane_core/lib/fastlane_core.rb b/fastlane_core/lib/fastlane_core.rb index 9db7a99cce0..7ab36782160 100644 --- a/fastlane_core/lib/fastlane_core.rb +++ b/fastlane_core/lib/fastlane_core.rb @@ -31,6 +31,9 @@ require 'fastlane_core/keychain_importer' require 'fastlane_core/swag' require 'fastlane_core/build_watcher' +require 'fastlane_core/crash_reporter/crash_reporter' +require 'fastlane_core/crash_reporter/crash_report_generator' +require 'fastlane_core/crash_reporter/backtrace_sanitizer' # Third Party code require 'colored' diff --git a/fastlane_core/lib/fastlane_core/crash_reporter/backtrace_sanitizer.rb b/fastlane_core/lib/fastlane_core/crash_reporter/backtrace_sanitizer.rb new file mode 100644 index 00000000000..353d3aa0d84 --- /dev/null +++ b/fastlane_core/lib/fastlane_core/crash_reporter/backtrace_sanitizer.rb @@ -0,0 +1,42 @@ +module FastlaneCore + class BacktraceSanitizer + class << self + def sanitize(type: :unknown, backtrace: nil) + if type == :user_error || type == :crash + # If the crash is from `UI` we only want to include the stack trace + # up to the point where the crash was initiated. + # The two stack frames we are dropping are `method_missing` and + # the call to `crash!` or `user_error!`. + stack = backtrace.drop(2) + else + stack = backtrace + end + + stack = remove_fastlane_gem_path(backtrace: stack) + stack = remove_gem_home_path(backtrace: stack) + remove_home_dir_mentions(backtrace: stack) + end + + def remove_fastlane_gem_path(backtrace: nil) + fastlane_path = Gem.loaded_specs['fastlane'].full_gem_path + return backtrace unless fastlane_path + backtrace.map do |frame| + frame.gsub(fastlane_path, '[fastlane_path]') + end + end + + def remove_gem_home_path(backtrace: nil) + return backtrace unless Gem.dir + backtrace.map do |frame| + frame.gsub(Gem.dir, '[gem_home]') + end + end + + def remove_home_dir_mentions(backtrace: nil) + backtrace.map do |frame| + frame.gsub(Dir.home, '~') + end + end + end + end +end diff --git a/fastlane_core/lib/fastlane_core/crash_reporter/crash_report_generator.rb b/fastlane_core/lib/fastlane_core/crash_reporter/crash_report_generator.rb new file mode 100644 index 00000000000..3bdf27264a5 --- /dev/null +++ b/fastlane_core/lib/fastlane_core/crash_reporter/crash_report_generator.rb @@ -0,0 +1,49 @@ +module FastlaneCore + class CrashReportGenerator + class << self + def types + { + user_error: '[USER_ERROR]', + crash: '[FASTLANE_CRASH]', + exception: '[EXCEPTION]', + connection_failure: '[CONNECTION_FAILURE]', + system: '[SYSTEM_ERROR]', + option_parser: '[OPTION_PARSER]', + invalid_command: '[INVALID_COMMAND]', + unknown: '[UNKNOWN]' + } + end + + def generate(type: :unknown, exception: nil) + message = crash_report_message(type: type, exception: exception) + crash_report_payload(message: message) + end + + private + + def crash_report_message(type: :unknown, exception: nil) + backtrace = FastlaneCore::BacktraceSanitizer.sanitize(type: type, backtrace: exception.backtrace).join("\n") + message = types[type] + if type == :user_error + message += ': ' + else + message += ": #{exception.message}" + end + message = message[0..100] + message += "\n" unless type == :user_error + message + backtrace + end + + def crash_report_payload(message: '') + { + 'eventTime' => Time.now.utc.to_datetime.rfc3339, + 'serviceContext' => { + 'service' => 'fastlane', + 'version' => Fastlane::VERSION + }, + 'message' => message + }.to_json + end + end + end +end diff --git a/fastlane_core/lib/fastlane_core/crash_reporter/crash_reporter.rb b/fastlane_core/lib/fastlane_core/crash_reporter/crash_reporter.rb new file mode 100644 index 00000000000..968bd7c08cf --- /dev/null +++ b/fastlane_core/lib/fastlane_core/crash_reporter/crash_reporter.rb @@ -0,0 +1,58 @@ +require 'faraday' +require 'json' + +module FastlaneCore + class CrashReporter + class << self + def crash_report_path + File.join(FastlaneCore.fastlane_user_dir, 'latest_crash.json') + end + + def enabled? + !FastlaneCore::Env.truthy?("FASTLANE_OPT_OUT_CRASH_REPORTING") + end + + def report_crash(type: :unknown, exception: nil) + return unless enabled? + payload = CrashReportGenerator.generate(type: type, exception: exception) + send_report(payload: payload) + save_file(payload: payload) + show_message unless did_show_message? + end + + private + + def show_message + UI.message("Sending crash report...") + UI.message("The stacktrace is sanitized so no personal information is sent.") + UI.message("To see what we are sending, look here: #{crash_report_path}") + UI.message("Learn more at https://github.com/fastlane/fastlane#crash-reporting") + UI.message("You can disable crash reporting by adding `opt_out_crash_reporting` at the top of your Fastfile") + end + + def did_show_message? + file_name = ".did_show_opt_out_crash_info" + + path = File.join(FastlaneCore.fastlane_user_dir, file_name) + did_show = File.exist?(path) + + return did_show if did_show + + File.write(path, '1') + false + end + + def save_file(payload: "{}") + File.write(crash_report_path, payload) + end + + def send_report(payload: "{}") + connection = Faraday.new(url: "https://clouderrorreporting.googleapis.com/v1beta1/projects/fastlane-166414/events:report?key=AIzaSyAMACPfuI-wi4grJWEZjcPvhfV2Rhmddwo") + connection.post do |request| + request.headers['Content-Type'] = 'application/json' + request.body = payload + end + end + end + end +end diff --git a/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb b/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb index fdfd49c5dc7..b7aa66a93de 100644 --- a/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb +++ b/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb @@ -48,6 +48,7 @@ def run! if FastlaneCore::Helper.test? raise e else + FastlaneCore::CrashReporter.report_crash(type: :invalid_command, exception: e) abort "#{e}. Use --help for more information" end rescue Interrupt => e @@ -66,11 +67,13 @@ def run! if FastlaneCore::Helper.test? raise e else + FastlaneCore::CrashReporter.report_crash(type: :option_parser, exception: e) abort e.to_s end rescue FastlaneCore::Interface::FastlaneError => e # user_error! collector.did_raise_error(@program[:name]) show_github_issues(e.message) if e.show_github_issues + FastlaneCore::CrashReporter.report_crash(type: :user_error, exception: e) display_user_error!(e, e.message) rescue Errno::ENOENT => e # We're also printing the new-lines, as otherwise the message is not very visible in-between the error and the stacktrace @@ -78,6 +81,7 @@ def run! FastlaneCore::UI.important("Error accessing file, this might be due to fastlane's directory handling") FastlaneCore::UI.important("Check out https://docs.fastlane.tools/advanced/#directory-behavior for more details") puts "" + FastlaneCore::CrashReporter.report_crash(type: :system, exception: e) raise e rescue FastlaneCore::Interface::FastlaneTestFailure => e # test_failure! display_user_error!(e, e.to_s) @@ -87,9 +91,12 @@ def run! if e.message.include? 'Connection reset by peer - SSL_connect' handle_tls_error!(e) else + FastlaneCore::CrashReporter.report_crash(type: :connection_failure, exception: e) handle_unknown_error!(e) end rescue => e # high chance this is actually FastlaneCore::Interface::FastlaneCrash, but can be anything else + type = e.kind_of?(FastlaneCore::Interface::FastlaneCrash) ? :crash : :exception + FastlaneCore::CrashReporter.report_crash(type: type, exception: e) collector.did_crash(@program[:name]) handle_unknown_error!(e) ensure diff --git a/fastlane_core/spec/crash_reporter/backtrace_sanitizer_spec.rb b/fastlane_core/spec/crash_reporter/backtrace_sanitizer_spec.rb new file mode 100644 index 00000000000..d697eeb3e93 --- /dev/null +++ b/fastlane_core/spec/crash_reporter/backtrace_sanitizer_spec.rb @@ -0,0 +1,51 @@ +describe FastlaneCore::BacktraceSanitizer do + context 'path sanitization' do + let(:fastlane_spec) { double('Gem::Specification') } + + it 'returns a trace with fastlane path sanitized' do + expect(Gem).to receive(:loaded_specs).and_return({ + 'fastlane' => fastlane_spec + }) + expect(fastlane_spec).to receive(:full_gem_path).at_least(1).and_return('/path/to/fastlane') + expect(FastlaneCore::BacktraceSanitizer.sanitize( + backtrace: ['/path/to/fastlane/source/file'] + )).to eq(['[fastlane_path]/source/file']) + end + + it 'returns a trace with gem home sanitized' do + expect(Gem).to receive(:dir).at_least(1).and_return('/path/to/gem') + expect(FastlaneCore::BacktraceSanitizer.sanitize( + backtrace: ['/path/to/gem/source/file'] + )).to eq(['[gem_home]/source/file']) + end + + it 'returns a trace with home directory sanitized' do + expect(Dir).to receive(:home).at_least(1).and_return('/path/to/home_dir') + expect(FastlaneCore::BacktraceSanitizer.sanitize( + backtrace: ['/path/to/home_dir/source/file'] + )).to eq(['~/source/file']) + end + end + + context 'stack frame dropping' do + it 'drops the first two frames from crashes' do + expect(FastlaneCore::BacktraceSanitizer.sanitize( + type: :crash, + backtrace: ['frame0', 'frame1', 'frame2'] + )).to eq(['frame2']) + end + + it 'drops the first two frames from user errors' do + expect(FastlaneCore::BacktraceSanitizer.sanitize( + type: :user_error, + backtrace: ['frame0', 'frame1', 'frame2'] + )).to eq(['frame2']) + end + + it 'drops no frames from other errors' do + expect(FastlaneCore::BacktraceSanitizer.sanitize( + backtrace: ['frame0', 'frame1', 'frame2'] + )).to eq(['frame0', 'frame1', 'frame2']) + end + end +end diff --git a/fastlane_core/spec/crash_reporter/crash_report_generator_spec.rb b/fastlane_core/spec/crash_reporter/crash_report_generator_spec.rb new file mode 100644 index 00000000000..19d8c4b398a --- /dev/null +++ b/fastlane_core/spec/crash_reporter/crash_report_generator_spec.rb @@ -0,0 +1,61 @@ +describe FastlaneCore::CrashReportGenerator do + context 'generate crash report' do + let(:exception) do + double( + 'Exception', + backtrace: [ + 'path/to/fastlane/line/that/crashed', + 'path/to/fastlane/line/that/called/the/crash' + ], + message: 'message goes here' + ) + end + + let(:expected_body) do + { + 'eventTime' => '0000-01-01T00:00:00+00:00', + 'serviceContext' => { + 'service' => 'fastlane', + 'version' => '2.29.0' + }, + 'message' => "" + } + end + + before do + allow(Time).to receive(:now).and_return(Time.utc(0)) + end + + it 'omits a message for type user_error' do + setup_sanitizer_expectation(type: :user_error) + setup_expected_body(type: :user_error, message_text: ": ") + expect(FastlaneCore::CrashReportGenerator.generate(type: :user_error, exception: exception)).to eq(expected_body.to_json) + end + + it 'includes a message for other types' do + setup_sanitizer_expectation + setup_expected_body(message_text: ": #{exception.message}\n") + expect(FastlaneCore::CrashReportGenerator.generate(exception: exception)).to eq(expected_body.to_json) + end + + it 'includes stack frames in message' do + setup_sanitizer_expectation + setup_expected_body(message_text: ": #{exception.message}\n") + report = JSON.parse(FastlaneCore::CrashReportGenerator.generate(exception: exception)) + expect(report['message']).to include(exception.backtrace.join("\n")) + end + end +end + +def setup_sanitizer_expectation(type: :unknown) + expect(FastlaneCore::BacktraceSanitizer).to receive(:sanitize).with( + type: type, + backtrace: exception.backtrace + ) do |args| + args[:backtrace] + end +end + +def setup_expected_body(type: :unknown, message_text: "") + expected_body['message'] = "#{FastlaneCore::CrashReportGenerator.types[type]}#{message_text}#{exception.backtrace.join("\n")}" +end diff --git a/fastlane_core/spec/crash_reporter/crash_reporter_spec.rb b/fastlane_core/spec/crash_reporter/crash_reporter_spec.rb new file mode 100644 index 00000000000..c766d89bcc7 --- /dev/null +++ b/fastlane_core/spec/crash_reporter/crash_reporter_spec.rb @@ -0,0 +1,92 @@ +describe FastlaneCore::CrashReporter do + context 'crash reporting' do + let(:exception) { double('Exception') } + + let(:stub_body) do + { + 'key' => 'value' + } + end + + context 'post reports to Stackdriver' do + before do + silence_ui_output + supress_crash_report_file_writing + supress_opt_out_crash_reporting_file_writing + end + + it 'posts a report to Stackdriver without specified type' do + stub_stackdriver_request + setup_crash_report_generator_expectation + FastlaneCore::CrashReporter.report_crash(exception: exception) + end + + it 'posts a report to Stackdriver with specified type' do + stub_stackdriver_request(type: :crash) + setup_crash_report_generator_expectation(type: :crash) + FastlaneCore::CrashReporter.report_crash(type: :crash, exception: exception) + end + end + + context 'opted out of crash reporting' do + before do + silence_ui_output + supress_opt_out_crash_reporting_file_writing + supress_crash_report_file_writing + end + + it 'does not post a report to Stackdriver if opted out' do + ENV['FASTLANE_OPT_OUT_CRASH_REPORTING'] = '1' + assert_not_requested(stub_stackdriver_request) + end + + after do + ENV['FASTLANE_OPT_OUT_CRASH_REPORTING'] = nil + end + end + + context 'write report to file' do + before do + silence_ui_output + supress_stackdriver_reporting + setup_crash_report_generator_expectation + supress_opt_out_crash_reporting_file_writing + end + + it 'writes a file with the json payload' do + expect(File).to receive(:write).with(FastlaneCore::CrashReporter.crash_report_path, stub_body.to_json) + + FastlaneCore::CrashReporter.report_crash(exception: exception) + end + end + end +end + +def silence_ui_output + allow(UI).to receive(:message) +end + +def supress_opt_out_crash_reporting_file_writing + allow(File).to receive(:write) +end + +def supress_crash_report_file_writing + allow(File).to receive(:write).with(FastlaneCore::CrashReporter.crash_report_path, stub_body.to_json) +end + +def supress_stackdriver_reporting + stub_stackdriver_request +end + +def setup_crash_report_generator_expectation(type: :unknown) + expect(FastlaneCore::CrashReportGenerator).to receive(:generate).with( + type: type, + exception: exception + ).and_return(stub_body.to_json) +end + +def stub_stackdriver_request(type: :unknown) + stub_request(:post, %r{https:\/\/clouderrorreporting.googleapis.com\/v1beta1\/projects\/fastlane-166414\/events:report\?key=.*}).with do |request| + request.body == stub_body.to_json + end +end diff --git a/fastlane_core/spec/fastlane_runner_spec.rb b/fastlane_core/spec/fastlane_runner_spec.rb index 7d53b780b59..77360ba8746 100644 --- a/fastlane_core/spec/fastlane_runner_spec.rb +++ b/fastlane_core/spec/fastlane_runner_spec.rb @@ -39,6 +39,7 @@ def run before(:each) do allow(Commander::Runner).to receive(:instance).and_return(Commander::Runner.new) expect(FastlaneCore::ToolCollector).to receive(:new).and_return(mock_tool_collector) + allow(FastlaneCore::CrashReporter).to receive(:report_crash) end it "calls the tool collector lifecycle methods for a successful run" do