diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..70c8672b8a6177e63c312c0b887bdd3ab37c8584
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,95 @@
+version: 2.1
+
+workflows:
+  main:
+    jobs:
+      - ruby27
+      - ruby26
+      - ruby25
+      - ruby24
+      - ruby23
+
+executors:
+  ruby27:
+    docker:
+      - image: circleci/ruby:2.7
+      - image: circleci/redis:alpine
+  ruby26:
+    docker:
+      - image: circleci/ruby:2.6
+      - image: circleci/redis:alpine
+  ruby25:
+    docker:
+      - image: circleci/ruby:2.5
+      - image: circleci/redis:alpine
+  ruby24:
+    docker:
+      - image: circleci/ruby:2.4
+      - image: circleci/redis:alpine
+  ruby23:
+    docker:
+      - image: circleci/ruby:2.3
+      - image: circleci/redis:alpine
+
+commands:
+  test:
+    steps:
+      - restore_cache:
+          keys:
+            - bundler-{{ checksum "Gemfile.lock" }}
+
+      - run:
+          name: Bundle Install
+          command: bundle check --path vendor/bundle || bundle install
+
+      - save_cache:
+          key: bundler-{{ checksum "Gemfile.lock" }}
+          paths:
+            - vendor/bundle
+
+      - run:
+          name: Run rspec
+          command: |
+            bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
+
+jobs:
+  ruby27:
+    executor: ruby27
+    steps:
+      - checkout
+      - test
+
+      - run:
+          name: Report Test Coverage
+          command: |
+            wget https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 -O cc-test-reporter
+            chmod +x cc-test-reporter
+            ./cc-test-reporter format-coverage -t simplecov -o coverage/codeclimate.json coverage/.resultset.json
+            ./cc-test-reporter upload-coverage -i coverage/codeclimate.json
+
+      - store_test_results:
+          path: test_results
+
+  ruby26:
+    executor: ruby26
+    steps:
+      - checkout
+      - test
+
+  ruby25:
+    executor: ruby25
+    steps:
+      - checkout
+      - test
+
+  ruby24:
+    executor: ruby24
+    steps:
+      - checkout
+      - test
+
+  ruby23:
+    executor: ruby23
+    steps:
+      - checkout
+      - test
diff --git a/.document b/.document
deleted file mode 100644
index ecf3673194b8b6963488dabc93d5f16fea93c5e9..0000000000000000000000000000000000000000
--- a/.document
+++ /dev/null
@@ -1,5 +0,0 @@
-README.rdoc
-lib/**/*.rb
-bin/*
-features/**/*.feature
-LICENSE
diff --git a/.gitignore b/.gitignore
index 50290e0f2bf3e53f1282fbdebcd5dedbe11f690e..98b35f2a79e7c656f426896a073f891f5b658921 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,5 +19,6 @@ rdoc
 pkg
 *.gem
 gemfiles/*.lock
+.rspec_status
 
 ## PROJECT::SPECIFIC
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f04fbba79f068a3404663118f64384bcea40f6df
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,11 @@
+AllCops:
+  Exclude:
+    - 'spec/**/*'
+
+Metrics/LineLength:
+  Max: 120
+Metrics/MethodLength:
+  Max: 20
+
+Style/TrailingCommaInArguments:
+  EnforcedStyleForMultiline: comma
diff --git a/.travis.yml b/.travis.yml
index 7b96ef55f62d8a3596c282294e88537776e40e37..aff3ccd869edee250694ce277b180a67ae7bcfc5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,13 +4,13 @@ sudo: false
 services:
   - redis-server
 rvm:
-  - 2.4.1
-  - 2.3.1
+  - 2.6
+  - 2.5
+  - 2.4
+  - 2.3
   - 2.2
   - 2.1
-  - 2.0.0
-  - 1.9.3
-  - jruby-19mode
+  - 2.0
 env:
   - USE_REAL_REDIS=true
 gemfile:
diff --git a/Appraisals b/Appraisals
deleted file mode 100644
index da230180a7411086a103aba330c7d5f34763a0d7..0000000000000000000000000000000000000000
--- a/Appraisals
+++ /dev/null
@@ -1,7 +0,0 @@
-appraise "redis-3" do
-  gem "redis", "~> 3.3.2"
-end
-
-appraise "redis-4" do
-  gem "redis", "4.0.0.rc1"
-end
diff --git a/Gemfile b/Gemfile
index b4e2a20bb6069d33479542fc863e7e36810e0f01..7f4f5e950d1572e1ce5607ca237375aa2e30d662 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,3 +1,5 @@
-source "https://rubygems.org"
+# frozen_string_literal: true
+
+source 'https://rubygems.org'
 
 gemspec
