spec/lib/gdk/telemetry_spec.rb (394 lines of code) (raw):

# frozen_string_literal: true require 'fileutils' require 'gitlab-sdk' require 'sentry-ruby' require 'snowplow-tracker' # rubocop:disable RSpec/ExpectInHook RSpec.describe GDK::Telemetry, :with_telemetry do include ShelloutHelper let(:git_email) { 'cool-contributor@gmail.com' } let(:profile_output) { '' } before do sh = gdk_shellout_double(run: git_email) allow_gdk_shellout_command(%w[git config --get user.email]).and_return(sh) sh = gdk_shellout_double(read_stdout: profile_output) allow(sh).to receive(:execute).and_return(sh) allow_gdk_shellout_command(%w[profiles status -type enrollment]).and_return(sh) end describe '.with_telemetry' do let(:command) { 'test_command' } let(:args) { %w[arg1 arg2] } let(:telemetry_enabled) { true } let(:asdf?) { true } let(:mise?) { false } let(:client) { double('Client') } # rubocop:todo RSpec/VerifiedDoubles before do expect(described_class).to receive_messages(telemetry_enabled?: telemetry_enabled) expect(described_class).to receive(:with_telemetry).and_call_original allow(GDK).to receive_message_chain(:config, :telemetry, :username).and_return('testuser') allow(GDK).to receive_message_chain(:config, :telemetry, :environment).and_return('native') allow(GDK).to receive_message_chain(:config, :asdf, :opt_out?).and_return(!asdf?) allow(GDK).to receive_message_chain(:config, :mise, :enabled?).and_return(mise?) allow(described_class).to receive(:enabled_services).and_return(%w[mocked_service1 mocked_service2]) allow(described_class).to receive_messages(client: client) stub_const('ARGV', args) end context 'when telemetry is not enabled' do let(:telemetry_enabled) { false } it 'does not track telemetry and directly yields the block' do expect { |b| described_class.with_telemetry(command, &b) }.to yield_control end end it 'tracks the finish of the command' do expect(client).to receive(:identify).with('testuser') expect(client).to receive(:track).with(a_string_starting_with('Finish'), hash_including(:duration, :environment, :platform, :architecture, :version_manager, :team_member, :enabled_services, :session_id, :cpu_count)) described_class.with_telemetry(command) { true } end context 'when the block returns false' do it 'tracks the failure of the command' do expect(client).to receive(:identify).with('testuser') expect(client).to receive(:track).with(a_string_starting_with('Failed'), hash_including(:duration, :environment, :platform, :architecture, :version_manager, :team_member, :enabled_services, :session_id, :cpu_count)) described_class.with_telemetry(command) { false } end end context 'when the block raises an error' do it 'tracks the failure of the command' do expect(client).to receive(:identify).with('testuser') expect(client).to receive(:track).with(a_string_starting_with('Failed'), hash_including(:duration, :environment, :platform, :architecture, :version_manager, :team_member, :session_id, :cpu_count)) expect do described_class.with_telemetry(command) { raise 'Test error' } end.to raise_error('Test error') end end describe 'payload' do let(:payload) do payload = nil allow(client).to receive(:identify).with('testuser') allow(client).to receive(:track) do |_, received| payload = received nil end described_class.with_telemetry(command) { false } payload end describe 'version_manager' do it { expect(payload[:version_manager]).to eq('asdf') } context 'when opting out of asdf' do let(:asdf?) { false } it { expect(payload[:version_manager]).to eq('none') } end context 'when mise is enabled' do let(:asdf?) { false } let(:mise?) { true } it { expect(payload[:version_manager]).to eq('mise') } end end end end describe '.client' do let(:mocked_client) { instance_double(GitlabSDK::Client) } let(:ci) { false } before do stub_env('CI', ci ? '1' : nil) described_class.instance_variable_set(:@client, nil) allow(GitlabSDK::Client).to receive_messages(new: mocked_client) end after do described_class.instance_variable_set(:@client, nil) end it 'initializes the gitlab sdk client with the production configuration' do expect(SnowplowTracker::LOGGER).to receive(:level=).with(Logger::WARN) expect(GitlabSDK::Client).to receive(:new).with( app_id: described_class::ANALYTICS_APP_ID, host: described_class::ANALYTICS_BASE_URL, buffer_size: 10 ).and_return(mocked_client) described_class.client end context 'when in CI' do let(:ci) { true } it 'initializes the gitlab sdk client with the CI configuration' do expect(GitlabSDK::Client).to receive(:new).with( app_id: described_class::CI_ANALYTICS_APP_ID, host: described_class::ANALYTICS_BASE_URL, buffer_size: 10 ).and_return(mocked_client) described_class.client end end context 'when client is already initialized' do before do described_class.instance_variable_set(:@client, mocked_client) end it 'returns the existing client without reinitializing' do expect(GitlabSDK::Client).not_to receive(:new) expect(described_class.client).to eq(mocked_client) end end end describe '.flush_events' do let(:mocked_client) { instance_double(GitlabSDK::Client) } before do described_class.instance_variable_set(:@client, nil) allow(described_class).to receive(:client).and_return(mocked_client) allow(mocked_client).to receive(:flush_events) end after do described_class.instance_variable_set(:@client, nil) end context 'when telemetry endpoint is not reachable' do let(:telemetry_host) { 'example.com' } before do allow(Timeout).to receive(:timeout).and_raise(Timeout::Error) allow(described_class).to receive(:telemetry_host).and_return(telemetry_host) end it 'shows a warning message' do expect(GDK::Output).to receive(:warn).with("Could not flush telemetry events within #{GDK::Telemetry::FLUSH_TIMEOUT_SECONDS} seconds. Is #{telemetry_host} blocked or unreachable?") described_class.flush_events end end end describe '.init_sentry' do let(:config) { instance_double(Sentry::Configuration) } it 'initializes the sentry client with expected values' do allow(Sentry).to receive(:init).and_yield(config) allow(Sentry).to receive(:set_user) allow(GDK).to receive_message_chain(:config, :telemetry, :username).and_return('testuser') expect(config).to receive(:dsn=).with('https://4e771163209528e15a6a66a6e674ddc3@new-sentry.gitlab.net/38') expect(config).to receive(:breadcrumbs_logger=).with([:sentry_logger]) expect(config).to receive(:traces_sample_rate=).with(1.0) expect(config).to receive_message_chain(:logger, :level=).with(Logger::WARN) expect(config).to receive(:server_name) expect(config).to receive(:server_name=).with(/\A\h{16}\z/) expect(config).to receive(:before_send=).with(kind_of(Proc)) expect(Sentry).to receive(:set_user).with({ username: 'testuser' }) described_class.init_sentry end end describe '.telemetry_enabled?' do [true, false].each do |value| context "when #{value}" do it "returns #{value}" do expect(GDK).to receive_message_chain(:config, :telemetry, :enabled).and_return(value) expect(described_class.telemetry_enabled?).to eq(value) end end end end describe '.team_member?' do let(:macos) { false } let(:linux) { false } subject { described_class.team_member? } before do described_class.remove_instance_variable(:@team_member) if described_class.instance_variable_defined?(:@team_member) allow(GDK::Machine).to receive_messages(macos?: macos, linux?: linux) end after do described_class.remove_instance_variable(:@team_member) if described_class.instance_variable_defined?(:@team_member) end it { is_expected.to be(false) } context 'when using an @gitlab.com email in Git' do let(:git_email) { 'tanuki@gitlab.com' } it { is_expected.to be(true) } end context 'when on MacOS' do let(:macos) { true } let(:profile_output) do <<~JAMF Enrolled via DEP: Yes MDM enrollment: Yes (User Approved) MDM server: #{server} JAMF end context 'when is enrolled in GitLab jamf' do let(:server) { 'https://gitlab.jamfcloud.com/mdm/ServerURL' } it { is_expected.to be(true) } end context 'when is enrolled somewhere else' do let(:server) { 'https://something.jamfcloud.com/mdm/ServerURL' } it { is_expected.to be(false) } end end context 'when on Linux' do let(:linux) { true } let(:hostname) { 'somehost' } let(:zoom_config) { Pathname(Dir.home).join('.config/zoomus.conf') } let(:zoom_content) { nil } before do allow(Etc).to receive(:uname).and_return({ nodename: hostname }) allow(File).to receive(:exist?).with(zoom_config).and_return(zoom_content) allow(File).to receive(:foreach).with(zoom_config).and_return(zoom_content.to_s.lines) if zoom_content end it { is_expected.to be(false) } context 'with hostname standard' do let(:hostname) { 'foo--20250101-XYZ12' } it { is_expected.to be(true) } end context 'with zoom config' do context 'when GitLab related' do let(:zoom_content) do <<~CONFIG key=value conf.webserver.vendor.default=https://gitlab.zoom.us foo=bar CONFIG end it { is_expected.to be(true) } end context 'when GitLab unrelated' do let(:zoom_content) do <<~CONFIG key=value conf.webserver.vendor.default=https://zoom.us foo=bar CONFIG end it { is_expected.to be(false) } end end end end describe '.update_settings' do let(:generated_username) { SecureRandom.hex } before do allow(GDK.config).to receive_message_chain(:telemetry, :enabled).and_return(enabled) end context "when answer is 'y'" do let(:answer) { 'y' } before do allow(GDK.config).to receive_message_chain(:telemetry, :username).and_return(existing_username) end context 'and telemetry is already enabled' do let(:enabled) { true } context 'with already anonymized username' do let(:existing_username) { 'a' * 32 } it 'keeps telemetry enabled and does not change the username' do expect(GDK.config).not_to receive(:bury!).with('telemetry.enabled', anything) expect(GDK.config).not_to receive(:bury!).with('telemetry.username', anything) described_class.update_settings(answer) end end context 'with non-anonymized username' do let(:existing_username) { 'test_user' } before do allow(SecureRandom).to receive(:hex).and_return(generated_username) end it 'keeps telemetry enabled but anonymizes username' do expect(GDK.config).not_to receive(:bury!).with('telemetry.enabled', anything) expect(GDK.config).to receive(:bury!).with('telemetry.username', generated_username) expect(GDK.config).to receive(:save_yaml!) expect(GDK::Output).to receive(:info).with('Telemetry username has been anonymized.') described_class.update_settings(answer) end end end context 'when telemetry is currently disabled' do let(:enabled) { false } let(:existing_username) { 'test_user' } before do allow(SecureRandom).to receive(:hex).and_return(generated_username) end it 'enables telemetry and sets anonymized username' do expect(GDK.config).to receive(:bury!).with('telemetry.enabled', true) expect(GDK.config).to receive(:bury!).with('telemetry.username', generated_username) expect(GDK.config).to receive(:save_yaml!) expect(GDK::Output).to receive(:info).with('Telemetry username has been anonymized.') described_class.update_settings(answer) end end end context "when answer is 'n'" do let(:answer) { 'n' } context 'when telemetry is already disabled' do let(:enabled) { false } it 'keeps telemetry disabled and does not change the username' do expect(GDK.config).not_to receive(:bury!).with('telemetry.enabled', anything) expect(GDK.config).not_to receive(:bury!).with('telemetry.username', anything) described_class.update_settings(answer) end end context 'when telemetry is currently enabled' do let(:enabled) { true } it 'disables telemetry and does not change the username' do expect(GDK.config).to receive(:bury!).with('telemetry.enabled', false) expect(GDK.config).not_to receive(:bury!).with('telemetry.username', anything) expect(GDK.config).to receive(:save_yaml!) described_class.update_settings(answer) end end end end describe '.capture_exception' do let(:telemetry_enabled) { true } before do GDK.config.bury!('telemetry.enabled', telemetry_enabled) allow(described_class).to receive(:init_sentry) allow(described_class).to receive(:enabled_services).and_return(%w[mocked_service1 mocked_service2]) allow(Sentry).to receive(:capture_exception) end context 'when telemetry is not enabled' do let(:telemetry_enabled) { false } it 'does not capture the exception' do described_class.capture_exception('Test error') expect(Sentry).not_to have_received(:capture_exception) end end context 'when given an exception' do let(:raised) do raise 'boom' rescue RuntimeError => e e.freeze end it 'captures the given exception' do described_class.capture_exception(raised) expect(Sentry).to have_received(:capture_exception) do |exception| expect(exception).to be_a(RuntimeError) expect(exception.message).to eq(raised.message) expect(exception.backtrace.first).not_to include(__FILE__) end end end context 'when given a string' do let(:message) { 'Test error message' } it 'captures a new exception with the given message' do described_class.capture_exception(message) expect(Sentry).to have_received(:capture_exception) do |exception| expect(exception).to be_a(StandardError) expect(exception.message).to eq(message) expect(exception.backtrace.first).not_to include(__FILE__) end end end end end RSpec.describe GDK::Telemetry::LoggerWithoutBacktrace do describe '.warn' do subject(:warn) { SnowplowTracker::LOGGER.warn(arg) } before do GDK::Telemetry.client end context 'with string' do let(:arg) { 'some failure' } it 'shows plain string' do expect_warning(/#{arg}\n\z/) end end context 'with exception' do let(:arg) { exception } let(:message) { 'some failure' } let(:backtrace) { [__FILE__] } before do exception.set_backtrace(backtrace) end [Errno::ECONNREFUSED, Errno::ECONNABORTED].each do |exception_klass| context "and #{exception_klass}" do let(:exception) { exception_klass.new(message) } it 'removes backtrace' do expect_warning(/#{message} \(#{exception_klass}\)\n\n\z/) end end end end def expect_warning(expected) expect { warn }.to output(expected).to_stderr_from_any_process end end end # rubocop:enable RSpec/ExpectInHook