diff --git a/Gemfile.lock b/Gemfile.lock index 0593aef..2a7b083 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -45,6 +45,7 @@ GEM ffi (~> 1.0) msgpack (1.7.2) multipart-post (2.3.0) + mysql2 (0.5.5) rack (3.0.8) rack-test (2.1.0) rack (>= 1.3) @@ -79,6 +80,7 @@ DEPENDENCIES dogstatsd-ruby (<= 4.8.3) faraday (~> 1) faraday-rack + mysql2 (>= 0.5) network_resiliency! rack rack-test diff --git a/lib/network_resiliency.rb b/lib/network_resiliency.rb index f17bfec..854f9b5 100644 --- a/lib/network_resiliency.rb +++ b/lib/network_resiliency.rb @@ -5,6 +5,7 @@ module Adapter autoload :HTTP, "network_resiliency/adapter/http" autoload :Faraday, "network_resiliency/adapter/faraday" autoload :Redis, "network_resiliency/adapter/redis" + autoload :Mysql, "network_resiliency/adapter/mysql" end extend self @@ -22,6 +23,8 @@ def patch(*adapters) Adapter::HTTP.patch when :redis Adapter::Redis.patch + when :mysql + Adapter::Mysql.patch else raise NotImplementedError end diff --git a/lib/network_resiliency/adapter/mysql.rb b/lib/network_resiliency/adapter/mysql.rb new file mode 100644 index 0000000..a5671ed --- /dev/null +++ b/lib/network_resiliency/adapter/mysql.rb @@ -0,0 +1,52 @@ +gem "mysql2", ">= 0.5" +require "mysql2" + +module NetworkResiliency + module Adapter + module Mysql + extend self + + def patch + return if patched? + + Mysql2::Client.prepend(Instrumentation) + end + + def patched? + Mysql2::Client.ancestors.include?(Instrumentation) + end + + module Instrumentation + def connect(_, _, host, *args) + # timeout = query_options[:connect_timeout] + + return super unless NetworkResiliency.enabled?(:mysql) + + begin + ts = -NetworkResiliency.timestamp + + super + rescue Mysql2::Error::TimeoutError => e + # capture error + raise + ensure + ts += NetworkResiliency.timestamp + + NetworkResiliency.record( + adapter: "mysql", + action: "connect", + destination: host, + error: e&.class, + duration: ts, + ) + end + end + + # def query(sql, options = {}) + # puts "query" + # super + # end + end + end + end +end diff --git a/network_resiliency.gemspec b/network_resiliency.gemspec index c00c8ef..a4a2470 100644 --- a/network_resiliency.gemspec +++ b/network_resiliency.gemspec @@ -18,6 +18,7 @@ Gem::Specification.new do |s| s.add_development_dependency "dogstatsd-ruby", "<= 4.8.3" s.add_development_dependency "faraday", "~> 1" s.add_development_dependency "faraday-rack" + s.add_development_dependency "mysql2", ">= 0.5" s.add_development_dependency "rack" s.add_development_dependency "rack-test" s.add_development_dependency "redis", "~> 4" diff --git a/spec/mysql_spec.rb b/spec/mysql_spec.rb new file mode 100644 index 0000000..85acb5f --- /dev/null +++ b/spec/mysql_spec.rb @@ -0,0 +1,74 @@ +describe NetworkResiliency::Adapter::Mysql, :mock_mysql do + before do + stub_const("Mysql2::Client", klass_mock) + end + + let(:klass_mock) { Class.new(Mysql2::Client) } + + describe ".patch" do + subject do + described_class.patched? + end + + it { is_expected.to be false } + + context "when patched" do + before do + allow(klass_mock).to receive(:prepend).and_call_original + described_class.patch + end + + it { is_expected.to be true } + + it "will only patch once" do + expect(klass_mock).to have_received(:prepend).once + + described_class.patch + + expect(klass_mock).to have_received(:prepend).once + end + end + end + + describe ".connect" do + subject do + mysql rescue Mysql2::Error::ConnectionError + + NetworkResiliency.statsd + end + + let(:host) { "my.fav.sql.com" } + let(:mysql) { Mysql2::Client.new(host: host, socket: mock_mysql) } + # let(:select) { mysql.query("SELECT 1").first.first.last } + + before do + described_class.patch + end + + it "can not connect to a mysql server" do + expect { mysql }.to raise_error(Mysql2::Error::ConnectionError) + end + + it "logs connection" do + is_expected.to have_received(:distribution).with( + /connect/, + Numeric, + anything, + ) + end + + it "logs duration" do + is_expected.to have_received(:distribution) do |_, duration, _| + expect(duration).to be > 0 + end + end + + it "tags the destination host" do + is_expected.to have_received(:distribution).with( + String, + Numeric, + tags: include(destination: host), + ) + end + end +end diff --git a/spec/support/mock_mysql.rb b/spec/support/mock_mysql.rb new file mode 100644 index 0000000..f62d6bf --- /dev/null +++ b/spec/support/mock_mysql.rb @@ -0,0 +1,41 @@ +require "mysql2" +require "socket" + +module Helpers + module MockMysql + SOCKET_PATH = "/tmp/mock_mysql.sock" + + def mock_mysql + # raise Mysql2::Error::TimeoutError.new("fake timeout", nil, error_number = 1205) + + File.delete(SOCKET_PATH) if File.exists?(SOCKET_PATH) + + server = UNIXServer.new(SOCKET_PATH) + + Thread.new do + socket, _ = server.accept + + # line = socket.recv(1024) + # line = socket.gets.chomp + # puts "mysql server: #{line}" + + # server_socket.write("#{resp}") + # server_socket.write("\r\n") unless resp.end_with?("\r\n") + ensure + socket&.close + end + + SOCKET_PATH + end + end +end + + +RSpec.configure do |config| + config.include Helpers::MockMysql, :mock_mysql + + config.after(mock_mysql: true) do + # clean up socket file + File.delete(Helpers::MockMysql::SOCKET_PATH) if File.exists?(Helpers::MockMysql::SOCKET_PATH) + end +end