summaryrefslogtreecommitdiff
path: root/lib/gitlab/error_tracking.rb
blob: b893d625f8d2589fe1c2c9409f26f516bc2f8733 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# frozen_string_literal: true

module Gitlab
  module ErrorTracking
    # Exceptions in this group will receive custom Sentry fingerprinting
    CUSTOM_FINGERPRINTING = %w[
      Acme::Client::Error::BadNonce
      Acme::Client::Error::NotFound
      Acme::Client::Error::RateLimited
      Acme::Client::Error::Timeout
      Acme::Client::Error::UnsupportedOperation
      ActiveRecord::ConnectionTimeoutError
      ActiveRecord::QueryCanceled
      Gitlab::RequestContext::RequestDeadlineExceeded
      GRPC::DeadlineExceeded
      JIRA::HTTPError
      Rack::Timeout::RequestTimeoutException
    ].freeze

    class << self
      def configure
        Raven.configure do |config|
          config.dsn = sentry_dsn
          config.release = Gitlab.revision
          config.current_environment = Gitlab.config.sentry.environment

          # Sanitize fields based on those sanitized from Rails.
          config.sanitize_fields = Rails.application.config.filter_parameters.map(&:to_s)
          # Sanitize authentication headers
          config.sanitize_http_headers = %w[Authorization Private-Token]
          config.tags = { program: Gitlab.process_name }
          config.before_send = method(:before_send)
        end
      end

      def with_context(current_user = nil)
        last_user_context = Raven.context.user

        user_context = {
          id: current_user&.id,
          email: current_user&.email,
          username: current_user&.username
        }.compact

        Raven.tags_context(default_tags)
        Raven.user_context(user_context)

        yield
      ensure
        Raven.user_context(last_user_context)
      end

      # This should be used when you want to passthrough exception handling:
      # rescue and raise to be catched in upper layers of the application.
      #
      # If the exception implements the method `sentry_extra_data` and that method
      # returns a Hash, then the return value of that method will be merged into
      # `extra`. Exceptions can use this mechanism to provide structured data
      # to sentry in addition to their message and back-trace.
      def track_and_raise_exception(exception, extra = {})
        process_exception(exception, sentry: true, extra: extra)

        raise exception
      end

      # This can be used for investigating exceptions that can be recovered from in
      # code. The exception will still be raised in development and test
      # environments.
      #
      # That way we can track down these exceptions with as much information as we
      # need to resolve them.
      #
      # If the exception implements the method `sentry_extra_data` and that method
      # returns a Hash, then the return value of that method will be merged into
      # `extra`. Exceptions can use this mechanism to provide structured data
      # to sentry in addition to their message and back-trace.
      #
      # Provide an issue URL for follow up.
      # as `issue_url: 'http://gitlab.com/gitlab-org/gitlab/issues/111'`
      def track_and_raise_for_dev_exception(exception, extra = {})
        process_exception(exception, sentry: true, extra: extra)

        raise exception if should_raise_for_dev?
      end

      # This should be used when you only want to track the exception.
      #
      # If the exception implements the method `sentry_extra_data` and that method
      # returns a Hash, then the return value of that method will be merged into
      # `extra`. Exceptions can use this mechanism to provide structured data
      # to sentry in addition to their message and back-trace.
      def track_exception(exception, extra = {})
        process_exception(exception, sentry: true, extra: extra)
      end

      # This should be used when you only want to log the exception,
      # but not send it to Sentry.
      #
      # If the exception implements the method `sentry_extra_data` and that method
      # returns a Hash, then the return value of that method will be merged into
      # `extra`. Exceptions can use this mechanism to provide structured data
      # to sentry in addition to their message and back-trace.
      def log_exception(exception, extra = {})
        process_exception(exception, extra: extra)
      end

      private

      def before_send(event, hint)
        event = add_context_from_exception_type(event, hint)
        event = custom_fingerprinting(event, hint)

        event
      end

      def process_exception(exception, sentry: false, logging: true, extra:)
        exception.try(:sentry_extra_data)&.tap do |data|
          extra = extra.merge(data) if data.is_a?(Hash)
        end

        extra = sanitize_request_parameters(extra)

        if sentry && Raven.configuration.server
          Raven.capture_exception(exception, tags: default_tags, extra: extra)
        end

        if logging
          # TODO: this logic could migrate into `Gitlab::ExceptionLogFormatter`
          # and we could also flatten deep nested hashes if required for search
          # (e.g. if `extra` includes hash of hashes).
          # In the current implementation, we don't flatten multi-level folded hashes.
          log_hash = {}
          Raven.context.tags.each { |name, value| log_hash["tags.#{name}"] = value }
          Raven.context.user.each { |name, value| log_hash["user.#{name}"] = value }
          Raven.context.extra.merge(extra).each { |name, value| log_hash["extra.#{name}"] = value }

          Gitlab::ExceptionLogFormatter.format!(exception, log_hash)

          Gitlab::ErrorTracking::Logger.error(log_hash)
        end
      end

      def sanitize_request_parameters(parameters)
        filter = ActiveSupport::ParameterFilter.new(::Rails.application.config.filter_parameters)
        filter.filter(parameters)
      end

      def sentry_dsn
        return unless Rails.env.production? || Rails.env.development?
        return unless Gitlab.config.sentry.enabled

        Gitlab.config.sentry.dsn
      end

      def should_raise_for_dev?
        Rails.env.development? || Rails.env.test?
      end

      def default_tags
        {
          Labkit::Correlation::CorrelationId::LOG_KEY.to_sym => Labkit::Correlation::CorrelationId.current_id,
          locale: I18n.locale
        }
      end

      # Debugging for https://gitlab.com/gitlab-org/gitlab-foss/issues/57727
      def add_context_from_exception_type(event, hint)
        if ActiveModel::MissingAttributeError === hint[:exception]
          columns_hash = ActiveRecord::Base
                            .connection
                            .schema_cache
                            .instance_variable_get(:@columns_hash)
                            .map { |k, v| [k, v.map(&:first)] }
                            .to_h

          event.extra.merge!(columns_hash)
        end

        event
      end

      # Group common, mostly non-actionable exceptions by type and message,
      # rather than cause
      def custom_fingerprinting(event, hint)
        ex = hint[:exception]

        return event unless CUSTOM_FINGERPRINTING.include?(ex.class.name)

        event.fingerprint = [ex.class.name, ex.message]

        event
      end
    end
  end
end