rollout-redis 0.3.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +121 -3
- data/lib/rollout/feature.rb +9 -2
- data/lib/rollout/notifications/channels/email.rb +27 -0
- data/lib/rollout/notifications/channels/slack.rb +41 -0
- data/lib/rollout/notifications/notifiers/base.rb +15 -0
- data/lib/rollout/notifications/notifiers/degrade.rb +36 -0
- data/lib/rollout/notifications/notifiers/status_change.rb +45 -0
- data/lib/rollout/tasks/rollout.rake +18 -4
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +147 -19
- data/rollout-redis.gemspec +2 -0
- metadata +35 -2
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: d3d7a7f17af2164aaeaefcea4ebd6def9fa5a9d29af982e5ed0ad05f2ee4a3a8
         | 
| 4 | 
            +
              data.tar.gz: f9b9ff1cd3a1d45fa46695150d705dfff5489937baeaff082718fca7221e90f0
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 016c6a7f22c74586628b600bada44d95a3c5934b40c973811e539de286136282d039e1dedc991d6678275b4b25a67a9b3c8139b8403d607ebc78ec27657545d7
         | 
| 7 | 
            +
              data.tar.gz: e959cd3d1d9a3a9bbd727b68fef633a30b6e066ecfa0d708bc9dc27db199a6ab9285e2a9709b2696d02af14565055230c02fb85305917f4854454345a7dab005
         | 
    
        data/CHANGELOG.md
    CHANGED
    
    | @@ -4,6 +4,25 @@ All changes to `rollout-redis` will be documented in this file. | |
| 4 4 | 
             
            The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
         | 
| 5 5 | 
             
            and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
         | 
| 6 6 |  | 
| 7 | 
            +
            ## [1.1.0] - 2023-10-28
         | 
| 8 | 
            +
             | 
| 9 | 
            +
            ### Added
         | 
| 10 | 
            +
            - `#with_old_rollout_gem_compatibility` method for allowing working with Feature Flags that were stored by the old `rollout` gem.
         | 
| 11 | 
            +
            - `rollout:migrate_from_rollout_format` rake task for performing a migration of the feature flags stored by the old `rollout` gem to the new `rollout-redis` format.
         | 
| 12 | 
            +
            - Add a new parameter when performing `#activate` method for providing a specific degrade configuration for the feature flag that is being activated.
         | 
| 13 | 
            +
            - Add a new parameter when performing `rollout:on` rake task for providing a specific degrade configuration for the feature flag that is being activated.
         | 
| 14 | 
            +
            - You can implement now your own `Rollout::Notifications::Channels::Channel` in case the ones offered by the `gem` are not enough.
         | 
| 15 | 
            +
             | 
| 16 | 
            +
            ## [1.0.0] - 2023-10-25
         | 
| 17 | 
            +
             | 
| 18 | 
            +
            ### Added
         | 
| 19 | 
            +
            - `#with_notifications` method for allowing to send notifications when some different event occurs.
         | 
| 20 | 
            +
             | 
| 21 | 
            +
            ### Changed
         | 
| 22 | 
            +
            - When the threshold of errors is reached when using the `with_degrade` feature, instead of deleting the feature flag from the redis, we are marking it now as degraded, moving the activation percentage to 0% and adding some useful information to the feature flag stored data.
         | 
| 23 | 
            +
             | 
| 24 | 
            +
            ## [0.3.1] - 2023-10-24
         | 
| 25 | 
            +
            - Same as 0.3.0. When testing GitHub actions for moving to first release `1.0.0` it deployed a new version of the gem by error.
         | 
| 7 26 |  | 
| 8 27 | 
             
            ## [0.3.0] - 2023-10-24
         | 
| 9 28 |  | 
    
        data/README.md
    CHANGED
    
    | @@ -14,8 +14,10 @@ Topics covered in this README: | |