diff --git a/Gemfile.lock b/Gemfile.lock
deleted file mode 100644
index df919eaa823e253e8b4abef591cd99378215453e..0000000000000000000000000000000000000000
--- a/Gemfile.lock
+++ /dev/null
@@ -1,56 +0,0 @@
-PATH
-  remote: .
-  specs:
-    rollout (2.4.3)
-
-GEM
-  remote: https://rubygems.org/
-  specs:
-    appraisal (2.2.0)
-      bundler
-      rake
-      thor (>= 0.14.0)
-    codeclimate-test-reporter (1.0.8)
-      simplecov (<= 0.13)
-    diff-lcs (1.3)
-    docile (1.1.5)
-    fakeredis (0.6.0)
-      redis (~> 3.2)
-    json (2.1.0)
-    rake (12.0.0)
-    redis (3.3.3)
-    rspec (3.6.0)
-      rspec-core (~> 3.6.0)
-      rspec-expectations (~> 3.6.0)
-      rspec-mocks (~> 3.6.0)
-    rspec-core (3.6.0)
-      rspec-support (~> 3.6.0)
-    rspec-expectations (3.6.0)
-      diff-lcs (>= 1.2.0, < 2.0)
-      rspec-support (~> 3.6.0)
-    rspec-mocks (3.6.0)
-      diff-lcs (>= 1.2.0, < 2.0)
-      rspec-support (~> 3.6.0)
-    rspec-support (3.6.0)
-    simplecov (0.13.0)
-      docile (~> 1.1.0)
-      json (>= 1.8, < 3)
-      simplecov-html (~> 0.10.0)
-    simplecov-html (0.10.1)
-    thor (0.19.4)
-
-PLATFORMS
-  ruby
-
-DEPENDENCIES
-  appraisal
-  bundler (>= 1.0.0)
-  codeclimate-test-reporter
-  fakeredis
-  redis
-  rollout!
-  rspec
-  simplecov
-
-BUNDLED WITH
-   1.15.1
diff --git a/README.md b/README.md
index 85db04025470aa8aa7b8c3c19dfd66341df11a0b..8f71a971ee4a290a51fc64bdcde704867302ab92 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,10 @@
 
 Fast feature flags based on Redis.
 
