client.rb 8.1 KB
Newer Older
1 2 3 4 5
# frozen_string_literal: true

module Sentry
  class Client
    Error = Class.new(StandardError)
6
    MissingKeysError = Class.new(StandardError)
7
    ResponseInvalidSizeError = Class.new(StandardError)
8 9 10 11 12 13 14 15
    BadRequestError = Class.new(StandardError)

    SENTRY_API_SORT_VALUE_MAP = {
      # <accepted_by_client> => <accepted_by_sentry_api>
      'frequency' => 'freq',
      'first_seen' => 'new',
      'last_seen' => nil
    }.freeze
16 17 18 19 20 21 22 23

    attr_accessor :url, :token

    def initialize(api_url, token)
      @url = api_url
      @token = token
    end

24 25 26 27 28 29 30 31 32 33 34 35
    def issue_details(issue_id:)
      issue = get_issue(issue_id: issue_id)

      map_to_detailed_error(issue)
    end

    def issue_latest_event(issue_id:)
      latest_event = get_issue_latest_event(issue_id: issue_id)

      map_to_event(latest_event)
    end

36
    def list_issues(**keyword_args)
37 38 39 40
      response = get_issues(keyword_args)

      issues = response[:issues]
      pagination = response[:pagination]
41

42 43
      validate_size(issues)

44
      handle_mapping_exceptions do
45 46 47 48
        {
          issues: map_to_errors(issues),
          pagination: pagination
        }
49
      end
50 51
    end

52 53
    def list_projects
      projects = get_projects
54 55 56 57

      handle_mapping_exceptions do
        map_to_projects(projects)
      end
58 59
    end

60 61
    private

62 63 64
    def validate_size(issues)
      return if Gitlab::Utils::DeepSize.new(issues).valid?

65
      raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."
66 67
    end

68 69 70 71
    def handle_mapping_exceptions(&block)
      yield
    rescue KeyError => e
      Gitlab::Sentry.track_acceptable_exception(e)
72
      raise MissingKeysError, "Sentry API response is missing keys. #{e.message}"
73 74
    end

75 76 77 78 79 80 81 82 83
    def request_params
      {
        headers: {
          'Authorization' => "Bearer #{@token}"
        },
        follow_redirects: false
      }
    end

84
    def http_get(url, params = {})
85 86 87 88
      response = handle_request_exceptions do
        Gitlab::HTTP.get(url, **request_params.merge(params))
      end
      handle_response(response)
89 90
    end

91
    def get_issues(**keyword_args)
92
      response = http_get(
93 94 95
        issues_api_url,
        query: list_issue_sentry_query(keyword_args)
      )
96 97 98 99 100

      {
        issues: response[:body],
        pagination: Sentry::PaginationParser.parse(response[:headers])
      }
101 102
    end

103
    def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil)
104 105 106 107
      unless SENTRY_API_SORT_VALUE_MAP.key?(sort)
        raise BadRequestError, 'Invalid value for sort param'
      end

108
      {
109 110
        query: "is:#{issue_status} #{search_term}".strip,
        limit: limit,
111 112 113
        sort: SENTRY_API_SORT_VALUE_MAP[sort],
        cursor: cursor
      }.compact
114 115
    end

116
    def get_issue(issue_id:)
117
      http_get(issue_api_url(issue_id))[:body]
118 119 120
    end

    def get_issue_latest_event(issue_id:)
121
      http_get(issue_latest_event_api_url(issue_id))[:body]
122 123
    end

124
    def get_projects
125
      http_get(projects_api_url)[:body]
126 127
    end

128 129
    def handle_request_exceptions
      yield
130
    rescue Gitlab::HTTP::Error => e
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
      Gitlab::Sentry.track_acceptable_exception(e)
      raise_error 'Error when connecting to Sentry'
    rescue Net::OpenTimeout
      raise_error 'Connection to Sentry timed out'
    rescue SocketError
      raise_error 'Received SocketError when trying to connect to Sentry'
    rescue OpenSSL::SSL::SSLError
      raise_error 'Sentry returned invalid SSL data'
    rescue Errno::ECONNREFUSED
      raise_error 'Connection refused'
    rescue => e
      Gitlab::Sentry.track_acceptable_exception(e)
      raise_error "Sentry request failed due to #{e.class}"
    end

146 147
    def handle_response(response)
      unless response.code == 200
148
        raise_error "Sentry response status code: #{response.code}"
149 150
      end

151
      { body: response.parsed_response, headers: response.headers }
152 153
    end

154 155 156 157
    def raise_error(message)
      raise Client::Error, message
    end

158 159 160 161 162 163 164
    def projects_api_url
      projects_url = URI(@url)
      projects_url.path = '/api/0/projects/'

      projects_url
    end