| 14 14 | 
             
              - [Gradual activation based on percentages](#gradual-activation-based-on-percentages)
         | 
| 15 15 | 
             
              - [Caching Feature Flags](#caching-feature-flags)
         | 
| 16 16 | 
             
              - [Auto-deactivating flags](#auto-deactivating-flags)
         | 
| 17 | 
            +
              - [Sending Notifications](#sending-notifications)
         | 
| 17 18 | 
             
            - [Rake tasks](#rake-tasks)
         | 
| 18 19 | 
             
            - [Migrating from rollout gem](#migrating-from-rollout-gem-)
         | 
| 20 | 
            +
              - [Compatible payloads and keys](#compatible-payloads-and-keys)
         | 
| 19 21 | 
             
            - [Changelog](#changelog)
         | 
| 20 22 | 
             
            - [Contributing](#contributing)
         | 
| 21 23 |  | 
| @@ -142,7 +144,7 @@ In the case that you need to clear the cache at any point, you can make use of t | |
| 142 144 |  | 
| 143 145 | 
             
            ### Auto-deactivating flags
         | 
| 144 146 |  | 
| 145 | 
            -
            If you want to allow the gem to deactivate  | 
| 147 | 
            +
            If you want to allow the gem to deactivate all the feature flags automatically when a threshold of errors is reached, you can enable the degrade feature using the `with_degrade` method.
         | 
| 146 148 |  | 
| 147 149 | 
             
            ```ruby
         | 
| 148 150 | 
             
            @rollout ||= Rollout.new(redis)
         | 
| @@ -150,6 +152,18 @@ If you want to allow the gem to deactivate your feature flag automatically when | |
| 150 152 | 
             
                          .with_degrade(min: 100, threshold: 0.1)
         | 
| 151 153 | 
             
            ```
         | 
| 152 154 |  | 
| 155 | 
            +
            However, if you just want to activate the degradation of an specific feature flag, you need to provide the following information when activating the feature flag (note that now the percentage is a mandatory parameter if you want to pass the degrade options):
         | 
| 156 | 
            +
             | 
| 157 | 
            +
            ```ruby
         | 
| 158 | 
            +
            @rollout.activate('FEATURE_FLAG_NAME', 100, degrade: { min: 500, threshold: 0.2 })
         | 
| 159 | 
            +
            ```
         | 
| 160 | 
            +
             | 
| 161 | 
            +
            The same configuration 👆 is available when using the rake task for activating the feature flag. Check [Rake tasks](#rake-tasks) section.
         | 
| 162 | 
            +
             | 
| 163 | 
            +
            ```shell
         | 
| 164 | 
            +
            bundle exec rake rollout:on[FEATURE_FLAG_NAME,100,500,0.2]
         | 
| 165 | 
            +
            ```
         | 
| 166 | 
            +
             | 
| 153 167 | 
             
            So now, instead of using the `active?` method, you need to wrap your new code under the `with_feature` method.
         | 
| 154 168 |  | 
| 155 169 | 
             
            ```ruby
         | 
| @@ -158,7 +172,99 @@ So now, instead of using the `active?` method, you need to wrap your new code un | |
| 158 172 | 
             
            end
         | 
| 159 173 | 
             
            ```
         | 
| 160 174 |  | 
| 161 | 
            -
            When any unexpected error appears during the wrapped code execution, the Rollout gem will take it into account for automatically  | 
| 175 | 
            +
            When any unexpected error appears during the wrapped code execution, the Rollout gem will take it into account for automatically degrading the feature flag if the threshold of errors is reached. The feature flag will not be removed from the redis, but it will change its percentage to 0 and it will be marked as degraded.
         | 
| 176 | 
            +
             | 
| 177 | 
            +
            _NOTE_: All the managed or captured errors inside the wrapped code will not be taken into consideration for degrading the feature flag.
         | 
| 178 | 
            +
             | 
| 179 | 
            +
            ### Sending notifications
         | 
| 180 | 
            +
             | 
| 181 | 
            +
            `rollout-redis` gem can send different notifications to your development team. For enabling this feature, you just need to use the `with_notifications` instance method providing the channels where you want to publish each of the different events that can occur:
         | 
| 182 | 
            +
             | 
| 183 | 
            +
            - **status_change**: This notification is triggered when a feature flag is activated or deactivated using the `rollout-redis` gem.
         | 
| 184 | 
            +
            - **degrade**: This notification is triggered when a feature flag is automatically degraded because the threshold of errors is reached
         | 
| 185 | 
            +
              - The instance must be configured for automatically degrading using the `with_degrade` instance method.
         | 
| 186 | 
            +
             | 
| 187 | 
            +
            You must provide at least one [channel](#defining-the-channels) as a parameter if you want to enable the notifications for that specific event. If no channels provided, the notifications will not be sent. 
         | 
| 188 | 
            +
             | 
| 189 | 
            +
            ```ruby
         | 
| 190 | 
            +
            @rollout ||= Rollout.new(redis)
         | 
| 191 | 
            +
                          .with_cache
         | 
| 192 | 
            +
                          .with_degrade(min: 100, threshold: 0.1)
         | 
| 193 | 
            +
                          .with_notifications(
         | 
| 194 | 
            +
                            status_change: [slack_channel],
         | 
| 195 | 
            +
                            degrade: [slack_channel, email_channel]
         | 
| 196 | 
            +
                          )
         | 
| 197 | 
            +
            ```
         | 
| 198 | 
            +
             | 
| 199 | 
            +
            #### Defining the channels
         | 
| 200 | 
            +
             | 
| 201 | 
            +
            When enabling a notification, you can provide the different channels where the notification should be published. `rollout-redis` gem offers different channels that can be configured.
         | 
| 202 | 
            +
             | 
| 203 | 
            +
            ##### Slack Channel
         | 
| 204 | 
            +
             | 
| 205 | 
            +
            Allows you to send notifications using a slack webhook.
         | 
| 206 | 
            +
             | 
| 207 | 
            +
            The first thing to do is to setup an incoming webhook service integration. You can do this from your services page.
         | 
| 208 | 
            +
             | 
| 209 | 
            +
            After that, you can provide the obtained webhook url when instantiating the Slack channel.
         | 
| 210 | 
            +
             | 
| 211 | 
            +
            ```ruby
         | 
| 212 | 
            +
            require 'rollout'
         | 
| 213 | 
            +
             | 
| 214 | 
            +
            slack_channel = Rollout::Notifications::Channels::Slack.new(
         | 
| 215 | 
            +
              webhook_url: ENV.fetch('SLACK_COMPANY_WEBHOOK_URL'),
         | 
| 216 | 
            +
              channel: '#feature-flags-notifications',
         | 
| 217 | 
            +
              username: 'rollout-redis'
         | 
| 218 | 
            +
            )
         | 
| 219 | 
            +
            ```
         | 
| 220 | 
            +
             | 
| 221 | 
            +
            ##### Email Channel
         | 
| 222 | 
            +
             | 
| 223 | 
            +
            Allows you to send notifications via email.
         | 
| 224 | 
            +
             | 
| 225 | 
            +
            ```ruby
         | 
| 226 | 
            +
            require 'rollout'
         | 
| 227 | 
            +
             | 
| 228 | 
            +
            email_channel = Rollout::Notifications::Channels::Email.new(
         | 
| 229 | 
            +
              smtp_host: ENV.fetch('SMTP_HOST'),
         | 
| 230 | 
            +
              smtp_port: ENV.fetch('SMTP_PORT'),
         | 
| 231 | 
            +
              from: 'no-reply@rollout-redis.com',
         | 
| 232 | 
            +
              to: 'developers@yourcompany.com'
         | 
| 233 | 
            +
            )
         | 
| 234 | 
            +
            ```
         | 
| 235 | 
            +
             | 
| 236 | 
            +
            ##### Custom channel
         | 
| 237 | 
            +
             | 
| 238 | 
            +
            If you want to send the notifications using a different channel not offered by this gem, you can implement your own class with a `publish` method.
         | 
| 239 | 
            +
             | 
| 240 | 
            +
            ```ruby
         | 
| 241 | 
            +
            require 'rollout'
         | 
| 242 | 
            +
             | 
| 243 | 
            +
            module YourApp
         | 
| 244 | 
            +
              class YourCustomChannel
         | 
| 245 | 
            +
                def initialize()
         | 
| 246 | 
            +
                  # provide here whatever you need for configuring your channel
         | 
| 247 | 
            +
                end
         | 
| 248 | 
            +
             | 
| 249 | 
            +
                def publish(text)
         | 
| 250 | 
            +
                  # Implement the way you want to publish the notification
         | 
| 251 | 
            +
                end
         | 
| 252 | 
            +
              end
         | 
| 253 | 
            +
            end
         | 
| 254 | 
            +
            ```
         | 
| 255 | 
            +
             | 
| 256 | 
            +
            After implementing it, you can pass it to the list of configured channels
         | 
| 257 | 
            +
             | 
| 258 | 
            +
            ```ruby
         | 
| 259 | 
            +
            your_channel = YourApp::YourCustomChannel.new(url: 'wadus', param2: 'foo')
         | 
| 260 | 
            +
            @rollout ||= Rollout.new(redis)
         | 
| 261 | 
            +
                          .with_cache
         | 
| 262 | 
            +
                          .with_degrade(min: 100, threshold: 0.1)
         | 
| 263 | 
            +
                          .with_notifications(
         | 
| 264 | 
            +
                            status_change: [your_channel],
         | 
| 265 | 
            +
                            degrade: [your_channel]
         | 
| 266 | 
            +
                          )
         | 
| 267 | 
            +
            ```
         | 
| 162 268 |  | 
| 163 269 | 
             
            ## Rake tasks
         | 
| 164 270 |  | 
| @@ -200,10 +306,20 @@ For listing all the stored feature flags, do: | |
| 200 306 | 
             
            bundle exec rake rollout:list
         | 
| 201 307 | 
             
            ```
         | 
| 202 308 |  | 
| 309 | 
            +
            For migrating feature flags stored using the old `rollout` gem format (check [migration guide](https://github.com/jcagarcia/rollout-redis/blob/main/MIGRATING_FROM_ROLLOUT_GEM.md)), do:
         | 
| 310 | 
            +
             | 
| 311 | 
            +
            ```shell
         | 
| 312 | 
            +
            bundle exec rake rollout:migrate_from_rollout_format
         | 
| 313 | 
            +
            ```
         | 
| 314 | 
            +
             | 
| 203 315 | 
             
            ## Migrating from rollout gem 🚨
         | 
| 204 316 |  | 
| 205 317 | 
             
            If you are currently using the unmaintained [rollout](https://github.com/fetlife/rollout) gem, you should consider checking this [migration guide](https://github.com/jcagarcia/rollout-redis/blob/main/MIGRATING_FROM_ROLLOUT_GEM.md) for start using the new `rollout-redis` gem.
         | 
| 206 318 |  | 
| 319 | 
            +
            ### Compatible payloads and keys
         | 
| 320 | 
            +
             | 
| 321 | 
            +
            You can use the `.with_old_rollout_gem_compatibility` instance method for making the `rollout-redis` gem work as the the discontinued [rollout](https://github.com/fetlife/rollout) gem in terms of redis storage and format storage. Check the [migration guide](https://github.com/jcagarcia/rollout-redis/blob/main/MIGRATING_FROM_ROLLOUT_GEM.md) for more information.
         | 
| 322 | 
            +
             | 
| 207 323 | 
             
            ## Changelog
         | 
| 208 324 |  | 
| 209 325 | 
             
            If you're interested in seeing the changes and bug fixes between each version of `rollout-redis`, read the [Changelog](https://github.com/jcagarcia/rollout-redis/blob/main/CHANGELOG.md).
         | 
| @@ -226,7 +342,9 @@ We welcome and appreciate contributions from the open-source community. Before y | |
| 226 342 |  | 
| 227 343 | 
             
            ### Development
         | 
| 228 344 |  | 
| 229 | 
            -
            This project is dockerized | 
| 345 | 
            +
            This project is dockerized, so be sure you have docker installed in your machine. 
         | 
| 346 | 
            +
             | 
| 347 | 
            +
            Once you clone the repository, you can use the `Make` commands to build the project.
         | 
| 230 348 |  | 
| 231 349 | 
             
            ```shell
         | 
| 232 350 | 
             
            make build
         | 
    
        data/lib/rollout/feature.rb
    CHANGED
    
    | @@ -4,7 +4,7 @@ require 'zlib' | |
| 4 4 |  | 
| 5 5 | 
             
            class Rollout
         | 
| 6 6 | 
             
                class Feature
         | 
| 7 | 
            -
                  attr_accessor :percentage
         | 
| 7 | 
            +
                  attr_accessor :percentage, :degrade
         | 
| 8 8 | 
             
                  attr_reader :name, :data
         | 
| 9 9 |  | 
| 10 10 | 
             
                  RAND_BASE = (2**32 - 1) / 100.0
         | 
| @@ -13,6 +13,7 @@ class Rollout | |
| 13 13 | 
             
                    @name = name
         | 
| 14 14 | 
             
                    @data = data
         | 
| 15 15 | 
             
                    @percentage = @data[:percentage]
         | 
| 16 | 
            +
                    @degrade = @data[:degrade]
         | 
| 16 17 | 
             
                  end
         | 
| 17 18 |  | 
| 18 19 | 
             
                  def active?(determinator=nil)
         | 
| @@ -48,7 +49,7 @@ class Rollout | |
| 48 49 | 
             
                  end
         | 
| 49 50 |  | 
| 50 51 | 
             
                  def to_h
         | 
| 51 | 
            -
                    {
         | 
| 52 | 
            +
                    h = {
         | 
| 52 53 | 
             
                      name: @name,
         | 
| 53 54 | 
             
                      percentage: @percentage,
         | 
| 54 55 | 
             
                      data: {
         | 
| @@ -56,6 +57,12 @@ class Rollout | |
| 56 57 | 
             
                        errors: errors
         | 
| 57 58 | 
             
                      }
         | 
| 58 59 | 
             
                    }
         | 
| 60 | 
            +
             | 
| 61 | 
            +
                    h.merge!({
         | 
| 62 | 
            +
                      degrade: @degrade
         | 
| 63 | 
            +
                    }) if @degrade
         | 
| 64 | 
            +
             | 
| 65 | 
            +
                    h
         | 
| 59 66 | 
             
                  end
         | 
| 60 67 |  | 
| 61 68 | 
             
                  private
         | 
| @@ -0,0 +1,27 @@ | |
| 1 | 
            +
            require 'mail'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class Rollout
         | 
| 4 | 
            +
              module Notifications
         | 
| 5 | 
            +
                module Channels
         | 
| 6 | 
            +
                  class Email
         | 
| 7 | 
            +
                    def initialize(smtp_host:, smtp_port:, from:'no-reply@rollout-redis.com', to:)
         | 
| 8 | 
            +
                      @smtp_host = smtp_host
         | 
| 9 | 
            +
                      @smtp_port = smtp_port
         | 
| 10 | 
            +
                      @from = from
         | 
| 11 | 
            +
                      @to = to
         | 
| 12 | 
            +
                    end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                    def publish(subject, body)
         | 
| 15 | 
            +
                      mail = Mail.new do
         | 
| 16 | 
            +
                        subject  subject
         | 
| 17 | 
            +
                        body     body
         | 
| 18 | 
            +
                      end
         | 
| 19 | 
            +
                      mail.smtp_envelope_from = @from
         | 
| 20 | 
            +
                      mail.smtp_envelope_to = @to
         | 
| 21 | 
            +
                      mail.delivery_method :smtp, address: @smtp_host, port: @smtp_port
         | 
| 22 | 
            +
                      mail.deliver
         | 
| 23 | 
            +
                    end
         | 
| 24 | 
            +
                  end
         | 
| 25 | 
            +
                end
         | 
| 26 | 
            +
              end
         | 
| 27 | 
            +
            end 
         | 
| @@ -0,0 +1,41 @@ | |
| 1 | 
            +
            require 'slack-notifier'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class Rollout
         | 
| 4 | 
            +
              module Notifications
         | 
| 5 | 
            +
                module Channels
         | 
| 6 | 
            +
                  class Slack
         | 
| 7 | 
            +
                    def initialize(webhook_url:, channel:, username:'rollout-redis')
         | 
| 8 | 
            +
                      @webhook_url = webhook_url
         | 
| 9 | 
            +
                      @channel = channel
         | 
| 10 | 
            +
                      @username = username
         | 
| 11 | 
            +
                    end
         | 
| 12 | 
            +
             | 
| 13 | 
            +
                    def publish(text)
         | 
| 14 | 
            +
                      begin
         | 
| 15 | 
            +
                        blocks = [
         | 
| 16 | 
            +
                          {
         | 
| 17 | 
            +
                            "type": "section",
         | 
| 18 | 
            +
                            "text": {
         | 
| 19 | 
            +
                              "type": "mrkdwn",
         | 
| 20 | 
            +
                              "text": text
         | 
| 21 | 
            +
                            }
         | 
| 22 | 
            +
                          }
         | 
| 23 | 
            +
                        ]
         | 
| 24 | 
            +
                        slack_notifier.post(blocks: blocks)
         | 
| 25 | 
            +
                      rescue => e
         | 
| 26 | 
            +
                        puts "[ERROR] Error sending notification to slack webhook. Error => #{e}"
         | 
| 27 | 
            +
                      end
         | 
| 28 | 
            +
                    end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                    private
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    def slack_notifier
         | 
| 33 | 
            +
                      @notifier ||= ::Slack::Notifier.new @webhook_url do
         | 
| 34 | 
            +
                        defaults channel: @channel,
         | 
| 35 | 
            +
                                 username: @username
         | 
| 36 | 
            +
                      end
         | 
| 37 | 
            +
                    end
         | 
| 38 | 
            +
                  end
         | 
| 39 | 
            +
                end
         | 
| 40 | 
            +
              end
         | 
| 41 | 
            +
            end
         | 
| @@ -0,0 +1,36 @@ | |
| 1 | 
            +
            require_relative 'base'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class Rollout
         | 
| 4 | 
            +
              module Notifications
         | 
| 5 | 
            +
                module Notifiers
         | 
| 6 | 
            +
                  class Degrade < Base
         | 
| 7 | 
            +
                    def initialize(channels)
         | 
| 8 | 
            +
                      super(channels)
         | 
| 9 | 
            +
                    end
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                    def notify(feature_name, requests, errors)
         | 
| 12 | 
            +
                      @channels.each do |c|
         | 
| 13 | 
            +
                        if c.class == Rollout::Notifications::Channels::Email
         | 
| 14 | 
            +
                          publish_to_email_channel(c, feature_name, requests, errors)
         | 
| 15 | 
            +
                        else
         | 
| 16 | 
            +
                          publish_to_channel(c, feature_name, requests, errors)
         | 
| 17 | 
            +
                        end
         | 
| 18 | 
            +
                      end
         | 
| 19 | 
            +
                    end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    private
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                    def publish_to_channel(c, feature_name, requests, errors)
         | 
| 24 | 
            +
                      text = "Feature flag '#{feature_name}' has been degraded after #{requests} requests and #{errors} errors"
         | 
| 25 | 
            +
                      c.publish(text)
         | 
| 26 | 
            +
                    end
         | 
| 27 | 
            +
             | 
| 28 | 
            +
                    def publish_to_email_channel(c, feature_name, requests, errors)
         | 
| 29 | 
            +
                      subject = 'Feature flag has been automatically deactivated!'
         | 
| 30 | 
            +
                      content = "Feature flag '#{feature_name}' has been degraded after #{requests} requests and #{errors} errors"
         | 
| 31 | 
            +
                      c.publish(subject, content)
         | 
| 32 | 
            +
                    end
         | 
| 33 | 
            +
                  end
         | 
| 34 | 
            +
                end
         | 
| 35 | 
            +
              end
         | 
| 36 | 
            +
            end
         | 
| @@ -0,0 +1,45 @@ | |
| 1 | 
            +
            require_relative 'base'
         | 
| 2 | 
            +
             | 
| 3 | 
            +
            class Rollout
         | 
| 4 | 
            +
              module Notifications
         | 
| 5 | 
            +
                module Notifiers
         | 
| 6 | 
            +
                  class StatusChange < Base
         | 
| 7 | 
            +
                    def initialize(channels)
         | 
| 8 | 
            +
                      super(channels)
         | 
| 9 | 
            +
                    end
         | 
| 10 | 
            +
             | 
| 11 | 
            +
                    def notify(feature_name, new_status, new_percentage=nil)
         | 
| 12 | 
            +
                      @channels.each do |c|
         | 
| 13 | 
            +
                        if c.class == Rollout::Notifications::Channels::Email
         | 
| 14 | 
            +
                          publish_to_email_channel(c, feature_name, new_status, new_percentage)
         | 
| 15 | 
            +
                        else
         | 
| 16 | 
            +
                          publish_to_channel(c, feature_name, new_status, new_percentage)
         | 
| 17 | 
            +
                        end
         | 
| 18 | 
            +
                      end
         | 
| 19 | 
            +
                    end
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                    private
         | 
| 22 | 
            +
             | 
| 23 | 
            +
                    def publish_to_channel(c, feature_name, new_status, new_percentage)
         | 
| 24 | 
            +
                      if new_status == :activated
         | 
| 25 | 
            +
                        text = "Feature flag '#{feature_name}' has been activated with percentage #{new_percentage}!"
         | 
| 26 | 
            +
                      elsif new_status == :deactivated
         | 
| 27 | 
            +
                        text = "Feature flag '#{feature_name}' has been deactivated and deleted!"
         | 
| 28 | 
            +
                      end
         | 
| 29 | 
            +
                      c.publish(text)
         | 
| 30 | 
            +
                    end
         | 
| 31 | 
            +
             | 
| 32 | 
            +
                    def publish_to_email_channel(c, feature_name, new_status, new_percentage)
         | 
| 33 | 
            +
                      if new_status == :activated
         | 
| 34 | 
            +
                        subject = 'Feature flag has been activated!'
         | 
| 35 | 
            +
                        content = "Feature flag '#{feature_name}' has been activated with percentage #{new_percentage}!"
         | 
| 36 | 
            +
                      elsif new_status == :deactivated
         | 
| 37 | 
            +
                        subject = 'Feature flag has been deactivated!'
         | 
| 38 | 
            +
                        content = "Feature flag '#{feature_name}' has been deactivated and deleted!"
         | 
| 39 | 
            +
                      end
         | 
| 40 | 
            +
                      c.publish(subject, content)
         | 
| 41 | 
            +
                    end
         | 
| 42 | 
            +
                  end
         | 
| 43 | 
            +
                end
         | 
| 44 | 
            +
              end
         | 
| 45 | 
            +
            end
         | 
| @@ -1,13 +1,20 @@ | |
| 1 1 |  | 
| 2 2 | 
             
            namespace :rollout do
         | 
| 3 3 | 
             
              desc "Activate a feature"
         | 
| 4 | 
            -
              task :on, [:feature, :percentage] => :environment do |task, args|
         | 
| 4 | 
            +
              task :on, [:feature, :percentage, :degrade_min, :degrade_threshold] => :environment do |task, args|
         | 
| 5 5 | 
             
                if args.feature
         | 
| 6 | 
            -
                  puts "Activating feature #{args.feature}..."
         | 
| 7 6 | 
             
                  if args.percentage
         | 
| 8 | 
            -
                     | 
| 7 | 
            +
                    percentage = args.percentage.to_i
         | 
| 9 8 | 
             
                  else
         | 
| 10 | 
            -
                     | 
| 9 | 
            +
                    percentage = 100
         | 
| 10 | 
            +
                  end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
                  if args.degrade_min && args.degrade_threshold
         | 
| 13 | 
            +
                    puts "Activating feature #{args.feature} at #{percentage}% (degrade config set to a min of #{args.degrade_min} requests and a threshold of error of #{args.degrade_threshold.to_f*100}%)..."
         | 
| 14 | 
            +
                    activated = rollout.activate(args.feature, percentage, degrade: { min: args.degrade_min.to_i, threshold: args.degrade_threshold.to_f})
         | 
| 15 | 
            +
                  else
         | 
| 16 | 
            +
                    puts "Activating feature #{args.feature} at #{percentage}%..."
         | 
| 17 | 
            +
                    activated = rollout.activate(args.feature, percentage)
         | 
| 11 18 | 
             
                  end
         | 
| 12 19 |  | 
| 13 20 | 
             
                  if activated
         | 
| @@ -43,6 +50,13 @@ namespace :rollout do | |
| 43 50 | 
             
                end
         | 
| 44 51 | 
             
              end
         | 
| 45 52 |  | 
| 53 | 
            +
              desc "Migrate stored feature flags to the new format without removing the old information"
         | 
| 54 | 
            +
              task migrate_from_rollout_format: :environment do
         | 
| 55 | 
            +
                puts "Starting the migration..."
         | 
| 56 | 
            +
                rollout.migrate_from_rollout_format
         | 
| 57 | 
            +
                puts "Migration has finished!"
         | 
| 58 | 
            +
              end
         | 
| 59 | 
            +
             | 
| 46 60 | 
             
              private
         | 
| 47 61 |  | 
| 48 62 | 
             
              def rollout
         | 
    
        data/lib/rollout/version.rb
    CHANGED
    
    
    
        data/lib/rollout.rb
    CHANGED
    
    | @@ -1,10 +1,16 @@ | |
| 1 1 | 
             
            # frozen_string_literal: true
         | 
| 2 2 |  | 
| 3 | 
            -
            require 'rollout/feature'
         | 
| 4 | 
            -
            require 'rollout/version'
         | 
| 5 3 | 
             
            require 'redis'
         | 
| 6 4 | 
             
            require 'json'
         | 
| 7 5 |  | 
| 6 | 
            +
            require 'rollout/feature'
         | 
| 7 | 
            +
            require 'rollout/notifications/channels/email'
         | 
| 8 | 
            +
            require 'rollout/notifications/channels/slack'
         | 
| 9 | 
            +
            require 'rollout/notifications/notifiers/degrade'
         | 
| 10 | 
            +
            require 'rollout/notifications/notifiers/status_change'
         | 
| 11 | 
            +
            require 'rollout/version'
         | 
| 12 | 
            +
             | 
| 13 | 
            +
             | 
| 8 14 | 
             
            class Rollout
         | 
| 9 15 |  | 
| 10 16 | 
             
              class Error < StandardError; end
         | 
| @@ -15,6 +21,8 @@ class Rollout | |
| 15 21 | 
             
                @storage = storage
         | 
| 16 22 | 
             
                @cache_enabled = false
         | 
| 17 23 | 
             
                @degrade_enabled = false
         | 
| 24 | 
            +
                @old_gem_compatibility_enabled = false
         | 
| 25 | 
            +
                @auto_migrate_from_old_format = false
         | 
| 18 26 | 
             
              end
         | 
| 19 27 |  | 
| 20 28 | 
             
              def with_cache(expires_in: 300)
         | 
| @@ -33,14 +41,56 @@ class Rollout | |
| 33 41 | 
             
                self
         | 
| 34 42 | 
             
              end
         | 
| 35 43 |  | 
| 36 | 
            -
              def  | 
| 44 | 
            +
              def with_notifications(status_change:[], degrade:[])
         | 
| 45 | 
            +
                status_change_channels = status_change
         | 
| 46 | 
            +
                degrade_channels = degrade
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                if !status_change_channels.empty?
         | 
| 49 | 
            +
                  status_change_channels.each do |c|
         | 
| 50 | 
            +
                    raise Rollout::Error.new("Channel #{c.class.name} does not implement `publish` method") unless c.respond_to?(:publish)
         | 
| 51 | 
            +
                  end
         | 
| 52 | 
            +
                  @status_change_notifier = Notifications::Notifiers::StatusChange.new(status_change_channels)
         | 
| 53 | 
            +
                end
         | 
| 54 | 
            +
             | 
| 55 | 
            +
                if !degrade_channels.empty?
         | 
| 56 | 
            +
                  degrade_channels.each do |c|
         | 
| 57 | 
            +
                    raise Rollout::Error.new("Channel #{c.class.name} does not implement `publish` method") unless c.respond_to?(:publish)
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
                  @degrade_notifier = Notifications::Notifiers::Degrade.new(degrade_channels)
         | 
| 60 | 
            +
                end
         | 
| 61 | 
            +
             | 
| 62 | 
            +
                self
         | 
| 63 | 
            +
              end
         | 
| 64 | 
            +
             | 
| 65 | 
            +
              def with_old_rollout_gem_compatibility(auto_migration: false)
         | 
| 66 | 
            +
                @old_gem_compatibility_enabled = true
         | 
| 67 | 
            +
                @auto_migrate_from_old_format = auto_migration
         | 
| 68 | 
            +
             | 
| 69 | 
            +
                self
         | 
| 70 | 
            +
              end
         | 
| 71 | 
            +
             | 
| 72 | 
            +
              def activate(feature_name, percentage=100, degrade: nil)
         | 
| 37 73 | 
             
                data = { percentage: percentage }
         | 
| 74 | 
            +
                data.merge!({
         | 
| 75 | 
            +
                  degrade: {
         | 
| 76 | 
            +
                    min: degrade[:min] || 0,
         | 
| 77 | 
            +
                    threshold: degrade[:threshold] || 0
         | 
| 78 | 
            +
                  }
         | 
| 79 | 
            +
                }) if degrade
         | 
| 80 | 
            +
             | 
| 38 81 | 
             
                feature = Feature.new(feature_name, data)
         | 
| 39 | 
            -
                 | 
| 40 | 
            -
             | 
| 41 | 
            -
             | 
| 42 | 
            -
             | 
| 43 | 
            -
             | 
| 82 | 
            +
                result = save(feature) == "OK"
         | 
| 83 | 
            +
             | 
| 84 | 
            +
                if result
         | 
| 85 | 
            +
                  @cache[feature_name] = {
         | 
| 86 | 
            +
                    feature: feature,
         | 
| 87 | 
            +
                    timestamp: Time.now.to_i
         | 
| 88 | 
            +
                  } if @cache_enabled
         | 
| 89 | 
            +
             | 
| 90 | 
            +
                  @status_change_notifier&.notify(feature_name, :activated, percentage)
         | 
| 91 | 
            +
                end
         | 
| 92 | 
            +
             | 
| 93 | 
            +
                result
         | 
| 44 94 | 
             
              end
         | 
| 45 95 |  | 
| 46 96 | 
             
              def activate_percentage(feature_name, percentage)
         | 
| @@ -48,16 +98,27 @@ class Rollout | |
| 48 98 | 
             
              end
         | 
| 49 99 |  | 
| 50 100 | 
             
              def deactivate(feature_name)
         | 
| 51 | 
            -
                del(feature_name)
         | 
| 101 | 
            +
                result = del(feature_name)
         | 
| 102 | 
            +
             | 
| 103 | 
            +
                @status_change_notifier&.notify(feature_name, :deactivated)
         | 
| 104 | 
            +
             | 
| 105 | 
            +
                result
         | 
| 52 106 | 
             
              end
         | 
| 53 107 |  | 
| 54 108 | 
             
              def active?(feature_name, determinator = nil)
         | 
| 55 | 
            -
                feature = get(feature_name | 
| 109 | 
            +
                feature = get(feature_name)
         | 
| 110 | 
            +
                if feature.nil? && @old_gem_compatibility_enabled
         | 
| 111 | 
            +
                  feature = get_with_old_format(feature_name)
         | 
| 112 | 
            +
                  if feature && @auto_migrate_from_old_format
         | 
| 113 | 
            +
                    activate(feature_name, feature.percentage)
         | 
| 114 | 
            +
                  end
         | 
| 115 | 
            +
                end
         | 
| 116 | 
            +
             | 
| 56 117 | 
             
                return false unless feature
         | 
| 57 118 |  | 
| 58 119 | 
             
                active = feature.active?(determinator)
         | 
| 59 120 |  | 
| 60 | 
            -
                if active &&  | 
| 121 | 
            +
                if active && degrade_enabled?(feature)
         | 
| 61 122 | 
             
                  feature.add_request
         | 
| 62 123 | 
             
                  save(feature)
         | 
| 63 124 | 
             
                end
         | 
| @@ -67,13 +128,15 @@ class Rollout | |
| 67 128 |  | 
| 68 129 | 
             
              def with_feature_flag(feature_name, determinator = nil, &block)
         | 
| 69 130 | 
             
                yield if active?(feature_name, determinator)
         | 
| 131 | 
            +
              rescue Rollout::Error => e
         | 
| 132 | 
            +
                raise
         | 
| 70 133 | 
             
              rescue => e
         | 
| 71 | 
            -
                feature = get(feature_name | 
| 72 | 
            -
                if  | 
| 134 | 
            +
                feature = get(feature_name)
         | 
| 135 | 
            +
                if feature && degrade_enabled?(feature)
         | 
| 73 136 | 
             
                  feature.add_error
         | 
| 74 137 | 
             
                  save(feature)
         | 
| 75 138 |  | 
| 76 | 
            -
                   | 
| 139 | 
            +
                  degrade(feature_name) if degraded?(feature)
         | 
| 77 140 | 
             
                end
         | 
| 78 141 | 
             
                raise e
         | 
| 79 142 | 
             
              end
         | 
| @@ -115,14 +178,18 @@ class Rollout | |
| 115 178 |  | 
| 116 179 | 
             
                    @storage.set(new_key, new_data)
         | 
| 117 180 |  | 
| 118 | 
            -
                    puts "Migrated key | 
| 181 | 
            +
                    puts "Migrated redis key from #{old_key} to #{new_key}. Migrating data from '#{old_data}' to '#{new_data}'."
         | 
| 182 | 
            +
             | 
| 183 | 
            +
                    if percentage > 0
         | 
| 184 | 
            +
                      @status_change_notifier&.notify(new_key.gsub('feature-rollout-redis:', ''), :activated, percentage)
         | 
| 185 | 
            +
                    end
         | 
| 119 186 | 
             
                  end
         | 
| 120 187 | 
             
                end
         | 
| 121 188 | 
             
              end
         | 
| 122 189 |  | 
| 123 190 | 
             
              private
         | 
| 124 191 |  | 
| 125 | 
            -
              def get(feature_name | 
| 192 | 
            +
              def get(feature_name)
         | 
| 126 193 | 
             
                feature = from_redis(feature_name)
         | 
| 127 194 | 
             
                return unless feature
         | 
| 128 195 |  | 
| @@ -139,6 +206,15 @@ class Rollout | |
| 139 206 | 
             
                cached_feature
         | 
| 140 207 | 
             
              end
         | 
| 141 208 |  | 
| 209 | 
            +
              def get_with_old_format(feature_name)
         | 
| 210 | 
            +
                feature = from_redis_with_old_format(feature_name)
         | 
| 211 | 
            +
                return unless feature
         | 
| 212 | 
            +
             | 
| 213 | 
            +
                feature
         | 
| 214 | 
            +
              rescue ::Redis::BaseError => e
         | 
| 215 | 
            +
                raise Rollout::Error.new(e)
         | 
| 216 | 
            +
              end
         | 
| 217 | 
            +
             | 
| 142 218 | 
             
              def save(feature)
         | 
| 143 219 | 
             
                @storage.set(key(feature.name), feature.data.to_json)
         | 
| 144 220 | 
             
              end
         | 
| @@ -147,6 +223,22 @@ class Rollout | |
| 147 223 | 
             
                @storage.del(key(feature_name)) == 1
         | 
| 148 224 | 
             
              end
         | 
| 149 225 |  | 
| 226 | 
            +
              def degrade(feature_name)
         | 
| 227 | 
            +
                feature = get(feature_name)
         | 
| 228 | 
            +
                data_with_degrade = feature.data.merge({
         | 
| 229 | 
            +
                  percentage: 0,
         | 
| 230 | 
            +
                  degraded: true,
         | 
| 231 | 
            +
                  degraded_at: Time.now
         | 
| 232 | 
            +
                })
         | 
| 233 | 
            +
                result = @storage.set(key(feature.name), data_with_degrade.to_json) == "OK"
         | 
| 234 | 
            +
             | 
| 235 | 
            +
                if result
         | 
| 236 | 
            +
                  @degrade_notifier.notify(feature_name, feature.requests, feature.errors)
         | 
| 237 | 
            +
                end
         | 
| 238 | 
            +
             | 
| 239 | 
            +
                result
         | 
| 240 | 
            +
              end
         | 
| 241 | 
            +
             | 
| 150 242 | 
             
              def from_cache(feature_name)
         | 
| 151 243 | 
             
                return nil unless @cache_enabled
         | 
| 152 244 |  | 
| @@ -166,22 +258,58 @@ class Rollout | |
| 166 258 | 
             
                Feature.new(feature_name, JSON.parse(data, symbolize_names: true))
         | 
| 167 259 | 
             
              end
         | 
| 168 260 |  | 
| 261 | 
            +
              def from_redis_with_old_format(feature_name)
         | 
| 262 | 
            +
                old_data = @storage.get(old_key(feature_name))
         | 
| 263 | 
            +
                return unless old_data
         | 
| 264 | 
            +
             | 
| 265 | 
            +
                percentage = old_data.split('|')[0].to_i
         | 
| 266 | 
            +
             | 
| 267 | 
            +
                new_data = {
         | 
| 268 | 
            +
                  percentage: percentage,
         | 
| 269 | 
            +
                  requests: 0,
         | 
| 270 | 
            +
                  errors: 0
         | 
| 271 | 
            +
                }
         | 
| 272 | 
            +
             | 
| 273 | 
            +
                Feature.new(feature_name, new_data)
         | 
| 274 | 
            +
              end
         | 
| 275 | 
            +
             | 
| 169 276 | 
             
              def expired?(timestamp)
         | 
| 170 277 | 
             
                Time.now.to_i - timestamp > @cache_time
         | 
| 171 278 | 
             
              end
         | 
| 172 279 |  | 
| 173 280 | 
             
              def degraded?(feature)
         | 
| 174 | 
            -
                return false if  | 
| 175 | 
            -
             | 
| 281 | 
            +
                return false if !degrade_enabled?(feature)
         | 
| 282 | 
            +
             | 
| 283 | 
            +
                if feature.degrade
         | 
| 284 | 
            +
                  degrade_min = feature.degrade[:min]
         | 
| 285 | 
            +
                  degrade_threshold = feature.degrade[:threshold]
         | 
| 286 | 
            +
                else
         | 
| 287 | 
            +
                  degrade_min = @degrade_min
         | 
| 288 | 
            +
                  degrade_threshold = @degrade_threshold
         | 
| 289 | 
            +
                end
         | 
| 176 290 |  | 
| 177 | 
            -
                 | 
| 291 | 
            +
                return false if feature.requests < degrade_min
         | 
| 292 | 
            +
             | 
| 293 | 
            +
                feature.errors > degrade_threshold * feature.requests
         | 
| 294 | 
            +
              end
         | 
| 295 | 
            +
             | 
| 296 | 
            +
              def degrade_enabled?(feature)
         | 
| 297 | 
            +
                @degrade_enabled || !feature.degrade.nil?
         | 
| 178 298 | 
             
              end
         | 
| 179 299 |  | 
| 180 300 | 
             
              def key(name)
         | 
| 181 301 | 
             
                "#{key_prefix}:#{name}"
         | 
| 182 302 | 
             
              end
         | 
| 183 303 |  | 
| 304 | 
            +
              def old_key(name)
         | 
| 305 | 
            +
                "#{old_key_prefix}:#{name}"
         | 
| 306 | 
            +
              end
         | 
| 307 | 
            +
             | 
| 184 308 | 
             
              def key_prefix
         | 
| 185 309 | 
             
                "feature-rollout-redis"
         | 
| 186 310 | 
             
              end
         | 
| 311 | 
            +
             | 
| 312 | 
            +
              def old_key_prefix
         | 
| 313 | 
            +
                "feature"
         | 
| 314 | 
            +
              end
         | 
| 187 315 | 
             
            end
         | 
    
        data/rollout-redis.gemspec
    CHANGED
    
    | @@ -23,6 +23,8 @@ Gem::Specification.new do |spec| | |
| 23 23 | 
             
              spec.required_ruby_version = '>= 2.3'
         | 
| 24 24 |  | 
| 25 25 | 
             
              spec.add_runtime_dependency 'redis', '>= 4.0', '<= 5'
         | 
| 26 | 
            +
              spec.add_runtime_dependency 'slack-notifier', '~> 2.4'
         | 
| 27 | 
            +
              spec.add_runtime_dependency 'mail', '~> 2.8'
         | 
| 26 28 |  | 
| 27 29 | 
             
              spec.add_development_dependency 'bundler', '>= 2.4'
         | 
| 28 30 | 
             
              spec.add_development_dependency 'rspec', '~> 3.12'
         | 
    
        metadata
    CHANGED
    
    | @@ -1,14 +1,14 @@ | |
| 1 1 | 
             
            --- !ruby/object:Gem::Specification
         | 
| 2 2 | 
             
            name: rollout-redis
         | 
| 3 3 | 
             
            version: !ruby/object:Gem::Version
         | 
| 4 | 
            -
              version:  | 
| 4 | 
            +
              version: 1.1.0
         | 
| 5 5 | 
             
            platform: ruby
         | 
| 6 6 | 
             
            authors:
         | 
| 7 7 | 
             
            - Juan Carlos García
         | 
| 8 8 | 
             
            autorequire: 
         | 
| 9 9 | 
             
            bindir: bin
         | 
| 10 10 | 
             
            cert_chain: []
         | 
| 11 | 
            -
            date: 2023-10- | 
| 11 | 
            +
            date: 2023-10-28 00:00:00.000000000 Z
         | 
| 12 12 | 
             
            dependencies:
         | 
| 13 13 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 14 14 | 
             
              name: redis
         | 
| @@ -30,6 +30,34 @@ dependencies: | |
| 30 30 | 
             
                - - "<="
         | 
| 31 31 | 
             
                  - !ruby/object:Gem::Version
         | 
| 32 32 | 
             
                    version: '5'
         | 
| 33 | 
            +
            - !ruby/object:Gem::Dependency
         | 
| 34 | 
            +
              name: slack-notifier
         | 
| 35 | 
            +
              requirement: !ruby/object:Gem::Requirement
         | 
| 36 | 
            +
                requirements:
         | 
| 37 | 
            +
                - - "~>"
         | 
| 38 | 
            +
                  - !ruby/object:Gem::Version
         | 
| 39 | 
            +
                    version: '2.4'
         | 
| 40 | 
            +
              type: :runtime
         | 
| 41 | 
            +
              prerelease: false
         | 
| 42 | 
            +
              version_requirements: !ruby/object:Gem::Requirement
         | 
| 43 | 
            +
                requirements:
         | 
| 44 | 
            +
                - - "~>"
         | 
| 45 | 
            +
                  - !ruby/object:Gem::Version
         | 
| 46 | 
            +
                    version: '2.4'
         | 
| 47 | 
            +
            - !ruby/object:Gem::Dependency
         | 
| 48 | 
            +
              name: mail
         | 
| 49 | 
            +
              requirement: !ruby/object:Gem::Requirement
         | 
| 50 | 
            +
                requirements:
         | 
| 51 | 
            +
                - - "~>"
         | 
| 52 | 
            +
                  - !ruby/object:Gem::Version
         | 
| 53 | 
            +
                    version: '2.8'
         | 
| 54 | 
            +
              type: :runtime
         | 
| 55 | 
            +
              prerelease: false
         | 
| 56 | 
            +
              version_requirements: !ruby/object:Gem::Requirement
         | 
| 57 | 
            +
                requirements:
         | 
| 58 | 
            +
                - - "~>"
         | 
| 59 | 
            +
                  - !ruby/object:Gem::Version
         | 
| 60 | 
            +
                    version: '2.8'
         | 
| 33 61 | 
             
            - !ruby/object:Gem::Dependency
         | 
| 34 62 | 
             
              name: bundler
         | 
| 35 63 | 
             
              requirement: !ruby/object:Gem::Requirement
         | 
| @@ -98,6 +126,11 @@ files: | |
| 98 126 | 
             
            - Rakefile
         | 
| 99 127 | 
             
            - lib/rollout.rb
         | 
| 100 128 | 
             
            - lib/rollout/feature.rb
         | 
| 129 | 
            +
            - lib/rollout/notifications/channels/email.rb
         | 
| 130 | 
            +
            - lib/rollout/notifications/channels/slack.rb
         | 
| 131 | 
            +
            - lib/rollout/notifications/notifiers/base.rb
         | 
| 132 | 
            +
            - lib/rollout/notifications/notifiers/degrade.rb
         | 
| 133 | 
            +
            - lib/rollout/notifications/notifiers/status_change.rb
         | 
| 101 134 | 
             
            - lib/rollout/tasks/rollout.rake
         | 
| 102 135 | 
             
            - lib/rollout/version.rb
         | 
| 103 136 | 
             
            - rollout-redis.gemspec
         |