diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-04 15:11:19 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-04 15:11:19 +0000 |
commit | 770adf92515e4311dfb42d89750d32a1e0628913 (patch) | |
tree | 574db6e5e92af5c1a0ffe87be345fffa24bb95f7 /lib/error_tracking | |
parent | d5d47b45ddddcef0f8fc80a35ca7a8a2a0765fd1 (diff) | |
download | gitlab-ce-770adf92515e4311dfb42d89750d32a1e0628913.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'lib/error_tracking')
-rw-r--r-- | lib/error_tracking/sentry_client.rb | 100 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/api_urls.rb | 41 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/event.rb | 36 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/issue.rb | 184 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/issue_link.rb | 52 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/pagination_parser.rb | 25 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/projects.rb | 39 | ||||
-rw-r--r-- | lib/error_tracking/sentry_client/repo.rb | 38 |
8 files changed, 515 insertions, 0 deletions
diff --git a/lib/error_tracking/sentry_client.rb b/lib/error_tracking/sentry_client.rb new file mode 100644 index 00000000000..68e64fba093 --- /dev/null +++ b/lib/error_tracking/sentry_client.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + include SentryClient::Event + include SentryClient::Projects + include SentryClient::Issue + include SentryClient::Repo + include SentryClient::IssueLink + + Error = Class.new(StandardError) + MissingKeysError = Class.new(StandardError) + + attr_accessor :url, :token + + def initialize(api_url, token) + @url = api_url + @token = token + end + + private + + def api_urls + @api_urls ||= SentryClient::ApiUrls.new(@url) + end + + def handle_mapping_exceptions(&block) + yield + rescue KeyError => e + Gitlab::ErrorTracking.track_exception(e) + raise MissingKeysError, "Sentry API response is missing keys. #{e.message}" + end + + def request_params + { + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{@token}" + }, + follow_redirects: false + } + end + + def http_get(url, params = {}) + http_request do + Gitlab::HTTP.get(url, **request_params.merge(params)) + end + end + + def http_put(url, params = {}) + http_request do + Gitlab::HTTP.put(url, **request_params.merge(body: params.to_json)) + end + end + + def http_post(url, params = {}) + http_request do + Gitlab::HTTP.post(url, **request_params.merge(body: params.to_json)) + end + end + + def http_request + response = handle_request_exceptions do + yield + end + + handle_response(response) + end + + def handle_request_exceptions + yield + rescue Gitlab::HTTP::Error => e + Gitlab::ErrorTracking.track_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::ErrorTracking.track_exception(e) + raise_error "Sentry request failed due to #{e.class}" + end + + def handle_response(response) + unless response.code.between?(200, 204) + raise_error "Sentry response status code: #{response.code}" + end + + { body: response.parsed_response, headers: response.headers } + end + + def raise_error(message) + raise SentryClient::Error, message + end + end +end diff --git a/lib/error_tracking/sentry_client/api_urls.rb b/lib/error_tracking/sentry_client/api_urls.rb new file mode 100644 index 00000000000..387309bfbdb --- /dev/null +++ b/lib/error_tracking/sentry_client/api_urls.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + class ApiUrls + def initialize(url_base) + @uri = URI(url_base).freeze + end + + def issues_url + with_path(File.join(@uri.path, '/issues/')) + end + + def issue_url(issue_id) + with_path("/api/0/issues/#{escape(issue_id)}/") + end + + def projects_url + with_path('/api/0/projects/') + end + + def issue_latest_event_url(issue_id) + with_path("/api/0/issues/#{escape(issue_id)}/events/latest/") + end + + private + + def with_path(new_path) + new_uri = @uri.dup + # Sentry API returns 404 if there are extra slashes in the URL + new_uri.path = new_path.squeeze('/') + + new_uri + end + + def escape(param) + CGI.escape(param.to_s) + end + end + end +end diff --git a/lib/error_tracking/sentry_client/event.rb b/lib/error_tracking/sentry_client/event.rb new file mode 100644 index 00000000000..93449344d6c --- /dev/null +++ b/lib/error_tracking/sentry_client/event.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + module Event + def issue_latest_event(issue_id:) + latest_event = http_get(api_urls.issue_latest_event_url(issue_id))[:body] + + map_to_event(latest_event) + end + + private + + 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 + + def parse_stack_trace(event) + exception_entry = event.dig('entries')&.detect { |h| h['type'] == 'exception' } + return [] unless exception_entry + + exception_values = exception_entry.dig('data', 'values') + stack_trace_entry = exception_values&.detect { |h| h['stacktrace'].present? } + return [] unless stack_trace_entry + + stack_trace_entry.dig('stacktrace', 'frames') || [] + end + end + end +end diff --git a/lib/error_tracking/sentry_client/issue.rb b/lib/error_tracking/sentry_client/issue.rb new file mode 100644 index 00000000000..513fb3daabe --- /dev/null +++ b/lib/error_tracking/sentry_client/issue.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + module Issue + BadRequestError = Class.new(StandardError) + ResponseInvalidSizeError = Class.new(StandardError) + + SENTRY_API_SORT_VALUE_MAP = { + # <accepted_by_client> => <accepted_by_sentry_api> + 'frequency' => 'freq', + 'first_seen' => 'new', + 'last_seen' => nil + }.freeze + + def list_issues(**keyword_args) + response = get_issues(**keyword_args) + + issues = response[:issues] + pagination = response[:pagination] + + validate_size(issues) + + handle_mapping_exceptions do + { + issues: map_to_errors(issues), + pagination: pagination + } + end + end + + def issue_details(issue_id:) + issue = get_issue(issue_id: issue_id) + + map_to_detailed_error(issue) + end + + def update_issue(issue_id:, params:) + http_put(api_urls.issue_url(issue_id), params)[:body] + end + + private + + def get_issues(**keyword_args) + response = http_get( + api_urls.issues_url, + query: list_issue_sentry_query(**keyword_args) + ) + + { + issues: response[:body], + pagination: SentryClient::PaginationParser.parse(response[:headers]) + } + end + + def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil) + unless SENTRY_API_SORT_VALUE_MAP.key?(sort) + raise BadRequestError, 'Invalid value for sort param' + end + + { + query: "is:#{issue_status} #{search_term}".strip, + limit: limit, + sort: SENTRY_API_SORT_VALUE_MAP[sort], + cursor: cursor + }.compact + end + + def validate_size(issues) + return if Gitlab::Utils::DeepSize.new(issues).valid? + + raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}." + end + + def get_issue(issue_id:) + http_get(api_urls.issue_url(issue_id))[:body] + end + + def parse_gitlab_issue(issue) + parse_issue_annotations(issue) || parse_plugin_issue(issue) + end + + def parse_issue_annotations(issue) + issue + .fetch('annotations', []) + .reject(&:blank?) + .map { |annotation| Nokogiri.make(annotation) } + .find { |html| html['href']&.starts_with?(Gitlab.config.gitlab.url) } + .try(:[], 'href') + end + + def parse_plugin_issue(issue) + plugin_issues = issue.fetch('pluginIssues', nil) + return unless plugin_issues + + gitlab_plugin = plugin_issues.detect { |item| item['id'] == 'gitlab' } + return unless gitlab_plugin + + gitlab_plugin.dig('issue', 'url') + end + + def issue_url(id) + parse_sentry_url("#{url}/issues/#{id}") + 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) + uri.path.squeeze!('/') + # Remove trailing slash + uri = uri.to_s.delete_suffix('/') + + uri + end + + def map_to_errors(issues) + issues.map(&method(:map_to_error)) + end + + def map_to_error(issue) + Gitlab::ErrorTracking::Error.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')), + 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') + ) + 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), + tags: extract_tags(issue), + 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'), + gitlab_issue: parse_gitlab_issue(issue), + 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'), + first_release_short_version: issue.dig('firstRelease', 'shortVersion'), + first_release_version: issue.dig('firstRelease', 'version'), + last_release_last_commit: issue.dig('lastRelease', 'lastCommit'), + last_release_short_version: issue.dig('lastRelease', 'shortVersion'), + last_release_version: issue.dig('lastRelease', 'version') + }) + end + + def extract_tags(issue) + { + level: issue.fetch('level', nil), + logger: issue.fetch('logger', nil) + } + end + end + end +end diff --git a/lib/error_tracking/sentry_client/issue_link.rb b/lib/error_tracking/sentry_client/issue_link.rb new file mode 100644 index 00000000000..1c2e8c4147a --- /dev/null +++ b/lib/error_tracking/sentry_client/issue_link.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + module IssueLink + # Creates a link in Sentry corresponding to the provided + # Sentry issue and GitLab issue + # @param integration_id [Integer, nil] Representing a global + # GitLab integration in Sentry. Nil for plugins. + # @param sentry_issue_id [Integer] Id for an issue from Sentry + # @param issue [Issue] Issue for which the link should be created + def create_issue_link(integration_id, sentry_issue_id, issue) + return create_plugin_link(sentry_issue_id, issue) unless integration_id + + create_global_integration_link(integration_id, sentry_issue_id, issue) + end + + private + + def create_global_integration_link(integration_id, sentry_issue_id, issue) + issue_link_url = global_integration_link_api_url(integration_id, sentry_issue_id) + + params = { + project: issue.project.id, + externalIssue: "#{issue.project.id}##{issue.iid}" + } + + http_put(issue_link_url, params) + end + + def global_integration_link_api_url(integration_id, sentry_issue_id) + issue_link_url = URI(url) + issue_link_url.path = "/api/0/groups/#{sentry_issue_id}/integrations/#{integration_id}/" + + issue_link_url + end + + def create_plugin_link(sentry_issue_id, issue) + issue_link_url = plugin_link_api_url(sentry_issue_id) + + http_post(issue_link_url, issue_id: issue.iid) + end + + def plugin_link_api_url(sentry_issue_id) + issue_link_url = URI(url) + issue_link_url.path = "/api/0/issues/#{sentry_issue_id}/plugins/gitlab/link/" + + issue_link_url + end + end + end +end diff --git a/lib/error_tracking/sentry_client/pagination_parser.rb b/lib/error_tracking/sentry_client/pagination_parser.rb new file mode 100644 index 00000000000..362a5d098f7 --- /dev/null +++ b/lib/error_tracking/sentry_client/pagination_parser.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + module PaginationParser + PATTERN = /rel=\"(?<direction>\w+)\";\sresults=\"(?<results>\w+)\";\scursor=\"(?<cursor>.+)\"/.freeze + + def self.parse(headers) + links = headers['link'].to_s.split(',') + + links.map { |link| parse_link(link) }.compact.to_h + end + + def self.parse_link(link) + match = link.match(PATTERN) + + return unless match + return if match['results'] != "true" + + [match['direction'], { 'cursor' => match['cursor'] }] + end + private_class_method :parse_link + end + end +end diff --git a/lib/error_tracking/sentry_client/projects.rb b/lib/error_tracking/sentry_client/projects.rb new file mode 100644 index 00000000000..9b8daa226b0 --- /dev/null +++ b/lib/error_tracking/sentry_client/projects.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + module Projects + def projects + projects = get_projects + + handle_mapping_exceptions do + map_to_projects(projects) + end + end + + private + + def get_projects + http_get(api_urls.projects_url)[:body] + end + + def map_to_projects(projects) + projects.map(&method(:map_to_project)) + end + + def map_to_project(project) + organization = project.fetch('organization') + + Gitlab::ErrorTracking::Project.new( + id: project.fetch('id', nil), + name: project.fetch('name'), + slug: project.fetch('slug'), + status: project.dig('status'), + organization_name: organization.fetch('name'), + organization_id: organization.fetch('id', nil), + organization_slug: organization.fetch('slug') + ) + end + end + end +end diff --git a/lib/error_tracking/sentry_client/repo.rb b/lib/error_tracking/sentry_client/repo.rb new file mode 100644 index 00000000000..3baa7e69be6 --- /dev/null +++ b/lib/error_tracking/sentry_client/repo.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ErrorTracking + class SentryClient + module Repo + def repos(organization_slug) + repos_url = repos_api_url(organization_slug) + + repos = http_get(repos_url)[:body] + + handle_mapping_exceptions do + map_to_repos(repos) + end + end + + private + + def repos_api_url(organization_slug) + repos_url = URI(url) + repos_url.path = "/api/0/organizations/#{organization_slug}/repos/" + + repos_url + end + + def map_to_repos(repos) + repos.map(&method(:map_to_repo)) + end + + def map_to_repo(repo) + Gitlab::ErrorTracking::Repo.new( + status: repo.fetch('status'), + integration_id: repo.fetch('integrationId'), + project_id: repo.fetch('externalSlug') + ) + end + end + end +end |