-[![Build Status](https://travis-ci.org/fetlife/rollout.svg?branch=master)](https://travis-ci.org/fetlife/rollout)
-[![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/fetlife/rollout)
-[![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/fetlife/rollout/coverage)
-[![Dependency Status](https://gemnasium.com/FetLife/rollout.svg)](https://gemnasium.com/fetlife/rollout)
+[![Gem Version](https://badge.fury.io/rb/rollout.svg)](https://badge.fury.io/rb/rollout)
+[![CircleCI](https://circleci.com/gh/fetlife/rollout.svg?style=svg)](https://circleci.com/gh/fetlife/rollout)
+[![Code Climate](https://codeclimate.com/github/FetLife/rollout/badges/gpa.svg)](https://codeclimate.com/github/FetLife/rollout)
+[![Test Coverage](https://codeclimate.com/github/FetLife/rollout/badges/coverage.svg)](https://codeclimate.com/github/FetLife/rollout/coverage)
 
 ## Install it
 
@@ -20,10 +20,18 @@ Initialize a rollout object. I assign it to a global var.
 ```ruby
 require 'redis'
 
-$redis   = Redis.new
+$redis = Redis.new
 $rollout = Rollout.new($redis)
 ```
 
+or even simpler
+
+```ruby
+require 'redis'
+$rollout = Rollout.new(Redis.current) # Will use REDIS_URL env var or default redis url
+```
+
+
 Update data specific to a feature:
 
 ```ruby
@@ -71,6 +79,9 @@ Deactivate groups like this:
 $rollout.deactivate_group(:chat, :all)
 ```
 
+Groups need to be defined every time your app starts. The logic is not persisted
+anywhere.
+
 ## Specific Users
 
 You might want to let a specific user into a beta test or something. If that
@@ -98,7 +109,7 @@ $rollout.activate_percentage(:chat, 20)
 The algorithm for determining which users get let in is this:
 
 ```ruby
-CRC32(user.id) % 100_000 < percentage * 1_000
+CRC32(user.id) < (2**32 - 1) / 100.0 * percentage
 ```
 
 So, for 20%, users 0, 1, 10, 11, 20, 21, etc would be allowed in. Those users
@@ -176,6 +187,7 @@ This example would use the "development:feature:chat:groups" key.
 *   Python: https://github.com/asenchi/proclaim
 *   PHP: https://github.com/opensoft/rollout
 *   Clojure: https://github.com/yeller/shoutout
+*   Perl: https://metacpan.org/pod/Toggle
 
 
 ## Contributors
diff --git a/Rakefile b/Rakefile
index 5f101654a7187da48e803e6103cdc83cdacd3a03..cffdd09be3bb6db7406fe4692a06d80407999ef5 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,9 +1,7 @@
-begin
-  require "rspec/core/rake_task"
+# frozen_string_literal: true
 
-  RSpec::Core::RakeTask.new(:spec)
+require 'rspec/core/rake_task'
 
-  task default: :spec
-rescue LoadError
-  # no rspec available
-end
+RSpec::Core::RakeTask.new(:spec)
+
+task default: :spec
diff --git a/gemfiles/redis_3.gemfile b/gemfiles/redis_3.gemfile
deleted file mode 100644
index b087aa826cdee9df9c53d89dc7031758384a1998..0000000000000000000000000000000000000000
--- a/gemfiles/redis_3.gemfile
+++ /dev/null
@@ -1,13 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-if RUBY_VERSION >= '2.0'
-  gem "json"
-else
-  gem "json", "~> 1.8.3"
-end
-
-gem "redis", "~> 3.3.2"
-
-gemspec :path => "../"
diff --git a/gemfiles/redis_4.gemfile b/gemfiles/redis_4.gemfile
deleted file mode 100644
index c2370626cc72ce5ff1bfb66610932d41058819d6..0000000000000000000000000000000000000000
--- a/gemfiles/redis_4.gemfile
+++ /dev/null
@@ -1,13 +0,0 @@
-# This file was generated by Appraisal
-
-source "https://rubygems.org"
-
-if RUBY_VERSION >= '2.0'
-  gem "json"
-else
-  gem "json", "~> 1.8.3"
-end
-
-gem "redis", "4.0.0.rc1"
-
-gemspec :path => "../"
diff --git a/lib/rollout.rb b/lib/rollout.rb
index fa6b3fbadd25f17ed08768d1a9d52f79f9cb3b8f..a8d7b2bc581064494d962b44ce3cff7ecb89cc0f 100644
--- a/lib/rollout.rb
+++ b/lib/rollout.rb
@@ -1,141 +1,30 @@
-require "rollout/version"
-require "zlib"
-require "set"
-require "json"
+# frozen_string_literal: true
 
-class Rollout
-  RAND_BASE = (2**32 - 1) / 100.0
-  
-  class Feature
-    attr_accessor :groups, :users, :percentage, :data
-    attr_reader :name, :options
-
-    def initialize(name, string = nil, opts = {})
-      @options = opts
-      @name    = name
-
-      if string
-        raw_percentage,raw_users,raw_groups,raw_data = string.split('|', 4)
-        @percentage = raw_percentage.to_f
-        @users = users_from_string(raw_users)
-        @groups = groups_from_string(raw_groups)
-        @data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
-      else
-        clear
-      end
-    end
-
-    def serialize
-      "#{@percentage}|#{@users.to_a.join(",")}|#{@groups.to_a.join(",")}|#{serialize_data}"
-    end
-
-    def add_user(user)
-      id = user_id(user)
-      @users << id unless @users.include?(id)
-    end
-
-    def remove_user(user)
-      @users.delete(user_id(user))
-    end
-
-    def add_group(group)
-      @groups << group.to_sym unless @groups.include?(group.to_sym)
-    end
-
-    def remove_group(group)
-      @groups.delete(group.to_sym)
-    end
-
-    def clear
-      @groups = groups_from_string("")
-      @users = users_from_string("")
-      @percentage = 0
-      @data = {}
-    end
-
-    def active?(rollout, user)
-      if user
-        id = user_id(user)
-        user_in_percentage?(id) ||
-          user_in_active_users?(id) ||
-            user_in_active_group?(user, rollout)
-      else
-        @percentage == 100
-      end
-    end
-
-    def user_in_active_users?(user)
-      @users.include?(user_id(user))
-    end
-
-    def to_hash
-      {
-        percentage: @percentage,
-        groups: @groups,
-        users: @users
-      }
-    end
-
-    private
-      def user_id(user)
-        if user.is_a?(Integer) || user.is_a?(String)
-          user.to_s
-        else
-          user.send(id_user_by).to_s
-        end
-      end
-
-      def id_user_by
-        @options[:id_user_by] || :id
-      end
-
-      def user_in_percentage?(user)
-        Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage
-      end
+require 'rollout/feature'
+require 'rollout/logging'
+require 'rollout/version'
+require 'zlib'
+require 'set'
+require 'json'
+require 'observer'
 
-      def user_id_for_percentage(user)
-        if @options[:randomize_percentage]
-          user_id(user).to_s + @name.to_s
-        else
-          user_id(user)
-        end
-      end
-
-      def user_in_active_group?(user, rollout)
-        @groups.any? do |g|
-          rollout.active_in_group?(g, user)
-        end
-      end
-
-      def serialize_data
-        return "" unless @data.is_a? Hash
-
-        @data.to_json
-      end
+class Rollout
+  include Observable
 
-      def users_from_string(raw_users)
-        users = (raw_users || "").split(",").map(&:to_s)
-        if @options[:use_sets]
-          users.to_set
-        else
-          users
-        end
-      end
+  RAND_BASE = (2**32 - 1) / 100.0
 
-      def groups_from_string(raw_groups)
-        groups = (raw_groups || "").split(",").map(&:to_sym)
-        if @options[:use_sets]
-          groups.to_set
-        else
-          groups
-        end
-      end
-  end
+  attr_reader :options, :storage
 
   def initialize(storage, opts = {})
     @storage = storage
     @options = opts
-    @groups  = { all: lambda { |user| true } }
+    @groups  = { all: ->(_user) { true } }
+
+    extend(Logging) if opts[:logging]
+  end
+
+  def groups
+    @groups.keys
   end
 
   def activate(feature)
@@ -145,16 +34,18 @@ class Rollout
   end
 
   def deactivate(feature)
-    with_feature(feature) do |f|
-      f.clear
-    end
+    with_feature(feature, &:clear)
   end
 
   def delete(feature)
-    features = (@storage.get(features_key) || "").split(",")
+    features = (@storage.get(features_key) || '').split(',')
     features.delete(feature.to_s)
-    @storage.set(features_key, features.join(","))
+    @storage.set(features_key, features.join(','))
     @storage.del(key(feature))
+
+    if respond_to?(:logging)
+      logging.delete(feature)
+    end
   end
 
   def set(feature, desired_state)
@@ -193,20 +84,20 @@ class Rollout
 
   def activate_users(feature, users)
     with_feature(feature) do |f|
-      users.each{|user| f.add_user(user)}
+      users.each { |user| f.add_user(user) }
     end
   end
 
   def deactivate_users(feature, users)
     with_feature(feature) do |f|
-      users.each{|user| f.remove_user(user)}
+      users.each { |user| f.remove_user(user) }
     end
   end
 
   def set_users(feature, users)
     with_feature(feature) do |f|
       f.users = []
-      users.each{|user| f.add_user(user)}
+      users.each { |user| f.add_user(user) }
     end
   end
 
@@ -242,7 +133,7 @@ class Rollout
 
   def active_in_group?(group, user)
     f = @groups[group.to_sym]
-    f && f.call(user)
+    f&.call(user)
   end
 
   def get(feature)
@@ -263,29 +154,31 @@ class Rollout
   end
 
   def multi_get(*features)
-    feature_keys = features.map{ |feature| key(feature) }
+    return [] if features.empty?
+
+    feature_keys = features.map { |feature| key(feature) }
     @storage.mget(*feature_keys).map.with_index { |string, index| Feature.new(features[index], string, @options) }
   end
 
   def features
-    (@storage.get(features_key) || "").split(",").map(&:to_sym)
+    (@storage.get(features_key) || '').split(',').map(&:to_sym)
   end
 
   def feature_states(user = nil)
-    features.each_with_object({}) do |f, hash|
-      hash[f] = active?(f, user)
+    multi_get(*features).each_with_object({}) do |f, hash|
+      hash[f.name] = f.active?(self, user)
     end
   end
 
   def active_features(user = nil)
-    features.select do |f|
-      active?(f, user)
-    end
+    multi_get(*features).select do |f|
+      f.active?(self, user)
+    end.map(&:name)
   end
 
   def clear!
     features.each do |feature|
-      with_feature(feature) { |f| f.clear }
+      with_feature(feature, &:clear)
       @storage.del(key(feature))
     end
 
@@ -293,7 +186,28 @@ class Rollout
   end
 
   def exists?(feature)
-    @storage.exists(key(feature))
+    # since redis-rb v4.2, `#exists?` replaces `#exists` which now returns integer value instead of boolean
+    # https://github.com/redis/redis-rb/pull/918
+    if @storage.respond_to?(:exists?)
+      @storage.exists?(key(feature))
+    else
+      @storage.exists(key(feature))
+    end
+  end
+
+  def with_feature(feature)
+    f = get(feature)
+
+    if count_observers > 0
+      before = Marshal.load(Marshal.dump(f))
+      yield(f)
+      save(f)
+      changed
+      notify_observers(:update, before, f)
+    else
+      yield(f)
+      save(f)
+    end
   end
 
   private
@@ -303,17 +217,11 @@ class Rollout
   end
 
   def features_key
-    "feature:__features__"
-  end
-
-  def with_feature(feature)
-    f = get(feature)
-    yield(f)
-    save(f)
+    'feature:__features__'
   end
 
   def save(feature)
     @storage.set(key(feature.name), feature.serialize)
-    @storage.set(features_key, (features | [feature.name.to_sym]).join(","))
+    @storage.set(features_key, (features | [feature.name.to_sym]).join(','))
   end
 end
diff --git a/lib/rollout/feature.rb b/lib/rollout/feature.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a39a555a284cb31ca3d4cd54c18bba386f0e3dda
--- /dev/null
+++ b/lib/rollout/feature.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+class Rollout
+  class Feature
+    attr_accessor :groups, :users, :percentage, :data
+    attr_reader :name, :options
+
+    def initialize(name, string = nil, opts = {})
+      @options = opts
+      @name    = name
+
+      if string
+        raw_percentage, raw_users, raw_groups, raw_data = string.split('|', 4)
+        @percentage = raw_percentage.to_f
+        @users = users_from_string(raw_users)
+        @groups = groups_from_string(raw_groups)
+        @data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
+      else
+        clear
+      end
+    end
+
+    def serialize
+      "#{@percentage}|#{@users.to_a.join(',')}|#{@groups.to_a.join(',')}|#{serialize_data}"
+    end
+
+    def add_user(user)
+      id = user_id(user)
+      @users << id unless @users.include?(id)
+    end
+
+    def remove_user(user)
+      @users.delete(user_id(user))
+    end
+
+    def add_group(group)
+      @groups << group.to_sym unless @groups.include?(group.to_sym)
+    end
+
+    def remove_group(group)
+      @groups.delete(group.to_sym)
+    end
+
+    def clear
+      @groups = groups_from_string('')
+      @users = users_from_string('')
+      @percentage = 0
+      @data = {}
+    end
+
+    def active?(rollout, user)
+      if user
+        id = user_id(user)
+        user_in_percentage?(id) ||
+          user_in_active_users?(id) ||
+          user_in_active_group?(user, rollout)
+      else
+        @percentage == 100
+      end
+    end
+
+    def user_in_active_users?(user)
+      @users.include?(user_id(user))
+    end
+
+    def to_hash
+      {
+        percentage: @percentage,
+        groups: @groups,
+        users: @users,
+        data: @data,
+      }
+    end
+
+    private
+
+    def user_id(user)
+      if user.is_a?(Integer) || user.is_a?(String)
+        user.to_s
+      else
+        user.send(id_user_by).to_s
+      end
+    end
+
+    def id_user_by
+      @options[:id_user_by] || :id
+    end
+
+    def user_in_percentage?(user)
+      Zlib.crc32(user_id_for_percentage(user)) < RAND_BASE * @percentage
+    end
+
+    def user_id_for_percentage(user)
+      if @options[:randomize_percentage]
+        user_id(user).to_s + @name.to_s
+      else
+        user_id(user)
+      end
+    end
+
+    def user_in_active_group?(user, rollout)
+      @groups.any? do |g|
+        rollout.active_in_group?(g, user)
+      end
+    end
+
+    def serialize_data
+      return '' unless @data.is_a? Hash
+
+      @data.to_json
+    end
+
+    def users_from_string(raw_users)
+      users = (raw_users || '').split(',').map(&:to_s)
+      if @options[:use_sets]
+        users.to_set
+      else
+        users
+      end
+    end
+
+    def groups_from_string(raw_groups)
+      groups = (raw_groups || '').split(',').map(&:to_sym)
+      if @options[:use_sets]
+        groups.to_set
+      else
+        groups
+      end
+    end
+  end
+end
diff --git a/lib/rollout/logging.rb b/lib/rollout/logging.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a8fa7570406bfeed1c4a7cb87ede6c61c470acd6
--- /dev/null
+++ b/lib/rollout/logging.rb
@@ -0,0 +1,198 @@
+class Rollout
+  module Logging
+    def self.extended(rollout)
+      options = rollout.options[:logging]
+      options = options.is_a?(Hash) ? options.dup : {}
+      options[:storage] ||= rollout.storage
+
+      logger = Logger.new(**options)
+
+      rollout.add_observer(logger, :log)
+      rollout.define_singleton_method(:logging) do
+        logger
+      end
+    end
+
+    class Event
+      attr_reader :feature, :name, :data, :context, :created_at
+
+      def self.from_raw(value, score)
+        hash = JSON.parse(value, symbolize_names: true)
+
+        new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000)))
+      end
+
+      def initialize(feature: nil, name:, data:, context: {}, created_at:)
+        @feature = feature
+        @name = name
+        @data = data
+        @context = context
+        @created_at = created_at
+      end
+
+      def timestamp
+        (@created_at.to_f * 1_000_000).to_i
+      end
+
+      def serialize
+        JSON.dump(
+          feature: @feature,
+          name: @name,
+          data: @data,
+          context: @context,
+        )
+      end
+
+      def ==(other)
+        feature == other.feature \
+          && name == other.name \
+          && data == other.data \
+          && created_at == other.created_at
+      end
+    end
+
+    class Logger
+      def initialize(storage: nil, history_length: 50, global: false)
+        @history_length = history_length
+        @storage = storage
+        @global = global
+      end
+
+      def updated_at(feature_name)
+        storage_key = events_storage_key(feature_name)
+        _, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first
+        Time.at(-score.to_f / 1_000_000) if score
+      end
+
+      def last_event(feature_name)
+        storage_key = events_storage_key(feature_name)
+        value = @storage.zrange(storage_key, 0, 0, with_scores: true).first
+        Event.from_raw(*value) if value
+      end
+
+      def events(feature_name)
+        storage_key = events_storage_key(feature_name)
+        @storage
+          .zrange(storage_key, 0, -1, with_scores: true)
+          .map { |v| Event.from_raw(*v) }
+          .reverse
+      end
+
+      def global_events
+        @storage
+          .zrange(global_events_storage_key, 0, -1, with_scores: true)
+          .map { |v| Event.from_raw(*v) }
+          .reverse
+      end
+
+      def delete(feature_name)
+        storage_key = events_storage_key(feature_name)
+        @storage.del(storage_key)
+      end
+
+      def update(before, after)
+        before_hash = before.to_hash
+        before_hash.delete(:data).each do |k, v|
+          before_hash["data.#{k}"] = v
+        end
+        after_hash = after.to_hash
+        after_hash.delete(:data).each do |k, v|
+          after_hash["data.#{k}"] = v
+        end
+
+        keys = before_hash.keys | after_hash.keys
+        change = { before: {}, after: {} }
+        changed_count = 0
+
+        keys.each do |key|
+          next if before_hash[key] == after_hash[key]
+
+          change[:before][key] = before_hash[key]
+          change[:after][key] = after_hash[key]
+
+          changed_count += 1
+        end
+
+        return if changed_count == 0
+
+        event = Event.new(
+          feature: after.name,
+          name: :update,
+          data: change,
+          context: current_context,
+          created_at: Time.now,
+        )
+
+        storage_key = events_storage_key(after.name)
+
+        @storage.zadd(storage_key, -event.timestamp, event.serialize)
+        @storage.zremrangebyrank(storage_key, @history_length, -1)
+
+        if @global
+          @storage.zadd(global_events_storage_key, -event.timestamp, event.serialize)
+          @storage.zremrangebyrank(global_events_storage_key, @history_length, -1)
+        end
+      end
+
+      def log(event, *args)
+        return unless logging_enabled?
+
+        unless respond_to?(event)
+          raise ArgumentError, "Invalid log event: #{event}"
+        end
+
+        expected_arity = method(event).arity
+        unless args.count == expected_arity
+          raise(
+            ArgumentError,
+            "Invalid number of arguments for event '#{event}': expected #{expected_arity} but got #{args.count}",
+          )
+        end
+
+        public_send(event, *args)
+      end
+
+      CONTEXT_THREAD_KEY = :rollout_logging_context
+      WITHOUT_THREAD_KEY = :rollout_logging_disabled
+
+      def with_context(context)
+        raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash)
+        raise ArgumentError, "block is required" unless block_given?
+
+        Thread.current[CONTEXT_THREAD_KEY] = context
+        yield
+      ensure
+        Thread.current[CONTEXT_THREAD_KEY] = nil
+      end
+
+      def current_context
+        Thread.current[CONTEXT_THREAD_KEY] || {}
+      end
+
+      def without
+        Thread.current[WITHOUT_THREAD_KEY] = true
+        yield
+      ensure
+        Thread.current[WITHOUT_THREAD_KEY] = nil
+      end
+
+      def logging_enabled?
+        !Thread.current[WITHOUT_THREAD_KEY]
+      end
+
+      private
+
+      def global_events_storage_key
+        "feature:_global_:logging:events"
+      end
+
+      def events_storage_key(feature_name)
+        "feature:#{feature_name}:logging:events"
+      end
+
+      def current_timestamp
+        (Time.now.to_f * 1_000_000).to_i
+      end
+    end
+  end
+end
diff --git a/lib/rollout/version.rb b/lib/rollout/version.rb
index 632a1900cfa14a307e2bfb7d03ba94c6f4e2b9b6..695b2ab64cb9fadb2dfb33225cc9208f3c50c0de 100644
--- a/lib/rollout/version.rb
+++ b/lib/rollout/version.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 class Rollout
-  VERSION = "2.4.3"
+  VERSION = '2.5.0'
 end
diff --git a/rollout.gemspec b/rollout.gemspec
index 19d2eccc1734fc520d741722a517bfe6feb84797..0a8526ee8fd61d8c0b4ea3e19d50d2ddc956f229 100644
--- a/rollout.gemspec
+++ b/rollout.gemspec
@@ -1,27 +1,31 @@
-# -*- encoding: utf-8 -*-
-$:.push File.expand_path("../lib", __FILE__)
-require "rollout/version"
+# frozen_string_literal: true
 
-Gem::Specification.new do |s|
-  s.name        = "rollout"
-  s.version     = Rollout::VERSION
-  s.authors     = ["James Golick"]
-  s.email       = ["jamesgolick@gmail.com"]
-  s.description = "Feature flippers with redis."
-  s.summary     = "Feature flippers with redis."
-  s.homepage    = "https://github.com/FetLife/rollout"
-  s.license     = "MIT"
+$LOAD_PATH.push File.expand_path('lib', __dir__)
+require 'rollout/version'
 
-  s.files         = `git ls-files`.split("\n")
-  s.test_files    = `git ls-files -- {test,spec,features}/*`.split("\n")
-  s.executables   = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
-  s.require_paths = ["lib"]
+Gem::Specification.new do |spec|
+  spec.name        = 'rollout'
+  spec.version     = Rollout::VERSION
+  spec.authors     = ['James Golick']
+  spec.email       = ['jamesgolick@gmail.com']
+  spec.description = 'Feature flippers with redis.'
+  spec.summary     = 'Feature flippers with redis.'
+  spec.homepage    = 'https://github.com/FetLife/rollout'
+  spec.license     = 'MIT'
 
-  s.add_development_dependency "rspec"
-  s.add_development_dependency "appraisal"
-  s.add_development_dependency "bundler", ">= 1.0.0"
-  s.add_development_dependency "redis"
-  s.add_development_dependency "fakeredis"
-  s.add_development_dependency "simplecov"
-  s.add_development_dependency "codeclimate-test-reporter"
+  spec.files         = `git ls-files`.split("\n")
+  spec.test_files    = `git ls-files -- {test,spec,features}/*`.split("\n")
+  spec.executables   = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
+  spec.require_paths = ['lib']
+
+  spec.required_ruby_version = '>= 2.3'
+
+  spec.add_dependency 'redis', '~> 4.0'
+
+  spec.add_development_dependency 'bundler', '>= 1.17'
+  spec.add_development_dependency 'pry'
+  spec.add_development_dependency 'rspec', '~> 3.0'
+  spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
+  spec.add_development_dependency 'rubocop', '~> 0.71'
+  spec.add_development_dependency 'simplecov', '0.17'
 end
diff --git a/spec/rollout/logging_spec.rb b/spec/rollout/logging_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..9e5c6915469803cf58edf348af9eb1afdcfaf4cf
--- /dev/null
+++ b/spec/rollout/logging_spec.rb
@@ -0,0 +1,143 @@
+require 'spec_helper'
+
+RSpec.describe 'Rollout::Logging' do
+  let(:rollout) { Rollout.new(Redis.current, logging: logging) }
+  let(:logging) { true }
+  let(:feature) { :foo }
+
+  it 'logs changes' do
+    expect(rollout.logging.last_event(feature)).to be_nil
+
+    rollout.activate_percentage(feature, 50)
+
+    expect(rollout.logging.updated_at(feature)).to_not be_nil
+
+    first_event = rollout.logging.last_event(feature)
+
+    expect(first_event.name).to eq 'update'
+    expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
+
+    rollout.activate_percentage(feature, 75)
+
+    second_event = rollout.logging.last_event(feature)
+
+    expect(second_event.name).to eq 'update'
+    expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 })
+
+    rollout.activate_group(feature, :hipsters)
+
+    third_event = rollout.logging.last_event(feature)
+
+    expect(third_event.name).to eq 'update'
+    expect(third_event.data).to eq(before: { groups: [] }, after: { groups: ['hipsters'] })
+
+    expect(rollout.logging.events(feature)).to eq [first_event, second_event, third_event]
+  end
+
+  context 'logging data changes' do
+    it 'logs changes' do
+      expect(rollout.logging.last_event(feature)).to be_nil
+
+      rollout.set_feature_data(feature, description: "foo")
+
+      event = rollout.logging.last_event(feature)
+
+      expect(event).not_to be_nil
+      expect(event.name).to eq 'update'
+      expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" })
+    end
+  end
+
+  context 'no logging' do
+    let(:logging) { nil }
+
+    it 'doesnt even respond to logging' do
+      expect(rollout).not_to respond_to :logging
+    end
+  end
+
+  context 'history truncation' do
+    let(:logging) { { history_length: 1 } }
+
+    it 'logs changes' do
+      expect(rollout.logging.last_event(feature)).to be_nil
+
+      rollout.activate_percentage(feature, 25)
+
+      first_event = rollout.logging.last_event(feature)
+
+      expect(first_event.name).to eq 'update'
+      expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
+
+      rollout.activate_percentage(feature, 30)
+
+      second_event = rollout.logging.last_event(feature)
+
+      expect(second_event.name).to eq 'update'
+      expect(second_event.data).to eq(before: { percentage: 25 }, after: { percentage: 30 })
+
+      expect(rollout.logging.events(feature)).to eq [second_event]
+    end
+  end
+
+  context 'with context' do
+    let(:current_user) { double(nickname: 'lester') }
+
+    it 'adds context to the event' do
+      rollout.logging.with_context(actor: current_user.nickname) do
+        rollout.activate_percentage(feature, 25)
+      end
+
+      event = rollout.logging.last_event(feature)
+
+      expect(event.name).to eq 'update'
+      expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
+      expect(event.context).to eq(actor: current_user.nickname)
+    end
+  end
+
+  context 'global logs' do
+    let(:logging) { { global: true } }
+    let(:feature_foo) { 'foo' }
+    let(:feature_bar) { 'bar' }
+
+    it 'logs changes' do
+      expect(rollout.logging.last_event(feature_foo)).to be_nil
+
+      rollout.activate_percentage(feature_foo, 25)
+
+      event_foo = rollout.logging.last_event(feature_foo)
+
+      expect(event_foo.feature).to eq feature_foo
+      expect(event_foo.name).to eq 'update'
+      expect(event_foo.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
+
+      expect(rollout.logging.events(feature_foo)).to eq [event_foo]
+
+      rollout.activate_percentage(feature_bar, 30)
+
+      event_bar = rollout.logging.last_event(feature_bar)
+
+      expect(event_bar.feature).to eq feature_bar
+      expect(event_bar.name).to eq 'update'
+      expect(event_bar.data).to eq(before: { percentage: 0 }, after: { percentage: 30 })
+
+      expect(rollout.logging.events(feature_bar)).to eq [event_bar]
+
+      expect(rollout.logging.global_events).to eq [event_foo, event_bar]
+    end
+  end
+
+  context 'no logging for block' do
+    it 'doesnt log' do
+      rollout.logging.without do
+        rollout.activate_percentage(feature, 25)
+      end
+
+      event = rollout.logging.last_event(feature)
+
+      expect(event).to be_nil
+    end
+  end
+end
+
diff --git a/spec/rollout_spec.rb b/spec/rollout_spec.rb
index 73d5fee4ef12bce1f8913db670cd21f6dc8a0ca4..7d3f4be15bc0ee8314ceaabfbaff385de3c17868 100644
--- a/spec/rollout_spec.rb
+++ b/spec/rollout_spec.rb
@@ -2,8 +2,7 @@ require "spec_helper"
 
 RSpec.describe "Rollout" do
   before do
-    @redis   = Redis.new
-    @rollout = Rollout.new(@redis)
+    @rollout = Rollout.new(Redis.current)
   end
 
   describe "when a group is activated" do
@@ -430,7 +429,8 @@ RSpec.describe "Rollout" do
       expect(feature.to_hash).to eq(
         groups: [:caretakers, :greeters],
         percentage: 10,
-        users: %w(42)
+        users: %w(42),
+        data: {},
       )
 
       feature = @rollout.get(:signup)
@@ -450,7 +450,8 @@ RSpec.describe "Rollout" do
       expect(feature.to_hash).to eq(
         groups: [:caretakers, :greeters].to_set,
         percentage: 10,
-        users: %w(42).to_set
+        users: %w(42).to_set,
+        data: {},
       )
 
       feature = @rollout.get(:signup)
@@ -474,7 +475,8 @@ RSpec.describe "Rollout" do
         expect(@rollout.get(feature).to_hash).to eq(
           percentage: 0,
           users: [],
-          groups: []
+          groups: [],
+          data: {},
         )
       end
     end
@@ -486,7 +488,8 @@ RSpec.describe "Rollout" do
         expect(@rollout.get(feature).to_hash).to eq(
           percentage: 0,
           users: Set.new,
-          groups: Set.new
+          groups: Set.new,
+          data: {},
         )
       end
     end
@@ -605,6 +608,12 @@ RSpec.describe "Rollout" do
       expect(features[2].percentage).to eq 100
       expect(features.size).to eq 3
     end
+
+    describe 'when given feature keys is empty' do
+      it 'returns empty array' do
+        expect(@rollout.multi_get(*[])).to match_array([])
+      end
+    end
   end
 
   describe "#set_feature_data" do
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index a2b2d2d97314ee7fda473b26cd4406c758208a8e..e6b5821344a06c35213254e739558a69a9270b45 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,18 +1,27 @@
-$LOAD_PATH.unshift(File.dirname(__FILE__))
-$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
+# frozen_string_literal: true
 
-require "simplecov"
-require "rspec"
-require ENV["USE_REAL_REDIS"] == "true" ? "redis" : "fakeredis"
+require 'simplecov'
 
-SimpleCov.start do
-  formatter SimpleCov::Formatter::MultiFormatter.new([
-    SimpleCov::Formatter::HTMLFormatter,
-  ])
-end
+SimpleCov.start
+
+require 'bundler/setup'
+require 'redis'
+require 'rollout'
 
-require "rollout"
+Redis.current = Redis.new(
+  host: ENV.fetch('REDIS_HOST', '127.0.0.1'),
+  port: ENV.fetch('REDIS_PORT', '6379'),
+  db: ENV.fetch('REDIS_DB', '7'),
+)
 
 RSpec.configure do |config|
-  config.before { Redis.new.flushdb }
+  config.example_status_persistence_file_path = '.rspec_status'
+
+  # config.disable_monkey_patching!
+
+  config.expect_with :rspec do |c|
+    c.syntax = :expect
+  end
+
+  config.before { Redis.current.flushdb }
 end