├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── cacheflow.gemspec ├── gemfiles ├── activesupport71.gemfile └── activesupport72.gemfile ├── lib ├── cacheflow.rb └── cacheflow │ ├── memcached.rb │ ├── railtie.rb │ ├── redis.rb │ ├── sidekiq.rb │ └── version.rb └── test ├── memcached_test.rb ├── redis_test.rb └── test_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - ruby: 3.4 11 | gemfile: Gemfile 12 | redis: latest 13 | - ruby: 3.3 14 | gemfile: gemfiles/activesupport72.gemfile 15 | - ruby: 3.2 16 | gemfile: gemfiles/activesupport71.gemfile 17 | env: 18 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | 26 | - if: ${{ matrix.redis == 'latest' }} 27 | run: | 28 | curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg 29 | echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list 30 | 31 | - run: | 32 | sudo apt-get update 33 | sudo apt-get install memcached redis-server valkey-server 34 | sudo systemctl start memcached 35 | sudo systemctl start redis-server 36 | redis-cli info | grep version 37 | - run: bundle exec rake test 38 | 39 | - run: | 40 | sudo systemctl stop redis-server 41 | sleep 3 42 | sudo systemctl start valkey-server 43 | valkey-cli info | grep version 44 | - run: bundle exec rake test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.lock 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.5.0 (2025-05-05) 2 | 3 | - Dropped support for Ruby < 3.2 and Active Support < 7.1 4 | 5 | ## 0.4.0 (2024-10-22) 6 | 7 | - Improved binary data detection 8 | - Dropped support for Ruby < 3.1 and Active Support < 7 9 | 10 | ## 0.3.3 (2024-02-27) 11 | 12 | - Fixed error with binary data 13 | 14 | ## 0.3.2 (2023-12-07) 15 | 16 | - Fixed deprecation warning with Active Support 7.1 17 | 18 | ## 0.3.1 (2023-02-15) 19 | 20 | - Fixed error with `redis-client` 21 | 22 | ## 0.3.0 (2022-09-05) 23 | 24 | - Added support for `redis` 5 and `redis-client` gems 25 | - Dropped support for Ruby < 2.7 and Active Support < 6 26 | 27 | ## 0.2.1 (2022-01-12) 28 | 29 | - Fixed deprecation warning with Dalli 3 30 | 31 | ## 0.2.0 (2022-01-10) 32 | 33 | - Dropped support for Ruby < 2.6 and Active Support < 5.2 34 | 35 | ## 0.1.1 (2017-11-13) 36 | 37 | - Added `silence` method 38 | - Added `silence_sidekiq!` method 39 | 40 | ## 0.1.0 (2017-09-30) 41 | 42 | - First release 43 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "activesupport", "~> 8.0.0" 8 | gem "dalli" 9 | gem "redis" 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2025 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cacheflow 2 | 3 | Colorized logging for Memcached, Redis, and Valkey 4 | 5 | Works with the Rails cache, as well as [Dalli](https://github.com/petergoldstein/dalli) and [Redis](https://github.com/redis/redis-rb) clients directly 6 | 7 | [![Build Status](https://github.com/ankane/cacheflow/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/cacheflow/actions) 8 | 9 | ## Installation 10 | 11 | Add this line to your application’s Gemfile: 12 | 13 | ```ruby 14 | gem "cacheflow", group: :development 15 | ``` 16 | 17 | When your log level is set to `DEBUG` (Rails default in development), all commands to Memcached, Redis, and Valkey are logged. 18 | 19 | ## Features 20 | 21 | To silence logging, use: 22 | 23 | ```ruby 24 | Cacheflow.silence do 25 | # code 26 | end 27 | ``` 28 | 29 | To silence logging for [Sidekiq](https://github.com/mperham/sidekiq) commands, create an initializer with: 30 | 31 | ```ruby 32 | Cacheflow.silence_sidekiq! 33 | ``` 34 | 35 | ## Data Protection 36 | 37 | If you use Cacheflow in an environment with [personal data](https://en.wikipedia.org/wiki/Personally_identifiable_information) and store that data in Memcached, Redis, or Valkey, it can end up in your app logs. To avoid this, silence logging for those calls. 38 | 39 | ## History 40 | 41 | View the [changelog](https://github.com/ankane/cacheflow/blob/master/CHANGELOG.md) 42 | 43 | ## Contributing 44 | 45 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 46 | 47 | - [Report bugs](https://github.com/ankane/cacheflow/issues) 48 | - Fix bugs and [submit pull requests](https://github.com/ankane/cacheflow/pulls) 49 | - Write, clarify, or fix documentation 50 | - Suggest or add new features 51 | 52 | To get started with development: 53 | 54 | ```sh 55 | git clone https://github.com/ankane/cacheflow.git 56 | cd cacheflow 57 | bundle install 58 | bundle exec rake test 59 | ``` 60 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /cacheflow.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/cacheflow/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "cacheflow" 5 | spec.version = Cacheflow::VERSION 6 | spec.summary = "Colorized logging for Memcached, Redis, and Valkey" 7 | spec.homepage = "https://github.com/ankane/cacheflow" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | 18 | spec.add_dependency "activesupport", ">= 7.1" 19 | end 20 | -------------------------------------------------------------------------------- /gemfiles/activesupport71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "activesupport", "~> 7.1.0" 8 | gem "dalli", "~> 2" 9 | gem "redis", "~> 4" 10 | -------------------------------------------------------------------------------- /gemfiles/activesupport72.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "activesupport", "~> 7.2.0" 8 | gem "dalli" 9 | gem "redis" 10 | -------------------------------------------------------------------------------- /lib/cacheflow.rb: -------------------------------------------------------------------------------- 1 | # dependencies 2 | require "active_support" 3 | 4 | # modules 5 | require_relative "cacheflow/version" 6 | 7 | module Cacheflow 8 | def self.activate 9 | require_relative "cacheflow/memcached" if defined?(Dalli) 10 | require_relative "cacheflow/redis" if defined?(Redis) || defined?(RedisClient) 11 | end 12 | 13 | def self.silenced? 14 | Thread.current[:cacheflow_silenced] 15 | end 16 | 17 | def self.silence 18 | previous_value = silenced? 19 | begin 20 | Thread.current[:cacheflow_silenced] = true 21 | yield 22 | ensure 23 | Thread.current[:cacheflow_silenced] = previous_value 24 | end 25 | end 26 | 27 | def self.silence_sidekiq! 28 | require_relative "cacheflow/sidekiq" 29 | end 30 | 31 | # private 32 | def self.args(args) 33 | args.map { |v| binary?(v) ? "" : v }.join(" ") 34 | end 35 | 36 | # private 37 | def self.binary?(v) 38 | v = v.to_s 39 | # string encoding creates false positives, so try to determine based on value 40 | v.include?("\x00") || !v.dup.force_encoding(Encoding::UTF_8).valid_encoding? 41 | end 42 | end 43 | 44 | if defined?(Rails) 45 | require_relative "cacheflow/railtie" 46 | else 47 | Cacheflow.activate 48 | end 49 | -------------------------------------------------------------------------------- /lib/cacheflow/memcached.rb: -------------------------------------------------------------------------------- 1 | module Cacheflow 2 | module Memcached 3 | module Notifications 4 | def request(op, *args) 5 | payload = { 6 | op: op, 7 | args: args 8 | } 9 | ActiveSupport::Notifications.instrument("query.memcached", payload) do 10 | super 11 | end 12 | end 13 | end 14 | 15 | class Instrumenter < ActiveSupport::LogSubscriber 16 | def query(event) 17 | return if !logger.debug? || Cacheflow.silenced? 18 | 19 | name = "%s (%.2fms)" % ["Memcached", event.duration] 20 | debug " #{color(name, BLUE, bold: true)} #{event.payload[:op].to_s.upcase} #{Cacheflow.args(event.payload[:args])}" 21 | end 22 | end 23 | end 24 | end 25 | 26 | if defined?(Dalli::Protocol::Binary) 27 | Dalli::Protocol::Binary.prepend(Cacheflow::Memcached::Notifications) 28 | else 29 | Dalli::Server.prepend(Cacheflow::Memcached::Notifications) 30 | end 31 | Cacheflow::Memcached::Instrumenter.attach_to(:memcached) 32 | -------------------------------------------------------------------------------- /lib/cacheflow/railtie.rb: -------------------------------------------------------------------------------- 1 | module Cashflow 2 | class Railtie < Rails::Railtie 3 | initializer "cacheflow" do 4 | Cacheflow.activate 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/cacheflow/redis.rb: -------------------------------------------------------------------------------- 1 | module Cacheflow 2 | module Redis 3 | # redis 5 / redis-client 4 | module ClientNotifications 5 | def call(command, redis_config) 6 | payload = { 7 | commands: [command] 8 | } 9 | ActiveSupport::Notifications.instrument("query.redis", payload) do 10 | super 11 | end 12 | end 13 | 14 | def call_pipelined(commands, redis_config) 15 | payload = { 16 | commands: commands 17 | } 18 | ActiveSupport::Notifications.instrument("query.redis", payload) do 19 | super 20 | end 21 | end 22 | end 23 | 24 | # redis 4 25 | module Notifications 26 | def logging(commands) 27 | payload = { 28 | commands: commands 29 | } 30 | ActiveSupport::Notifications.instrument("query.redis", payload) do 31 | super 32 | end 33 | end 34 | end 35 | 36 | class Instrumenter < ActiveSupport::LogSubscriber 37 | def query(event) 38 | return if !logger.debug? || Cacheflow.silenced? 39 | 40 | name = "%s (%.2fms)" % ["Redis", event.duration] 41 | 42 | commands = [] 43 | event.payload[:commands].map do |op, *args| 44 | commands << "#{op.to_s.upcase} #{Cacheflow.args(args)}".strip 45 | end 46 | 47 | debug " #{color(name, RED, bold: true)} #{commands.join(" >> ")}" 48 | end 49 | end 50 | end 51 | end 52 | 53 | if defined?(RedisClient) 54 | RedisClient.register(Cacheflow::Redis::ClientNotifications) 55 | elsif defined?(Redis) 56 | # redis < 5 57 | Redis::Client.prepend(Cacheflow::Redis::Notifications) 58 | end 59 | 60 | Cacheflow::Redis::Instrumenter.attach_to(:redis) 61 | -------------------------------------------------------------------------------- /lib/cacheflow/sidekiq.rb: -------------------------------------------------------------------------------- 1 | module Cacheflow 2 | module Sidekiq 3 | module ClassMethods 4 | def redis(*_) 5 | Cacheflow.silence do 6 | super 7 | end 8 | end 9 | end 10 | 11 | module Client 12 | module InstanceMethods 13 | def push(*_) 14 | Cacheflow.silence do 15 | super 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | ::Sidekiq.singleton_class.prepend(Cacheflow::Sidekiq::ClassMethods) 24 | ::Sidekiq::Client.prepend(Cacheflow::Sidekiq::Client::InstanceMethods) 25 | -------------------------------------------------------------------------------- /lib/cacheflow/version.rb: -------------------------------------------------------------------------------- 1 | module Cacheflow 2 | VERSION = "0.5.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/memcached_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class MemcachedTest < Minitest::Test 4 | def test_set_get 5 | client.set("hello", "world") 6 | client.get("hello") 7 | 8 | assert_events({"query.memcached" => 2}) 9 | assert_commands ["SET hello world 0 0", "GET hello"] 10 | end 11 | 12 | def test_silence 13 | assert_silence do 14 | Cacheflow.silence do 15 | client.get("silence") 16 | end 17 | end 18 | end 19 | 20 | def test_active_support 21 | cache = ActiveSupport::Cache::MemCacheStore.new 22 | cache.write("hello", "world") 23 | cache.read("hello") 24 | assert_commands ["SET hello ", "GET hello"] 25 | end 26 | 27 | def client 28 | @client ||= Dalli::Client.new 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/redis_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class RedisTest < Minitest::Test 4 | def test_set_get 5 | client.set("hello", "world") 6 | client.get("hello") 7 | 8 | assert_events({"query.redis" => 2}) 9 | assert_commands ["SET hello world", "GET hello"] 10 | end 11 | 12 | def test_multi 13 | if Redis::VERSION.to_f >= 4.6 14 | client.multi do |pipeline| 15 | pipeline.set "foo", "bar" 16 | pipeline.incr "baz" 17 | end 18 | else 19 | client.multi do 20 | client.set "foo", "bar" 21 | client.incr "baz" 22 | end 23 | end 24 | 25 | assert_events({"query.redis" => 1}) 26 | assert_commands ["MULTI >> SET foo bar >> INCR baz >> EXEC"] 27 | end 28 | 29 | def test_redis_client 30 | skip unless defined?(RedisClient) 31 | 32 | redis = RedisClient.new 33 | redis.call("SET", "hello", "world") 34 | redis.call("GET", "hello") 35 | 36 | assert_events({"query.redis" => 3}) 37 | assert_commands ["HELLO 3", "SET hello world", "GET hello"] 38 | end 39 | 40 | def test_silence 41 | assert_silence do 42 | Cacheflow.silence do 43 | client.get("silence") 44 | end 45 | end 46 | end 47 | 48 | def test_active_support 49 | cache = ActiveSupport::Cache::RedisCacheStore.new 50 | cache.write("hello", "world") 51 | cache.read("hello") 52 | assert_commands ["SET hello ", "GET hello"] 53 | end 54 | 55 | def client 56 | @client ||= Redis.new 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "dalli" 3 | require "redis" 4 | require "active_support/all" 5 | 6 | if ActiveSupport::VERSION::STRING.to_f >= 7.2 7 | # fix circular require warning 8 | ActiveSupport.deprecator.behavior = :raise 9 | end 10 | 11 | Bundler.require(:default) 12 | require "minitest/autorun" 13 | require "minitest/pride" 14 | 15 | $io = StringIO.new(+"") 16 | ActiveSupport::LogSubscriber.logger = ActiveSupport::Logger.new($io) 17 | 18 | $events = Hash.new(0) 19 | ActiveSupport::Notifications.subscribe(/memcached|redis/) do |name, _start, _finish, _id, _payload| 20 | $events[name] += 1 21 | end 22 | 23 | ActiveSupport.cache_format_version = 24 | if [7.2, 8.0].include?(ActiveSupport::VERSION::STRING.to_f) 25 | 7.1 26 | else 27 | ActiveSupport::VERSION::STRING.to_f 28 | end 29 | 30 | class Minitest::Test 31 | def setup 32 | $events.clear 33 | $io.truncate(0) 34 | end 35 | 36 | def teardown 37 | if ENV["VERBOSE"] 38 | puts "#{self.class.name}##{name}" 39 | print $io.string 40 | end 41 | end 42 | 43 | def assert_events(expected) 44 | assert_equal expected, $events 45 | end 46 | 47 | def assert_commands(commands) 48 | commands.each do |command| 49 | assert_match command, $io.string 50 | end 51 | end 52 | 53 | def assert_silence 54 | yield 55 | assert_equal "", $io.string 56 | end 57 | end 58 | --------------------------------------------------------------------------------