Commit 5ab1388f authored by rpereira2's avatar rpereira2 Committed by Peter Leitzen

Add service for hitting sentry issues api

Add a client which will pull a list of sentry issues using the sentry
api. The returned hash will be converted into an activemodel object.
parent 1ced98ab
......@@ -6,9 +6,35 @@ module ErrorTracking
validates :api_url, length: { maximum: 255 }, public_url: true, url: { enforce_sanitization: true }
validate :validate_api_url_path
attr_encrypted :token,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm'
def sentry_client
Sentry::Client.new(api_url, token)
end
def sentry_external_url
extract_external_url
end
private
# http://HOST/api/0/projects/ORG/PROJECT
# ->
# http://HOST/ORG/PROJECT
def extract_external_url
api_url.sub('api/0/projects/', '')
end
def validate_api_url_path
unless URI(api_url).path.starts_with?('/api/0/projects')
errors.add(:api_url, 'path needs to start with /api/0/projects')
end
rescue URI::InvalidURIError
end
end
end
......@@ -200,6 +200,7 @@ class ProjectPolicy < BasePolicy
enable :read_environment
enable :read_deployment
enable :read_merge_request
enable :read_sentry_issue
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
......
# frozen_string_literal: true
module ErrorTracking
class ListIssuesService < ::BaseService
DEFAULT_ISSUE_STATUS = 'unresolved'.freeze
DEFAULT_LIMIT = 20
def execute
return error('not enabled') unless valid?
return error('access denied') unless can?(current_user, :read_sentry_issue, project)
issues = sentry_client.list_issues(issue_status: issue_status, limit: limit)
success(issues: issues)
end
def external_url
project_error_tracking_setting&.sentry_external_url
end
private
def project_error_tracking_setting
project.error_tracking_setting
end
def issue_status
params[:issue_status] || DEFAULT_ISSUE_STATUS
end
def limit
params[:limit] || DEFAULT_LIMIT
end
def valid?
project_error_tracking_setting&.enabled?
end
def sentry_client
project_error_tracking_setting&.sentry_client
end
end
end
# frozen_string_literal: true
module Gitlab
module ErrorTracking
class Error
include ActiveModel::Model
attr_accessor :id, :title, :type, :user_count, :count,
:first_seen, :last_seen, :message, :culprit,
:external_url, :project_id, :project_name, :project_slug,
:short_id, :status, :frequency
end
end
end
# frozen_string_literal: true
module Sentry
class Client
Error = Class.new(StandardError)
attr_accessor :url, :token
def initialize(api_url, token)
@url = api_url
@token = token
end
def list_issues(issue_status:, limit:)
issues = get_issues(issue_status: issue_status, limit: limit)
map_to_errors(issues)
end
private
def request_params
{
headers: {
'Authorization' => "Bearer #{@token}"
},
follow_redirects: false
}
end
def request_params
{
headers: {
'Authorization' => "Bearer #{@token}"
},
follow_redirects: false
}
end
def get_issues(issue_status:, limit:)
resp = Gitlab::HTTP.get(
issues_api_url,
**request_params.merge(query: {
query: "is:#{issue_status}",
limit: limit
})
)
handle_response(resp)
end
def handle_response(response)
unless response.code == 200
raise Client::Error, "Sentry response error: #{response.code}"
end
response.as_json
end
def issues_api_url
issues_url = URI(@url + '/issues/')
issues_url.path.squeeze!('/')
issues_url
end
def map_to_errors(issues)
issues.map do |issue|
map_to_error(issue)
end
end
def map_to_error(issue)
project = issue.fetch('project')
count = issue.fetch('count', nil)
frequency = issue.dig('stats', '24h')
message = issue.dig('metadata', 'value')
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: count,
message: message,
culprit: issue.fetch('culprit', nil),
external_url: issue.fetch('permalink', nil),
short_id: issue.fetch('shortId', nil),
status: issue.fetch('status', nil),
frequency: frequency,
project_id: project.fetch('id'),
project_name: project.fetch('name', nil),
project_slug: project.fetch('slug', nil)
)
end
end
end
......@@ -3,7 +3,7 @@
FactoryBot.define do
factory :project_error_tracking_setting, class: ErrorTracking::ProjectErrorTrackingSetting do
project
api_url 'https://gitlab.com'
api_url 'https://gitlab.com/api/0/projects/sentry-org/sentry-project'
enabled true
token 'access_token_123'
end
......
[{
"lastSeen": "2018-12-31T12:00:11Z",
"numComments": 0,
"userCount": 0,
"stats": {
"24h": [
[
1546437600,
0
]
]
},
"culprit": "sentry.tasks.reports.deliver_organization_user_report",
"title": "gaierror: [Errno -2] Name or service not known",
"id": "11",
"assignedTo": null,
"logger": null,
"type": "error",
"annotations": [],
"metadata": {
"type": "gaierror",
"value": "[Errno -2] Name or service not known"
},
"status": "unresolved",
"subscriptionDetails": null,
"isPublic": false,
"hasSeen": false,
"shortId": "INTERNAL-4",
"shareId": null,
"firstSeen": "2018-12-17T12:00:14Z",
"count": "21",
"permalink": "35.228.54.90/sentry/internal/issues/11/",
"level": "error",
"isSubscribed": true,
"isBookmarked": false,
"project": {
"slug": "internal",
"id": "1",
"name": "Internal"
},
"statusDetails": {}
}]
# frozen_string_literal: true
require 'spec_helper'
describe Sentry::Client do
let(:issue_status) { 'unresolved' }
let(:limit) { 20 }
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
let(:sample_response) do
Gitlab::Utils.deep_indifferent_access(
JSON.parse(File.read(Rails.root.join('spec/fixtures/sentry/issues_sample_response.json')))
)
end
subject(:client) { described_class.new(sentry_url, token) }
describe '#list_issues' do
subject { client.list_issues(issue_status: issue_status, limit: limit) }
before do
stub_sentry_request(sentry_url + '/issues/?limit=20&query=is:unresolved', body: sample_response)
end
it 'returns objects of type ErrorTracking::Error' do
expect(subject.length).to eq(1)
expect(subject[0]).to be_a(Gitlab::ErrorTracking::Error)
end
context 'error object created from sentry response' do
using RSpec::Parameterized::TableSyntax
where(:error_object, :sentry_response) do
:id | :id
:first_seen | :firstSeen
:last_seen | :lastSeen
:title | :title
:type | :type
:user_count | :userCount
:count | :count
:message | [:metadata, :value]
:culprit | :culprit
:external_url | :permalink
:short_id | :shortId
:status | :status
:frequency | [:stats, '24h']
:project_id | [:project, :id]
:project_name | [:project, :name]
:project_slug | [:project, :slug]
end
with_them do
it { expect(subject[0].public_send(error_object)).to eq(sample_response[0].dig(*sentry_response)) }
end
end
context 'redirects' do
let(:redirect_to) { 'https://redirected.example.com' }
let(:other_url) { 'https://other.example.org' }
let!(:redirected_req_stub) { stub_sentry_request(other_url) }
let!(:redirect_req_stub) do
stub_sentry_request(
sentry_url + '/issues/?limit=20&query=is:unresolved',
status: 302,
headers: { location: redirect_to }
)
end
it 'does not follow redirects' do
expect { subject }.to raise_exception(Sentry::Client::Error, 'Sentry response error: 302')
expect(redirect_req_stub).to have_been_requested
expect(redirected_req_stub).not_to have_been_requested
end
end
# Sentry API returns 404 if there are extra slashes in the URL!
context 'extra slashes in URL' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects//sentry-org/sentry-project/' }
let(:client) { described_class.new(sentry_url, token) }
let!(:valid_req_stub) do
stub_sentry_request(
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved'
)
end
it 'removes extra slashes in api url' do
expect(Gitlab::HTTP).to receive(:get).with(
URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'),
anything
).and_call_original
client.list_issues(issue_status: issue_status, limit: limit)
expect(valid_req_stub).to have_been_requested
end
end
end
private
def stub_sentry_request(url, body: {}, status: 200, headers: {})
WebMock.stub_request(:get, url)
.to_return(
status: status,
headers: { 'Content-Type' => 'application/json' }.merge(headers),
body: body.to_json
)
end
end
......@@ -5,32 +5,59 @@ require 'spec_helper'
describe ErrorTracking::ProjectErrorTrackingSetting do
set(:project) { create(:project) }
subject { create(:project_error_tracking_setting, project: project) }
describe 'Associations' do
it { is_expected.to belong_to(:project) }
end
describe 'Validations' do
subject { create(:project_error_tracking_setting, project: project) }
context 'when api_url is over 255 chars' do
before do
it 'fails validation' do
subject.api_url = 'https://' + 'a' * 250
end
it 'fails validation' do
expect(subject).not_to be_valid
expect(subject.errors.messages[:api_url]).to include('is too long (maximum is 255 characters)')
end
end
context 'With unsafe url' do
let(:project_error_tracking_setting) { create(:project_error_tracking_setting, project: project) }
it 'fails validation' do
project_error_tracking_setting.api_url = "https://replaceme.com/'><script>alert(document.cookie)</script>"
subject.api_url = "https://replaceme.com/'><script>alert(document.cookie)</script>"
expect(project_error_tracking_setting).not_to be_valid
expect(subject).not_to be_valid
end
end
context 'URL path' do
it 'fails validation with wrong path' do
subject.api_url = 'http://gitlab.com/project1/something'
expect(subject).not_to be_valid
expect(subject.errors.messages[:api_url]).to include('path needs to start with /api/0/projects')
end
it 'passes validation with correct path' do
subject.api_url = 'http://gitlab.com/api/0/projects/project1/something'
expect(subject).to be_valid
end
end
end
describe '#sentry_external_url' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
it 'returns the correct url' do
subject.api_url = sentry_url
expect(subject.sentry_external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project')
end
end
describe '#sentry_client' do
it 'returns sentry client' do
expect(subject.sentry_client).to be_a(Sentry::Client)
end
end
end
......@@ -24,7 +24,7 @@ describe ProjectPolicy do
download_code fork_project create_project_snippet update_issue
admin_issue admin_label admin_list read_commit_status read_build
read_container_image read_pipeline read_environment read_deployment
read_merge_request download_wiki_code
read_merge_request download_wiki_code read_sentry_issue
]
end
......
# frozen_string_literal: true
require 'spec_helper'
describe ErrorTracking::ListIssuesService do
set(:user) { create(:user) }
set(:project) { create(:project) }
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
let(:client) { Sentry::Client.new(sentry_url, token) }
let(:error_tracking_setting) do
create(:project_error_tracking_setting, api_url: sentry_url, token: token, project: project)
end
subject { described_class.new(project, user) }
before do
expect(project).to receive(:error_tracking_setting).at_least(:once).and_return(error_tracking_setting)
project.add_reporter(user)
end
describe '#execute' do
context 'with authorized user' do
before do
expect(error_tracking_setting).to receive(:sentry_client).and_return(client)
end
it 'calls sentry client' do
expect(client).to receive(:list_issues).and_return([])
result = subject.execute
expect(result).to include(status: :success)
end
end
context 'with unauthorized user' do
let(:unauthorized_user) { create(:user) }
subject { described_class.new(project, unauthorized_user) }
it 'returns error' do
result = subject.execute
expect(result).to include(status: :error, message: 'access denied')
end
end
context 'with error tracking disabled' do
before do
error_tracking_setting.enabled = false
end
it 'raises error' do
result = subject.execute
expect(result).to include(status: :error, message: 'not enabled')
end
end
end
describe '#sentry_external_url' do
let(:external_url) { 'https://sentrytest.gitlab.com/sentry-org/sentry-project' }
it 'calls ErrorTracking::ProjectErrorTrackingSetting' do
expect(error_tracking_setting).to receive(:sentry_external_url).and_call_original
subject.external_url
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment