├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .ruby-version ├── .rvmrc ├── Gemfile ├── LICENCE ├── README.markdown ├── amnesia.gemspec ├── config.ru ├── lib ├── amnesia.rb └── amnesia │ ├── authentication.rb │ ├── helpers.rb │ ├── host.rb │ ├── public │ └── css │ │ └── application.css │ ├── routes.rb │ └── views │ ├── host.haml │ ├── index.haml │ └── layout.haml ├── spec ├── amnesia_spec.rb └── spec_helper.rb └── support ├── amnesia-screen.jpg └── amnesia_v1.psd /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | ruby: [ '2.7', '3.0', '3.1', '3.2', 'head' ] 11 | steps: 12 | - name: Install memcache 13 | run: | 14 | sudo apt update 15 | sudo apt install -y memcached 16 | - name: Start memcached 17 | run: memcached -d 18 | - uses: actions/checkout@v3 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby }} 22 | bundler-cache: true 23 | - name: Run tests 24 | run: bundle exec rspec 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg 20 | 21 | ## PROJECT::SPECIFIC 22 | log/* 23 | Capfile 24 | config/ 25 | amnesia.yml 26 | 27 | ## Locking not important 28 | Gemfile.lock 29 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.0 2 | -------------------------------------------------------------------------------- /.rvmrc: -------------------------------------------------------------------------------- 1 | rvm 1.9.2@amnesia 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009— Ben Schwarz 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Amnesia 2 | 3 | Amnesia is what you get when you lose your memory. 4 | 5 | Hopefully with Amnesia you'll know exactly whats happening with memory when it comes to memcached. 6 | 7 | ![Amnesia screen shot](http://farm5.static.flickr.com/4125/5030135910_698fdb4514_z_d.jpg "Amnesia") 8 | 9 | ## Why? 10 | 11 | Its always nice to have some statistics to see how everything is performing within your stack. Memcached seems to be a mystery box that people don't really pay a lot of attention to. 12 | 13 | Amnesia tells you how your application is performing, when it misses, when it is running sweet, when you're about to run out of memcached and (perhaps) fall down in a screaming heap. 14 | 15 | ## What does it tell you? 16 | 17 | All stats are since each memcached instance was restarted 18 | 19 | Available as a cumulative result of all your memcached instances, or single instances alone: 20 | 21 | * Cache hits and misses 22 | * Reads and writes 23 | * Remaining memory 24 | 25 | 26 | Available for single instances only: 27 | 28 | * Amount of items stored in cache 29 | * Connections (current and total) 30 | * Accesses (Read / Write) 31 | * Accuracy (Hits / Misses) 32 | * Memory (Used / Total) 33 | 34 | ## Installation / Getting started 35 | 36 | gem install amnesia 37 | 38 | ### How to run it alongside your Rack application 39 | 40 | "config.ru": 41 | 42 | require 'amnesia' 43 | rack_app = Rack::Builder.app do 44 | map "/amnesia" do 45 | run Amnesia::Application.new 46 | end 47 | run YourSinatra::Application 48 | end 49 | run rack_app 50 | 51 | ### How to run it alongside your Rails application 52 | 53 | "Gemfile": 54 | 55 | gem 'amnesia', '>=1.0.2' 56 | 57 | 58 | "config/routes.rb": 59 | 60 | mount Amnesia::Application.new => "/amnesia" 61 | 62 | 63 | ### Then, cruise on over to `your-host.tld/amnesia` 64 | 65 | 66 | ## Configuration options 67 | 68 | ### Hosts 69 | Amnesia will work automagically if you drop it on a Heroku powered app, likewise—for a "standard" memcache host (running on localhost:11211, the default.). 70 | 71 | When you need to specify where your memcache hosts can be found, you can either set an environment variable: 72 | 73 | ENV["MEMCACHE_SERVERS"] = ['localhost:11211'] 74 | 75 | or alternately, you can set it within your `config.ru`: 76 | 77 | use Amnesia::Application, hosts: ["mc1.yourapp.com:11211", "mc2.yourapp.com:11211"] 78 | 79 | ### Authentication 80 | 81 | When you want to keep your Amnesia data private, you can set an environment variable that will enable http basic authentication: 82 | 83 | ENV["AMNESIA_CREDS"] = ben:schwarz 84 | 85 | in your shell, you might do it like this: 86 | 87 | export AMNESIA_CREDS=ben:schwarz 88 | 89 | on heroku, like this: 90 | 91 | heroku config:add AMNESIA_CREDS=ben:schwarz 92 | 93 | ## Potential issues 94 | 95 | * Hosts are listed as "Inactive" or "Not Responding" 96 | 97 | Amnesia uses memcached-client to connect to memcached on the standard memcached port (11211), be sure to enter your 98 | full hostname with the port if you are using a non standard port. (localhost:11211 will work) 99 | 100 | Within my slices, I punched a hole through `iptables` 101 | 102 | sudo iptables -A INPUT -i eth0 -s HOST_THAT_REQUIRES_ACCESS -p tcp --destination-port 11211 -j ACCEPT 103 | 104 | You won't need to do this unless you've explicitly blocked ports to your server. (When in doubt, block nearly everything) 105 | 106 | Let me know if you come across any issues using Github messaging. 107 | 108 | ## Something missing? 109 | 110 | Amnesia used to be a full blown application that required a datamapper sqlite database, yml file for configuration and a bit of pain to get deployed. I decided these were all false constraints and wrapped it up as a middleware instead. Now—You can drop it alongside your rails/sinatra/rack application and see what the hell is going on with Memcached. 111 | 112 | ## Licence 113 | 114 | MIT, See `LICENCE` file. -------------------------------------------------------------------------------- /amnesia.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "amnesia" 3 | s.version = "1.2.0" 4 | s.platform = Gem::Platform::RUBY 5 | s.authors = ["Ben Schwarz"] 6 | s.email = ["ben.schwarz@gmail.com"] 7 | s.homepage = "http://github.com/benschwarz/amnesia" 8 | s.summary = "Amnesia is what you get when you lose your memory" 9 | s.description = "With Amnesia you'll know exactly whats happening with memory when it comes to memcached." 10 | s.files = Dir.glob("{bin,lib}/**/*") + %w(LICENCE README.markdown) 11 | s.require_path = 'lib' 12 | 13 | s.add_dependency "sinatra", "> 1" 14 | s.add_dependency "dalli" 15 | s.add_dependency "haml" 16 | 17 | s.add_development_dependency "rspec", "~> 3.9" 18 | s.add_development_dependency "rack-test" 19 | s.add_development_dependency "thin" 20 | end 21 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | require 'amnesia' 4 | 5 | # This stops invalid US-ASCII characters on heroku. 6 | Encoding.default_internal = 'utf-8' 7 | Encoding.default_external = 'utf-8' 8 | 9 | use Amnesia::Application, hosts: ['localhost', 'example.local:10987'] 10 | run Sinatra::Application 11 | -------------------------------------------------------------------------------- /lib/amnesia.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' 2 | require 'haml' 3 | require 'amnesia/authentication' 4 | require 'amnesia/helpers' 5 | require 'amnesia/host' 6 | require 'amnesia/routes' 7 | 8 | module Amnesia 9 | class Application < Sinatra::Base 10 | include Amnesia::Authentication 11 | include Amnesia::Helpers 12 | include Amnesia::Routes 13 | 14 | set :public_folder, File.join(__dir__, 'amnesia', 'public') 15 | set :views, File.join(__dir__, 'amnesia', 'views') 16 | 17 | def initialize(app = nil, options = {}) 18 | @hosts = build_hosts options[:hosts] || ENV['MEMCACHE_SERVERS'] || '127.0.0.1:11211' 19 | super app 20 | end 21 | 22 | def build_hosts addresses 23 | addresses = addresses.split "," if addresses.is_a? String 24 | Array(addresses).flatten.map { |address| Amnesia::Host.new address } 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/amnesia/authentication.rb: -------------------------------------------------------------------------------- 1 | module Amnesia 2 | module Authentication 3 | def self.included app 4 | app.class_eval do 5 | use Rack::Auth::Basic, "Amnesia" do |username, password| 6 | user, pass = ENV['AMNESIA_CREDS'].split(':') 7 | username == user and password == pass 8 | end if ENV['AMNESIA_CREDS'] 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/amnesia/helpers.rb: -------------------------------------------------------------------------------- 1 | module Amnesia 2 | module Helpers 3 | def self.included app 4 | app.helpers do 5 | include HelperMethods 6 | end 7 | end 8 | 9 | module HelperMethods 10 | SIZE_UNITS = %w[ Bytes KB MB GB TB PB EB ] 11 | 12 | def graph_url(*data) 13 | url "/pie?d="+data.join(',') 14 | end 15 | 16 | # https://github.com/rails/rails/blob/fbe335cfe09bf0949edfdf0c4b251f4d081bd5d7/activesupport/lib/active_support/number_helper/number_to_human_size_converter.rb 17 | def number_to_human_size(number, precision=1) 18 | number, base = Float(number), 1024 19 | 20 | if number.to_i < base 21 | "%d %s" % [ number.to_i, SIZE_UNITS.first ] 22 | else 23 | max = SIZE_UNITS.size - 1 24 | exp = (Math.log(number) / Math.log(base)).to_i 25 | exp = max if exp > max # avoid overflow for the highest unit 26 | result = number / (base**exp) 27 | "%.#{precision}f %s" % [ result, SIZE_UNITS[exp] ] 28 | end 29 | end 30 | 31 | def alive_hosts 32 | @alive_hosts ||= @hosts.select(&:alive?) 33 | end 34 | 35 | %w[ bytes limit_maxbytes get_hits get_misses cmd_get cmd_set ].each do |stat| 36 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 37 | def #{stat}_sum 38 | alive_hosts.map(&:#{stat}).sum 39 | end 40 | RUBY 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/amnesia/host.rb: -------------------------------------------------------------------------------- 1 | require 'dalli' 2 | 3 | module Amnesia 4 | class Host 5 | FLOAT_STATS = %w[ rusage_user rusage_system ] 6 | STRING_STATS = %w[ version libevent ] 7 | DEFAULT_PORT = 11_211 8 | 9 | def self.normalize_address address 10 | return "#{address}:#{DEFAULT_PORT}" unless address.include? ":" 11 | 12 | address 13 | end 14 | 15 | def initialize(address) 16 | @address = self.class.normalize_address address 17 | end 18 | 19 | def alive? 20 | return true if connection.stats[@address] 21 | rescue Dalli::DalliError 22 | return false 23 | end 24 | 25 | def method_missing(method, *args) 26 | if stats.has_key? method.to_s 27 | value = stats[method.to_s] 28 | if FLOAT_STATS.include? method 29 | Float(value) 30 | elsif STRING_STATS.include? method 31 | value 32 | else 33 | Integer(value) 34 | end 35 | else 36 | super 37 | end 38 | end 39 | 40 | def stats 41 | stats_val = connection.stats 42 | stats_val.values.first || {} 43 | end 44 | 45 | def address 46 | @address || @connection.servers.join(', ') 47 | end 48 | 49 | private 50 | 51 | def connection 52 | @connection ||= connect(@address) 53 | end 54 | 55 | def connect(address = nil) 56 | if defined?(EM) && EM.respond_to?(:reactor_running?) && EM::reactor_running? 57 | opts = {async: true} 58 | else 59 | opts = {} 60 | end 61 | Dalli::Client.new(address, opts) 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/amnesia/public/css/application.css: -------------------------------------------------------------------------------- 1 | body, h1, h2, h3, h4, h5, h6, p, a, ul, ol, dl, dt, dd, table, caption, th, td, 2 | fieldset, legend, blockquote { 3 | font-weight: normal; 4 | margin: 0; 5 | padding: 0; 6 | color: inherit; 7 | list-style-type: none; 8 | } 9 | a img, form, legend {border: 0; } 10 | 11 | html { color: #454545;} 12 | 13 | body { 14 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 15 | margin: 0 auto; 16 | padding: 0 1em; 17 | max-width: 450px; 18 | text-rendering: optimizeLegibility; 19 | -webkit-font-smoothing: antialiased; 20 | } 21 | 22 | img { display: block; } 23 | a:hover { color: #000; } 24 | 25 | header { margin: 1em 0; } 26 | .brand { font-size: 3.5em; } 27 | .brand a { display: block; text-decoration: none; } 28 | .brand a:hover:after { content: "—Huh?";} 29 | 30 | .host { 31 | font-size: 1em; 32 | color: #fff; 33 | } 34 | .host-address { 35 | font-weight: bold; 36 | color: #ff9900; 37 | border-radius: 2px; 38 | font-size: 0.9em; 39 | } 40 | .host-details { 41 | color: #000; 42 | padding: 2px 4px; 43 | } 44 | 45 | .stats { 46 | display: flex; 47 | align-items: center; 48 | margin: 2em 0; 49 | } 50 | .stats-text { 51 | flex: 1 1; 52 | margin-left: 1.5em; 53 | } 54 | 55 | .graph-indicator { color: #ff9900; } 56 | 57 | .alive-host, 58 | .dead-host { 59 | margin: 0.5em 0; 60 | } 61 | .dead-host { color: #999; } 62 | 63 | footer { 64 | margin-top: 2em; 65 | border-top: 0.1em solid #ccc; 66 | margin: 2em auto; 67 | padding: 0.5em 0; 68 | font-size: 0.8em; 69 | color: #ccc; 70 | text-align: center; 71 | } 72 | footer a:hover { color: #999; } 73 | 74 | @media (prefers-color-scheme: dark) { 75 | html { 76 | background: black; 77 | color: white; 78 | color: #ccc; 79 | } 80 | a:hover { color: #fff; } 81 | footer { border-color: #454545; color: #454545; } 82 | .host-details { 83 | color: #fff; 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /lib/amnesia/routes.rb: -------------------------------------------------------------------------------- 1 | module Amnesia 2 | module Routes 3 | def self.included app 4 | app.class_eval do 5 | get '/' do 6 | haml :index 7 | end 8 | 9 | get '/pie' do 10 | data = params[:d].to_s.split(",").map(&:to_i) 11 | 12 | r = 25 13 | c = (2*Math::PI*r).ceil 14 | v = data.first.to_f / data.last * 100 * c / 100 15 | v = v.nan? ? 0 : v.ceil 16 | 17 | content_type "image/svg+xml" 18 | <<~BODY 19 | 21 | 22 | 26 | 27 | BODY 28 | end 29 | 30 | get '/:address' do 31 | @host = find_host params[:address] 32 | @host ? haml(:host) : halt(404) 33 | end 34 | 35 | def find_host address 36 | @hosts.find { |h| h.address == address } 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/amnesia/views/host.haml: -------------------------------------------------------------------------------- 1 | %h2.host 2 | %span.host-address 3 | = @host.address 4 | %small.host-details 5 | = "#{@host.curr_items} item/s in cache, with #{@host.curr_connections} active connections" 6 | 7 | %article.stats 8 | %section.stats-graph 9 | %img{src: graph_url(@host.bytes, @host.limit_maxbytes), width: 90} 10 | %section.stats-text 11 | %h3 12 | %span.graph-indicator Used (#{number_to_human_size(@host.bytes)}) 13 | \/ Available Memory (#{number_to_human_size(@host.limit_maxbytes)}) 14 | %p The cumulative amount of available memory across all active hosts. 15 | 16 | %article.stats 17 | %section.stats-graph 18 | %img{src: graph_url(@host.get_hits, @host.get_misses), width: 90} 19 | %section.stats-text 20 | %h3 21 | %span.graph-indicator Hit (#{@host.get_hits}) 22 | \/ Miss (#{@host.get_misses}) 23 | %p The amount of returned caches vs misses, misses usually require your application servers to work harder. 24 | 25 | %section.stats 26 | %section.stats-graph 27 | %img{src: graph_url(@host.cmd_get, @host.cmd_set), width: 90} 28 | %section.stats-text 29 | %h3 30 | %span.graph-indicator Read (#{@host.cmd_get}) 31 | \/ Write (#{@host.cmd_set}) 32 | %p More writes than reads can often mean that you’re caching too early, or that you’ve not been monitoring for very long. 33 | -------------------------------------------------------------------------------- /lib/amnesia/views/index.haml: -------------------------------------------------------------------------------- 1 | - if alive_hosts.any? 2 | %article.stats 3 | %section.stats-graph 4 | %img{src: graph_url(bytes_sum, limit_maxbytes_sum), width: 90} 5 | %section.stats-text 6 | %h3 7 | %span.graph-indicator Used (#{number_to_human_size(bytes_sum)}) 8 | \/ Available Memory (#{number_to_human_size(limit_maxbytes_sum)}) 9 | %p The cumulative amount of available memory across all active hosts. 10 | 11 | %article.stats 12 | %section.stats-graph 13 | %img{src: graph_url(get_hits_sum, get_misses_sum), width: 90} 14 | %section.stats-text 15 | %h3 16 | %span.graph-indicator Hit (#{get_hits_sum}) 17 | \/ Miss (#{get_misses_sum}) 18 | %p The amount of returned caches vs misses, misses usually require your application servers to work harder. 19 | 20 | %article.stats 21 | %section.stats-graph 22 | %img{src: graph_url(cmd_get_sum, cmd_set_sum), width: 90} 23 | %section.stats-text 24 | %h3 25 | %span.graph-indicator Read (#{cmd_get_sum}) 26 | \/ Write (#{cmd_set_sum}) 27 | %p More writes than reads can often mean that you’re caching too early, or that you’ve not been monitoring for very long. 28 | 29 | %nav 30 | %p.sub Known Hosts 31 | %ul 32 | - for host in @hosts 33 | - if host.alive? 34 | %li.alive-host 35 | %a{href: url("/#{host.address}") }= host.address 36 | - else 37 | %li.dead-host 38 | = host.address 39 | (Inactive) 40 | -------------------------------------------------------------------------------- /lib/amnesia/views/layout.haml: -------------------------------------------------------------------------------- 1 | !!! 2 | %html 3 | %head 4 | %meta{charset: "utf-8"} 5 | %title Amnesia 6 | %link{rel: "stylesheet", href: url("/css/application.css")} 7 | %body 8 | %header 9 | %h1.brand 10 | %a{href: url('/')} Amnesia 11 | %p Statistics for Memcached. Wait—What? 12 | %main 13 | != yield 14 | %footer 15 | %p 16 | Another 17 | %a{href: "http://germanforblack.com"} Ben Schwarz 18 | joint. Get the 19 | %a{href: "http://github.com/benschwarz/amnesia"} Source. -------------------------------------------------------------------------------- /spec/amnesia_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Amnesia::Application do 4 | context "unauthenticated" do 5 | it "responds with 401 to get" do 6 | get '/' 7 | expect(last_response.status).to eq 401 8 | end 9 | end 10 | 11 | context "authenticated" do 12 | before(:each) do 13 | basic_authorize "admin", "amnesia" 14 | end 15 | 16 | let(:host) { Amnesia::Host.new("memcache") } 17 | 18 | it "should respond to root" do 19 | get '/' 20 | expect(last_response.status).to eq 200 21 | end 22 | 23 | it "should respond to /:host" do 24 | get "/localhost:11211" 25 | expect(last_response.status).to eq 200 26 | end 27 | 28 | it "should respond to /:host without port with not found" do 29 | get "/localhost" 30 | expect(last_response.status).to eq 404 31 | end 32 | 33 | it "should not display unknown host" do 34 | get "/unknown-host" 35 | expect(last_response.status).to eq 404 36 | end 37 | 38 | it "should not delete host" do 39 | delete "/unknown-host/destroy" 40 | expect(last_response.status).to eq 404 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rspec' 2 | 3 | ENV['RACK_ENV'] ||= 'test' 4 | ENV['AMNESIA_CREDS'] ||= 'admin:amnesia' 5 | ENV['MEMCACHE_SERVERS'] ||= 'localhost' 6 | 7 | require 'rack/test' 8 | require 'amnesia' 9 | 10 | module TestedApp 11 | include Rack::Test::Methods 12 | 13 | def app 14 | Amnesia::Application.new 15 | end 16 | end 17 | 18 | RSpec.configure do |rspec| 19 | rspec.include TestedApp 20 | end 21 | -------------------------------------------------------------------------------- /support/amnesia-screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benschwarz/amnesia/66f059676d4af9ccb1401efa18aec86d7d4dd473/support/amnesia-screen.jpg -------------------------------------------------------------------------------- /support/amnesia_v1.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benschwarz/amnesia/66f059676d4af9ccb1401efa18aec86d7d4dd473/support/amnesia_v1.psd --------------------------------------------------------------------------------