Commit 42893446 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'osw-support-multiple-redis-clients-instrumentation' into 'master'

Separate Redis instrumentation per client/instance

See merge request gitlab-org/gitlab!33539
parents 94dd4497 8c7745dc
# frozen_string_literal: true # frozen_string_literal: true
require 'action_cable/subscription_adapter/redis'
Rails.application.configure do Rails.application.configure do
# We only mount the ActionCable engine in tests where we run it in-app # We only mount the ActionCable engine in tests where we run it in-app
# For other environments, we run it on a standalone Puma server # For other environments, we run it on a standalone Puma server
...@@ -7,3 +9,11 @@ Rails.application.configure do ...@@ -7,3 +9,11 @@ Rails.application.configure do
config.action_cable.url = Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/cable') config.action_cable.url = Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/cable')
config.action_cable.worker_pool_size = Gitlab.config.action_cable.worker_pool_size config.action_cable.worker_pool_size = Gitlab.config.action_cable.worker_pool_size
end end
# https://github.com/rails/rails/blob/bb5ac1623e8de08c1b7b62b1368758f0d3bb6379/actioncable/lib/action_cable/subscription_adapter/redis.rb#L18
ActionCable::SubscriptionAdapter::Redis.redis_connector = lambda do |config|
args = config.except(:adapter, :channel_prefix)
.merge(instrumentation_class: ::Gitlab::Instrumentation::Redis::ActionCable)
::Redis.new(args)
end
# frozen_string_literal: true # frozen_string_literal: true
require 'redis'
module Gitlab module Gitlab
module Instrumentation module Instrumentation
module RedisInterceptor # Aggregates Redis measurements from different request storage sources.
def call(*args, &block)
start = Time.now
super(*args, &block)
ensure
duration = (Time.now - start)
if ::RequestStore.active?
::Gitlab::Instrumentation::Redis.increment_request_count
::Gitlab::Instrumentation::Redis.add_duration(duration)
::Gitlab::Instrumentation::Redis.add_call_details(duration, args)
end
end
end
class Redis class Redis
REDIS_REQUEST_COUNT = :redis_request_count ActionCable = Class.new(RedisBase)
REDIS_CALL_DURATION = :redis_call_duration Cache = Class.new(RedisBase)
REDIS_CALL_DETAILS = :redis_call_details Queues = Class.new(RedisBase)
REDIS_READ_BYTES = :redis_read_bytes SharedState = Class.new(RedisBase)
REDIS_WRITE_BYTES = :redis_write_bytes
STORAGES = [ActionCable, Cache, Queues, SharedState].freeze
# Milliseconds represented in seconds (from 1 to 500 milliseconds). # Milliseconds represented in seconds (from 1 to 500 milliseconds).
QUERY_TIME_BUCKETS = [0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5].freeze QUERY_TIME_BUCKETS = [0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5].freeze
def self.get_request_count class << self
::RequestStore[REDIS_REQUEST_COUNT] || 0 def detail_store
end STORAGES.flat_map(&:detail_store)
end
def self.increment_request_count
::RequestStore[REDIS_REQUEST_COUNT] ||= 0
::RequestStore[REDIS_REQUEST_COUNT] += 1
end
def self.increment_read_bytes(num_bytes)
::RequestStore[REDIS_READ_BYTES] ||= 0
::RequestStore[REDIS_READ_BYTES] += num_bytes
end
def self.increment_write_bytes(num_bytes)
::RequestStore[REDIS_WRITE_BYTES] ||= 0
::RequestStore[REDIS_WRITE_BYTES] += num_bytes
end
def self.read_bytes
::RequestStore[REDIS_READ_BYTES] || 0
end
def self.write_bytes
::RequestStore[REDIS_WRITE_BYTES] || 0
end
def self.detail_store
::RequestStore[REDIS_CALL_DETAILS] ||= []
end
def self.query_time
query_time = ::RequestStore[REDIS_CALL_DURATION] || 0
query_time.round(::Gitlab::InstrumentationHelper::DURATION_PRECISION)
end
def self.add_duration(duration)
::RequestStore[REDIS_CALL_DURATION] ||= 0
::RequestStore[REDIS_CALL_DURATION] += duration
end
def self.add_call_details(duration, args)
return unless Gitlab::PerformanceBar.enabled_for_request?
# redis-rb passes an array (e.g. [:get, key])
return unless args.length == 1
detail_store << { %i[get_request_count query_time read_bytes write_bytes].each do |method|
cmd: args.first, define_method method do
duration: duration, STORAGES.sum(&method) # rubocop:disable CodeReuse/ActiveRecord
backtrace: ::Gitlab::BacktraceCleaner.clean_backtrace(caller) end
} end
end end
end end
end end
end end
class ::Redis::Client
prepend ::Gitlab::Instrumentation::RedisInterceptor
end
# frozen_string_literal: true
require 'redis'
module Gitlab
module Instrumentation
class RedisBase
class << self
include ::Gitlab::Utils::StrongMemoize
# TODO: To be used by https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/395
# as a 'label' alias.
def storage_key
self.name.underscore
end
def add_duration(duration)
::RequestStore[call_duration_key] ||= 0
::RequestStore[call_duration_key] += duration
end
def add_call_details(duration, args)
return unless Gitlab::PerformanceBar.enabled_for_request?
# redis-rb passes an array (e.g. [[:get, key]])
return unless args.length == 1
# TODO: Add information about current Redis client
# being instrumented.
# https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/316.
detail_store << {
cmd: args.first,
duration: duration,
backtrace: ::Gitlab::BacktraceCleaner.clean_backtrace(caller)
}
end
def increment_request_count
::RequestStore[request_count_key] ||= 0
::RequestStore[request_count_key] += 1
end
def increment_read_bytes(num_bytes)
::RequestStore[read_bytes_key] ||= 0
::RequestStore[read_bytes_key] += num_bytes
end
def increment_write_bytes(num_bytes)
::RequestStore[write_bytes_key] ||= 0
::RequestStore[write_bytes_key] += num_bytes
end
def get_request_count
::RequestStore[request_count_key] || 0
end
def read_bytes
::RequestStore[read_bytes_key] || 0
end
def write_bytes
::RequestStore[write_bytes_key] || 0
end
def detail_store
::RequestStore[call_details_key] ||= []
end
def query_time
query_time = ::RequestStore[call_duration_key] || 0
query_time.round(::Gitlab::InstrumentationHelper::DURATION_PRECISION)
end
private
def request_count_key
strong_memoize(:request_count_key) { build_key(:redis_request_count) }
end
def read_bytes_key
strong_memoize(:read_bytes_key) { build_key(:redis_read_bytes) }
end
def write_bytes_key
strong_memoize(:write_bytes_key) { build_key(:redis_write_bytes) }
end
def call_duration_key
strong_memoize(:call_duration_key) { build_key(:redis_call_duration) }
end
def call_details_key
strong_memoize(:call_details_key) { build_key(:redis_call_details) }
end
def build_key(namespace)
"#{storage_key}_#{namespace}"
end
end
end
end
end
...@@ -4,7 +4,20 @@ require 'redis' ...@@ -4,7 +4,20 @@ require 'redis'
module Gitlab module Gitlab
module Instrumentation module Instrumentation
class RedisDriver < ::Redis::Connection::Ruby module RedisInterceptor
def call(*args, &block)
start = Time.now
super(*args, &block)
ensure
duration = (Time.now - start)
if ::RequestStore.active?
instrumentation_class.increment_request_count
instrumentation_class.add_duration(duration)
instrumentation_class.add_call_details(duration, args)
end
end
def write(command) def write(command)
measure_write_size(command) if ::RequestStore.active? measure_write_size(command) if ::RequestStore.active?
super super
...@@ -35,27 +48,40 @@ module Gitlab ...@@ -35,27 +48,40 @@ module Gitlab
end end
end end
::Gitlab::Instrumentation::Redis.increment_write_bytes(size) instrumentation_class.increment_write_bytes(size)
end end
def measure_read_size(result) def measure_read_size(result)
# The superclass can return one of four types of results from read: # The Connection::Ruby#read class can return one of four types of results from read:
# https://github.com/redis/redis-rb/blob/f597f21a6b954b685cf939febbc638f6c803e3a7/lib/redis/connection/ruby.rb#L406 # https://github.com/redis/redis-rb/blob/f597f21a6b954b685cf939febbc638f6c803e3a7/lib/redis/connection/ruby.rb#L406
# #
# 1. Error (exception, will not reach this line) # 1. Error (exception, will not reach this line)
# 2. Status (string) # 2. Status (string)
# 3. Integer (will be converted to string by to_s.bytesize and thrown away) # 3. Integer (will be converted to string by to_s.bytesize and thrown away)
# 4. "Binary" string (i.e. may contain zero byte) # 4. "Binary" string (i.e. may contain zero byte)
# 5. Array of binary string (recurses back into read) # 5. Array of binary string
# Avoid double-counting array responses: the array elements themselves size = if result.is_a? Array
# are retrieved with 'read'. # This count is an approximation that omits the Redis protocol overhead
unless result.is_a? Array # of type prefixes, length prefixes and line endings.
# This count is an approximation that omits the Redis protocol overhead result.inject(0) { |sum, y| sum + y.to_s.bytesize }
# of type prefixes, length prefixes and line endings. else
::Gitlab::Instrumentation::Redis.increment_read_bytes(result.to_s.bytesize) result.to_s.bytesize
end end
instrumentation_class.increment_read_bytes(size)
end
# That's required so it knows which GitLab Redis instance
# it's interacting with in order to categorize accordingly.
#
def instrumentation_class
@options[:instrumentation_class] # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
end end
end end
end end
class ::Redis::Client
prepend ::Gitlab::Instrumentation::RedisInterceptor
end
...@@ -27,6 +27,10 @@ module Gitlab ...@@ -27,6 +27,10 @@ module Gitlab
# this will force use of DEFAULT_REDIS_QUEUES_URL when config file is absent # this will force use of DEFAULT_REDIS_QUEUES_URL when config file is absent
super super
end end
def instrumentation_class
::Gitlab::Instrumentation::Redis::Cache
end
end end
end end
end end
......
...@@ -28,6 +28,10 @@ module Gitlab ...@@ -28,6 +28,10 @@ module Gitlab
# this will force use of DEFAULT_REDIS_QUEUES_URL when config file is absent # this will force use of DEFAULT_REDIS_QUEUES_URL when config file is absent
super super
end end
def instrumentation_class
::Gitlab::Instrumentation::Redis::Queues
end
end end
end end
end end
......
...@@ -30,6 +30,10 @@ module Gitlab ...@@ -30,6 +30,10 @@ module Gitlab
# this will force use of DEFAULT_REDIS_SHARED_STATE_URL when config file is absent # this will force use of DEFAULT_REDIS_SHARED_STATE_URL when config file is absent
super super
end end
def instrumentation_class
::Gitlab::Instrumentation::Redis::SharedState
end
end end
end end
end end
......
...@@ -71,6 +71,10 @@ module Gitlab ...@@ -71,6 +71,10 @@ module Gitlab
# nil will force use of DEFAULT_REDIS_URL when config file is absent # nil will force use of DEFAULT_REDIS_URL when config file is absent
nil nil
end end
def instrumentation_class
raise NotImplementedError
end
end end
def initialize(rails_env = nil) def initialize(rails_env = nil)
...@@ -100,7 +104,7 @@ module Gitlab ...@@ -100,7 +104,7 @@ module Gitlab
redis_url = config.delete(:url) redis_url = config.delete(:url)
redis_uri = URI.parse(redis_url) redis_uri = URI.parse(redis_url)
config[:driver] ||= ::Gitlab::Instrumentation::RedisDriver config[:instrumentation_class] ||= self.class.instrumentation_class
if redis_uri.scheme == 'unix' if redis_uri.scheme == 'unix'
# Redis::Store does not handle Unix sockets well, so let's do it for them # Redis::Store does not handle Unix sockets well, so let's do it for them
......
...@@ -9,6 +9,10 @@ module Peek ...@@ -9,6 +9,10 @@ module Peek
'redis' 'redis'
end end
def detail_store
::Gitlab::Instrumentation::Redis.detail_store
end
private private
def format_call_details(call) def format_call_details(call)
......
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Instrumentation::RedisBase, :request_store do
let(:instrumentation_class_a) do
stub_const('RedisInstanceA', Class.new(described_class))
end
let(:instrumentation_class_b) do
stub_const('RedisInstanceB', Class.new(described_class))
end
describe '.storage_key' do
it 'returns the class name with underscore' do
expect(instrumentation_class_a.storage_key).to eq('redis_instance_a')
expect(instrumentation_class_b.storage_key).to eq('redis_instance_b')
end
end
describe '.add_duration' do
it 'does not lose precision while adding' do
precision = 1.0 / (10**::Gitlab::InstrumentationHelper::DURATION_PRECISION)
2.times { instrumentation_class_a.add_duration(0.4 * precision) }
# 2 * 0.4 should be 0.8 and get rounded to 1
expect(instrumentation_class_a.query_time).to eq(1 * precision)
end
context 'storage key overlapping' do
it 'keys do not overlap across storages' do
instrumentation_class_a.add_duration(0.4)
instrumentation_class_b.add_duration(0.5)
expect(instrumentation_class_a.query_time).to eq(0.4)
expect(instrumentation_class_b.query_time).to eq(0.5)
end
end
end
describe '.increment_request_count' do
context 'storage key overlapping' do
it 'keys do not overlap across storages' do
3.times { instrumentation_class_a.increment_request_count }
2.times { instrumentation_class_b.increment_request_count }
expect(instrumentation_class_a.get_request_count).to eq(3)
expect(instrumentation_class_b.get_request_count).to eq(2)
end
end
end
describe '.increment_write_bytes' do
context 'storage key overlapping' do
it 'keys do not overlap across storages' do
2.times do
instrumentation_class_a.increment_write_bytes(42)
instrumentation_class_b.increment_write_bytes(77)
end
expect(instrumentation_class_a.write_bytes).to eq(42 * 2)
expect(instrumentation_class_b.write_bytes).to eq(77 * 2)
end
end
end
describe '.increment_read_bytes' do
context 'storage key overlapping' do
it 'keys do not overlap across storages' do
2.times do
instrumentation_class_a.increment_read_bytes(42)
instrumentation_class_b.increment_read_bytes(77)
end
expect(instrumentation_class_a.read_bytes).to eq(42 * 2)
expect(instrumentation_class_b.read_bytes).to eq(77 * 2)
end
end
end
describe '.add_call_details' do
before do
allow(Gitlab::PerformanceBar).to receive(:enabled_for_request?) { true }
end
context 'storage key overlapping' do
it 'keys do not overlap across storages' do
2.times do
instrumentation_class_a.add_call_details(0.3, [:set])
instrumentation_class_b.add_call_details(0.4, [:set])
end
expect(instrumentation_class_a.detail_store).to match(
[
a_hash_including(cmd: :set, duration: 0.3, backtrace: an_instance_of(Array)),
a_hash_including(cmd: :set, duration: 0.3, backtrace: an_instance_of(Array))
]
)
expect(instrumentation_class_b.detail_store).to match(
[
a_hash_including(cmd: :set, duration: 0.4, backtrace: an_instance_of(Array)),
a_hash_including(cmd: :set, duration: 0.4, backtrace: an_instance_of(Array))
]
)
end
end
end
end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
require 'rspec-parameterized' require 'rspec-parameterized'
describe Gitlab::Instrumentation::RedisDriver, :clean_gitlab_redis_shared_state, :request_store do describe Gitlab::Instrumentation::RedisInterceptor, :clean_gitlab_redis_shared_state, :request_store do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
describe 'read and write' do describe 'read and write' do
......
...@@ -3,13 +3,26 @@ ...@@ -3,13 +3,26 @@
require 'spec_helper' require 'spec_helper'
describe Gitlab::Instrumentation::Redis do describe Gitlab::Instrumentation::Redis do
describe '.add_duration', :request_store do def stub_storages(method, value)
it 'does not lose precision while adding' do described_class::STORAGES.each do |storage|
precision = 1.0 / (10**::Gitlab::InstrumentationHelper::DURATION_PRECISION) allow(storage).to receive(method) { value }
2.times { described_class.add_duration(0.4 * precision) } end
end
shared_examples 'aggregation of redis storage data' do |method|
describe "#{method} sum" do
it "sums data from all Redis storages" do
amount = 0.3
# 2 * 0.4 should be 0.8 and get rounded to 1 stub_storages(method, amount)
expect(described_class.query_time).to eq(1 * precision)
expect(described_class.public_send(method)).to eq(described_class::STORAGES.size * amount)
end
end end
end end
it_behaves_like 'aggregation of redis storage data', :get_request_count
it_behaves_like 'aggregation of redis storage data', :query_time
it_behaves_like 'aggregation of redis storage data', :read_bytes
it_behaves_like 'aggregation of redis storage data', :write_bytes
end end
...@@ -18,7 +18,21 @@ describe Gitlab::Redis::Wrapper do ...@@ -18,7 +18,21 @@ describe Gitlab::Redis::Wrapper do
let(:config_env_variable_url) {"TEST_GITLAB_REDIS_URL"} let(:config_env_variable_url) {"TEST_GITLAB_REDIS_URL"}
let(:class_redis_url) { Gitlab::Redis::Wrapper::DEFAULT_REDIS_URL } let(:class_redis_url) { Gitlab::Redis::Wrapper::DEFAULT_REDIS_URL }
include_examples "redis_shared_examples" include_examples "redis_shared_examples" do
before do
allow(described_class).to receive(:instrumentation_class) do
::Gitlab::Instrumentation::Redis::Cache
end
end
end
describe '.instrumentation_class' do
it 'raises a NotImplementedError' do
expect(described_class).to receive(:instrumentation_class).and_call_original
expect { described_class.instrumentation_class }.to raise_error(NotImplementedError)
end
end
describe '.config_file_path' do describe '.config_file_path' do
it 'returns the absolute path to the configuration file' do it 'returns the absolute path to the configuration file' do
......
...@@ -17,7 +17,7 @@ describe Peek::Views::RedisDetailed, :request_store do ...@@ -17,7 +17,7 @@ describe Peek::Views::RedisDetailed, :request_store do
with_them do with_them do
it 'scrubs Redis commands' do it 'scrubs Redis commands' do
subject.detail_store << { cmd: cmd, duration: 1.second } Gitlab::Instrumentation::Redis::SharedState.detail_store << { cmd: cmd, duration: 1.second }
expect(subject.results[:details].count).to eq(1) expect(subject.results[:details].count).to eq(1)
expect(subject.results[:details].first) expect(subject.results[:details].first)
...@@ -29,11 +29,12 @@ describe Peek::Views::RedisDetailed, :request_store do ...@@ -29,11 +29,12 @@ describe Peek::Views::RedisDetailed, :request_store do
end end
it 'returns aggregated results' do it 'returns aggregated results' do
subject.detail_store << { cmd: [:get, 'test'], duration: 0.001 } Gitlab::Instrumentation::Redis::Cache.detail_store << { cmd: [:get, 'test'], duration: 0.001 }
subject.detail_store << { cmd: [:get, 'test'], duration: 1.second } Gitlab::Instrumentation::Redis::Cache.detail_store << { cmd: [:get, 'test'], duration: 1.second }
Gitlab::Instrumentation::Redis::SharedState.detail_store << { cmd: [:get, 'test'], duration: 1.second }
expect(subject.results[:calls]).to eq(2) expect(subject.results[:calls]).to eq(3)
expect(subject.results[:duration]).to eq('1001.00ms') expect(subject.results[:duration]).to eq('2001.00ms')
expect(subject.results[:details].count).to eq(2) expect(subject.results[:details].count).to eq(3)
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment