summaryrefslogtreecommitdiff
path: root/lib/error_tracking
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-03-04 15:11:19 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-03-04 15:11:19 +0000
commit770adf92515e4311dfb42d89750d32a1e0628913 (patch)
tree574db6e5e92af5c1a0ffe87be345fffa24bb95f7 /lib/error_tracking
parentd5d47b45ddddcef0f8fc80a35ca7a8a2a0765fd1 (diff)
downloadgitlab-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.rb100
-rw-r--r--lib/error_tracking/sentry_client/api_urls.rb41
-rw-r--r--lib/error_tracking/sentry_client/event.rb36
-rw-r--r--lib/error_tracking/sentry_client/issue.rb184
-rw-r--r--lib/error_tracking/sentry_client/issue_link.rb52
-rw-r--r--lib/error_tracking/sentry_client/pagination_parser.rb25
-rw-r--r--lib/error_tracking/sentry_client/projects.rb39
-rw-r--r--lib/error_tracking/sentry_client/repo.rb38
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