Commit 5772636e authored by Bob Van Landuyt's avatar Bob Van Landuyt 💬
Browse files

Merge branch 'redis-tracing' into 'master'

Add a Redis interceptor to trace all Redis calls

Closes #2

See merge request gitlab-org/labkit-ruby!13
parents d2609f61 54ae95c1
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
  spec.add_runtime_dependency "grpc", "~> 1.19" # Be sure to update the "grpc-tools" dev_depenency too
  spec.add_runtime_dependency "jaeger-client", "~> 0.10"
  spec.add_runtime_dependency "opentracing", "~> 0.4"
  spec.add_runtime_dependency "redis", "~> 3.2"

  # Please maintain alphabetical order for dev dependencies
  spec.add_development_dependency "grpc-tools", "~> 1.19"
+1 −0
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@ module Labkit
    autoload :JaegerFactory, "labkit/tracing/jaeger_factory"
    autoload :RackMiddleware, "labkit/tracing/rack_middleware"
    autoload :Rails, "labkit/tracing/rails"
    autoload :Redis, "labkit/tracing/redis"
    autoload :Sidekiq, "labkit/tracing/sidekiq"
    autoload :TracingUtils, "labkit/tracing/tracing_utils"

+17 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require "redis"

module Labkit
  module Tracing
    # The Redis interceptor will intercept all calls to Redis and instrument them for distributed tracing
    module Redis
      autoload :RedisInterceptor, "labkit/tracing/redis/redis_interceptor"
      autoload :RedisInterceptorHelper, "labkit/tracing/redis/redis_interceptor_helper"

      def self.instrument
        ::Redis::Client.prepend RedisInterceptor
      end
    end
  end
end
+27 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require "redis"

module Labkit
  module Tracing
    module Redis
      # RedisInterceptor is an interceptor for Redis to add distributed tracing.
      # It should be installed using the `Labkit::Tracing.instrument` method
      module RedisInterceptor
        def call(command)
          RedisInterceptorHelper.call_with_tracing(command, self) do
            # Note: when used without any arguments super uses the arguments given to the subclass method.
            super
          end
        end

        def call_pipeline(pipeline)
          RedisInterceptorHelper.call_pipeline_with_tracing(pipeline, self) do
            # Note: when used without any arguments super uses the arguments given to the subclass method.
            super
          end
        end
      end
    end
  end
end
+99 −0
Original line number Diff line number Diff line
# frozen_string_literal: true

require "redis"

module Labkit
  module Tracing
    module Redis
      # RedisInterceptorHelper is a helper for the RedisInterceptor. This is not a public API
      class RedisInterceptorHelper
        # For optimization, compile this once
        MASK_REDIS_RE = /^([\w-]+(?:\W+[\w-]+(?:\W+[\w-]+)?)?)(.?)/.freeze

        def self.call_with_tracing(command, client)
          Labkit::Tracing::TracingUtils.with_tracing(operation_name: "redis.call", tags: tags_from_command(command, client)) do |_span|
            yield
          end
        end

        def self.call_pipeline_with_tracing(pipeline, client)
          Labkit::Tracing::TracingUtils.with_tracing(operation_name: "redis.call_pipeline", tags: tags_from_pipeline(pipeline, client)) do |_span|
            yield
          end
        end

        def self.common_tags_for_client(client)
          {
            "component" => "redis",
            "span.kind" => "client",
            "redis.scheme" => client.scheme,
            "redis.host" => client.host,
            "redis.port" => client.port,
            "redis.path" => client.path,
          }
        end

        def self.tags_from_command(command, client)
          tags = common_tags_for_client(client)

          tags["redis.command"] = command_serialized(command)

          tags
        end

        def self.command_serialized(command)
          command_name, *arguments = command

          info = [command_name]
          info << sanitize_argument_for_command(command_name, arguments.first) unless arguments.empty?

          # Additional arguments? Only include the number
          info << "...#{arguments.size - 1} more value(s)" if arguments.size > 1

          info.join(" ")
        end

        def self.tags_from_pipeline(pipeline, client)
          tags = common_tags_for_client(client)

          commands = pipeline.commands

          # Limit to the first 5 commands
          commands.first(5).each_with_index do |command, index|
            tags["redis.command.#{index}"] = command_serialized(command)
          end
          tags["redis.pipeline.commands.length"] = commands.length

          tags
        end

        # get_first_argument_for_command returns a masked value representing the first argument
        # from a redis command, taking care of certain sensitive commands
        def self.sanitize_argument_for_command(command_name, first_argument)
          return "*****" if command_is_sensitive(command_name)

          return "nil" if first_argument.nil?
          return first if first_argument.is_a?(Numeric)
          return "*****" unless first_argument.is_a?(String)

          mask_redis_arg(first_argument)
        end

        def self.command_is_sensitive(command_name)
          return true if command_name == :auth || "auth".casecmp(command_name).zero?
          return true if command_name == :eval || "eval".casecmp(command_name).zero?

          false
        end

        def self.mask_redis_arg(argument)
          return "" if argument.empty?

          matches = argument.match(MASK_REDIS_RE)
          matches[2].empty? ? matches[0] : matches[0] + "*****"
        end
        private_class_method :mask_redis_arg
      end
    end
  end
end
Loading