appsignal/appsignal

View on GitHub
lib/appsignal/transaction.rb

Summary

Maintainability
C
1 day
Test Coverage
# frozen_string_literal: true

require "json"

module Appsignal
  class Transaction
    HTTP_REQUEST   = "http_request".freeze
    BACKGROUND_JOB = "background_job".freeze
    ACTION_CABLE   = "action_cable".freeze
    FRONTEND       = "frontend".freeze
    BLANK          = "".freeze
    ALLOWED_TAG_KEY_TYPES = [Symbol, String].freeze
    ALLOWED_TAG_VALUE_TYPES = [Symbol, String, Integer].freeze

    class << self
      def create(id, namespace, request, options = {})
        # Allow middleware to force a new transaction
        if options.include?(:force) && options[:force]
          Thread.current[:appsignal_transaction] = nil
        end

        # Check if we already have a running transaction
        if Thread.current[:appsignal_transaction].nil?
          # If not, start a new transaction
          Thread.current[:appsignal_transaction] = Appsignal::Transaction.new(id, namespace, request, options)
        else
          # Otherwise, log the issue about trying to start another transaction
          Appsignal.logger.warn_once_then_debug :transaction_id, "Trying to start new transaction with id " \
            "'#{id}', but a transaction with id '#{current.transaction_id}' " \
            "is already running. Using transaction '#{current.transaction_id}'."

          # And return the current transaction instead
          current
        end
      end

      def current
        Thread.current[:appsignal_transaction] || NilTransaction.new
      end

      def complete_current!
        current.complete
      rescue => e
        Appsignal.logger.error("Failed to complete transaction ##{current.transaction_id}. #{e.message}")
      ensure
        clear_current_transaction!
      end

      # Remove current transaction from current Thread.
      # @api private
      def clear_current_transaction!
        Thread.current[:appsignal_transaction] = nil
      end

      def garbage_collection_profiler
        @garbage_collection_profiler ||=
          Appsignal.config[:enable_gc_instrumentation] ? Appsignal::GarbageCollectionProfiler.new : NilGarbageCollectionProfiler.new
      end
    end

    attr_reader :ext, :transaction_id, :action, :namespace, :request, :paused, :tags, :options, :discarded

    # @!attribute params
    #   Attribute for parameters of the transaction.
    #
    #   When no parameters are set with {#params=} the parameters it will look
    #   for parameters on the {#request} environment.
    #
    #   The parameters set using {#params=} are leading over those extracted
    #   from a request's environment.
    #
    #   @return [Hash]
    attr_writer :params

    def initialize(transaction_id, namespace, request, options = {})
      @transaction_id = transaction_id
      @action = nil
      @namespace = namespace
      @request = request
      @paused = false
      @discarded = false
      @tags = {}
      @store = Hash.new({})
      @options = options
      @options[:params_method] ||= :params

      @ext = Appsignal::Extension.start_transaction(
        @transaction_id,
        @namespace,
        self.class.garbage_collection_profiler.total_time
      )
    end

    def nil_transaction?
      false
    end

    def complete
      if discarded?
        Appsignal.logger.debug "Skipping transaction '#{transaction_id}' " \
          "because it was manually discarded."
        return
      end
      if @ext.finish(self.class.garbage_collection_profiler.total_time)
        sample_data
      end
      @ext.complete
    end

    def pause!
      @paused = true
    end

    def resume!
      @paused = false
    end

    def paused?
      @paused == true
    end

    def discard!
      @discarded = true
    end

    def restore!
      @discarded = false
    end

    def discarded?
      @discarded == true
    end

    def store(key)
      @store[key]
    end

    def params
      return @params if defined?(@params)
      request_params
    end

    # Set tags on the transaction.
    #
    # @param given_tags [Hash] Collection of tags.
    # @option given_tags [String, Symbol, Integer] :any
    #   The name of the tag as a Symbol.
    # @option given_tags [String, Symbol, Integer] "any"
    #   The name of the tag as a String.
    # @return [void]
    #
    # @see Appsignal.tag_request
    # @see http://docs.appsignal.com/ruby/instrumentation/tagging.html
    #   Tagging guide
    def set_tags(given_tags = {})
      @tags.merge!(given_tags)
    end

    # Set an action name for the transaction.
    #
    # An action name is used to identify the location of a certain sample;
    # error and performance issues.
    #
    # @param action [String] the action name to set.
    # @return [void]
    # @see Appsignal.set_action
    # @see #set_action_if_nil
    # @since 2.2.0
    def set_action(action)
      return unless action
      @action = action
      @ext.set_action(action)
    end

    # Set an action name only if there is no current action set.
    #
    # Commonly used by AppSignal integrations so that they don't override
    # custom action names.
    #
    # @example
    #   Appsignal.set_action("foo")
    #   Appsignal.set_action_if_nil("bar")
    #   # Transaction action will be "foo"
    #
    # @param action [String]
    # @return [void]
    # @see #set_action
    # @since 2.2.0
    def set_action_if_nil(action)
      return if @action
      set_action(action)
    end

    # Set the namespace for this transaction.
    #
    # Useful to split up parts of an application into certain namespaces. For
    # example: http requests, background jobs and administration panel
    # controllers.
    #
    # Note: The "http_request" namespace gets transformed on AppSignal.com to
    # "Web" and "background_job" gets transformed to "Background".
    #
    # @example
    #   transaction.set_action("admin")
    #
    # @param namespace [String] namespace name to use for this transaction.
    # @return [void]
    # @since 2.2.0
    def set_namespace(namespace)
      return unless namespace
      @namespace = namespace
      @ext.set_namespace(namespace)
    end

    def set_http_or_background_action(from = request.params)
      return unless from
      group_and_action = [
        from[:controller] || from[:class],
        from[:action] || from[:method]
      ]
      set_action_if_nil(group_and_action.compact.join("#"))
    end

    def set_queue_start(start)
      return unless start
      @ext.set_queue_start(start)
    rescue RangeError
      Appsignal.logger.warn("Queue start value #{start} is too big")
    end

    def set_http_or_background_queue_start
      if namespace == HTTP_REQUEST
        set_queue_start(http_queue_start)
      elsif namespace == BACKGROUND_JOB
        set_queue_start(background_queue_start)
      end
    end

    def set_metadata(key, value)
      return unless key && value
      @ext.set_metadata(key, value)
    end

    def set_sample_data(key, data)
      return unless key && data && (data.is_a?(Array) || data.is_a?(Hash))
      @ext.set_sample_data(
        key.to_s,
        Appsignal::Utils::Data.generate(data)
      )
    rescue RuntimeError => e
      begin
        inspected_data = data.inspect
        Appsignal.logger.error("Error generating data (#{e.class}: #{e.message}) for '#{inspected_data}'")
      rescue => e
        Appsignal.logger.error("Error generating data (#{e.class}: #{e.message}). Can't inspect data.")
      end
    end

    def sample_data
      {
        :params       => sanitized_params,
        :environment  => sanitized_environment,
        :session_data => sanitized_session_data,
        :metadata     => metadata,
        :tags         => sanitized_tags
      }.each do |key, data|
        set_sample_data(key, data)
      end
    end

    def set_error(error)
      unless error.is_a?(Exception)
        Appsignal.logger.error "Appsignal::Transaction#set_error: Cannot set error. " \
          "The given value is not an exception: #{error.inspect}"
        return
      end
      return unless error
      return unless Appsignal.active?

      backtrace = cleaned_backtrace(error.backtrace)
      @ext.set_error(
        error.class.name,
        error.message.to_s,
        backtrace ? Appsignal::Utils::Data.generate(backtrace) : Appsignal::Extension.data_array_new
      )
    end
    alias_method :add_exception, :set_error

    def start_event
      return if paused?
      @ext.start_event(self.class.garbage_collection_profiler.total_time)
    end

    def finish_event(name, title, body, body_format = Appsignal::EventFormatter::DEFAULT)
      return if paused?
      @ext.finish_event(
        name,
        title || BLANK,
        body || BLANK,
        body_format || Appsignal::EventFormatter::DEFAULT,
        self.class.garbage_collection_profiler.total_time
      )
    end

    def record_event(name, title, body, duration, body_format = Appsignal::EventFormatter::DEFAULT)
      return if paused?
      @ext.record_event(
        name,
        title || BLANK,
        body || BLANK,
        body_format || Appsignal::EventFormatter::DEFAULT,
        duration,
        self.class.garbage_collection_profiler.total_time
      )
    end

    def instrument(name, title = nil, body = nil, body_format = Appsignal::EventFormatter::DEFAULT)
      start_event
      yield if block_given?
    ensure
      finish_event(name, title, body, body_format)
    end

    # @api private
    def to_h
      JSON.parse(@ext.to_json)
    end
    alias_method :to_hash, :to_h

    class GenericRequest
      attr_reader :env

      def initialize(env)
        @env = env
      end

      def params
        env[:params]
      end
    end

    private

    # Returns calculated background queue start time in milliseconds, based on
    # environment values.
    #
    # @return [nil] if no {#environment} is present.
    # @return [nil] if there is no `:queue_start` in the {#environment}.
    # @return [Integer]
    def background_queue_start
      env = environment
      return unless env
      queue_start = env[:queue_start]
      return unless queue_start

      (queue_start.to_f * 1000.0).to_i
    end

    # Returns HTTP queue start time in milliseconds.
    #
    # @return [nil] if no queue start time is found.
    # @return [nil] if begin time is too low to be plausible.
    # @return [Integer] queue start in milliseconds.
    def http_queue_start
      env = environment
      return unless env
      env_var = env["HTTP_X_QUEUE_START".freeze] || env["HTTP_X_REQUEST_START".freeze]
      return unless env_var
      cleaned_value = env_var.tr("^0-9".freeze, "".freeze)
      return if cleaned_value.empty?

      value = cleaned_value.to_i
      if value > 4_102_441_200_000
        # Value is in microseconds. Transform to milliseconds.
        value / 1_000
      elsif value < 946_681_200_000
        # Value is too low to be plausible
        nil
      else
        # Value is in milliseconds
        value
      end
    end

    def sanitized_params
      return unless Appsignal.config[:send_params]

      filter_keys = Appsignal.config[:filter_parameters] || []
      Appsignal::Utils::HashSanitizer.sanitize params, filter_keys
    end

    def request_params
      return unless request.respond_to?(options[:params_method])

      begin
        request.send options[:params_method]
      rescue => e
        # Getting params from the request has been know to fail.
        Appsignal.logger.debug "Exception while getting params: #{e}"
        nil
      end
    end

    # Returns sanitized environment for a transaction.
    #
    # The environment of a transaction can contain a lot of information, not
    # all of it useful for debugging.
    #
    # @return [nil] if no environment is present.
    # @return [Hash<String, Object>]
    def sanitized_environment
      env = environment
      return if env.empty?

      {}.tap do |out|
        Appsignal.config[:request_headers].each do |key|
          out[key] = env[key] if env[key]
        end
      end
    end

    # Returns sanitized session data.
    #
    # The session data is sanitized by the {Appsignal::Utils::HashSanitizer}.
    #
    # @return [nil] if `:skip_session_data` config is set to `true`.
    # @return [nil] if the {#request} object doesn't respond to `#session`.
    # @return [nil] if the {#request} session data is `nil`.
    # @return [Hash<String, Object>]
    def sanitized_session_data
      return if Appsignal.config[:skip_session_data] ||
          !request.respond_to?(:session)
      session = request.session
      return unless session

      Appsignal::Utils::HashSanitizer.sanitize(
        session.to_hash, Appsignal.config[:filter_session_data]
      )
    end

    # Returns metadata from the environment.
    #
    # @return [nil] if no `:metadata` key is present in the {#environment}.
    # @return [Hash<String, Object>]
    def metadata
      environment[:metadata]
    end

    # Returns the environment for a transaction.
    #
    # Returns an empty Hash when the {#request} object doesn't listen to the
    # `#env` method or the `#env` is nil.
    #
    # @return [Hash<String, Object>]
    def environment
      return {} unless request.respond_to?(:env)
      return {} unless request.env

      request.env
    end

    # Only keep tags if they meet the following criteria:
    # * Key is a symbol or string with less then 100 chars
    # * Value is a symbol or string with less then 100 chars
    # * Value is an integer
    #
    # @see https://docs.appsignal.com/ruby/instrumentation/tagging.html
    def sanitized_tags
      @tags.select do |key, value|
        ALLOWED_TAG_KEY_TYPES.any? { |type| key.is_a? type } &&
          ALLOWED_TAG_VALUE_TYPES.any? { |type| value.is_a? type }
      end
    end

    def cleaned_backtrace(backtrace)
      if defined?(::Rails) && backtrace
        ::Rails.backtrace_cleaner.clean(backtrace, nil)
      else
        backtrace
      end
    end

    # Stub that is returned by {Transaction.current} if there is no current
    # transaction, so that it's still safe to call methods on it if there is no
    # current transaction.
    class NilTransaction
      def method_missing(m, *args, &block)
      end

      # Instrument should still yield
      def instrument(*_args)
        yield
      end

      def nil_transaction?
        true
      end
    end
  end
end