165 166 167 168 169 170 171 172 173 174 175 176 177 178
    def issue_api_url(issue_id)
      issue_url = URI(@url)
      issue_url.path = "/api/0/issues/#{issue_id}/"

      issue_url
    end

    def issue_latest_event_api_url(issue_id)
      latest_event_url = URI(@url)
      latest_event_url.path = "/api/0/issues/#{issue_id}/events/latest/"

      latest_event_url
    end

179 180 181 182 183 184 185 186
    def issues_api_url
      issues_url = URI(@url + '/issues/')
      issues_url.path.squeeze!('/')

      issues_url
    end

    def map_to_errors(issues)
187 188 189 190 191
      issues.map(&method(:map_to_error))
    end

    def map_to_projects(projects)
      projects.map(&method(:map_to_project))
192 193 194 195 196
    end

    def issue_url(id)
      issues_url = @url + "/issues/#{id}"

197 198 199 200 201 202 203 204 205 206 207
      parse_sentry_url(issues_url)
    end

    def project_url
      parse_sentry_url(@url)
    end

    def parse_sentry_url(api_url)
      url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url)

      uri = URI(url)
208
      uri.path.squeeze!('/')
209
      # Remove trailing slash
210
      uri = uri.to_s.gsub(/\/\z/, '')
211

212
      uri
213 214
    end

215 216 217 218 219 220 221 222 223
    def map_to_event(event)
      stack_trace = parse_stack_trace(event)

      Gitlab::ErrorTracking::ErrorEvent.new(
        issue_id: event.dig('groupID'),
        date_received: event.dig('dateReceived'),
        stack_trace_entries: stack_trace
      )
    end
224

225 226 227
    def parse_stack_trace(event)
      exception_entry = event.dig('entries')&.detect { |h| h['type'] == 'exception' }
      return unless exception_entry
228

229 230 231
      exception_values = exception_entry.dig('data', 'values')
      stack_trace_entry = exception_values&.detect { |h| h['stacktrace'].present? }
      return unless stack_trace_entry
232

233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260
      stack_trace_entry.dig('stacktrace', 'frames')
    end

    def map_to_detailed_error(issue)
      Gitlab::ErrorTracking::DetailedError.new(
        id: issue.fetch('id'),
        first_seen: issue.fetch('firstSeen', nil),
        last_seen: issue.fetch('lastSeen', nil),
        title: issue.fetch('title', nil),
        type: issue.fetch('type', nil),
        user_count: issue.fetch('userCount', nil),
        count: issue.fetch('count', nil),
        message: issue.dig('metadata', 'value'),
        culprit: issue.fetch('culprit', nil),
        external_url: issue_url(issue.fetch('id')),
        external_base_url: project_url,
        short_id: issue.fetch('shortId', nil),
        status: issue.fetch('status', nil),
        frequency: issue.dig('stats', '24h'),
        project_id: issue.dig('project', 'id'),
        project_name: issue.dig('project', 'name'),
        project_slug: issue.dig('project', 'slug'),
        first_release_last_commit: issue.dig('firstRelease', 'lastCommit'),
        last_release_last_commit: issue.dig('lastRelease', 'lastCommit'),
        first_release_short_version: issue.dig('firstRelease', 'shortVersion'),
        last_release_short_version: issue.dig('lastRelease', 'shortVersion')
      )
    end
261

262
    def map_to_error(issue)
263
      Gitlab::ErrorTracking::Error.new(
264
        id: issue.fetch('id'),
265 266 267 268 269
        first_seen: issue.fetch('firstSeen', nil),
        last_seen: issue.fetch('lastSeen', nil),
        title: issue.fetch('title', nil),
        type: issue.fetch('type', nil),
        user_count: issue.fetch('userCount', nil),
270 271
        count: issue.fetch('count', nil),
        message: issue.dig('metadata', 'value'),
272
        culprit: issue.fetch('culprit', nil),
273
        external_url: issue_url(issue.fetch('id')),
274 275
        short_id: issue.fetch('shortId', nil),
        status: issue.fetch('status', nil),
276
        frequency: issue.dig('stats', '24h'),
277 278 279
        project_id: issue.dig('project', 'id'),
        project_name: issue.dig('project', 'name'),
        project_slug: issue.dig('project', 'slug')
280 281
      )
    end
282 283 284 285 286

    def map_to_project(project)
      organization = project.fetch('organization')

      Gitlab::ErrorTracking::Project.new(
287
        id: project.fetch('id', nil),
288 289 290 291
        name: project.fetch('name'),
        slug: project.fetch('slug'),
        status: project.dig('status'),
        organization_name: organization.fetch('name'),
292
        organization_id: organization.fetch('id', nil),
293 294 295
        organization_slug: organization.fetch('slug')
      )
    end
296 297
  end
end