Commit ecb551e5 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch 'extract-list-sentry-issues-from-sentry-client' into 'master'

Extract list_issues from Sentry::Client class

See merge request gitlab-org/gitlab!22615
parents c6ef87c5 4d89445d
......@@ -9,14 +9,6 @@ module Sentry
Error = Class.new(StandardError)
MissingKeysError = Class.new(StandardError)
ResponseInvalidSizeError = Class.new(StandardError)
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
attr_accessor :url, :token
......@@ -25,30 +17,8 @@ module Sentry
@token = token
end
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
private
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 handle_mapping_exceptions(&block)
yield
rescue KeyError => e
......@@ -85,31 +55,6 @@ module Sentry
handle_response(response)
end
def get_issues(**keyword_args)
response = http_get(
issues_api_url,
query: list_issue_sentry_query(keyword_args)
)
{
issues: response[:body],
pagination: Sentry::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 handle_request_exceptions
yield
rescue Gitlab::HTTP::Error => e
......@@ -139,58 +84,5 @@ module Sentry
def raise_error(message)
raise Client::Error, message
end
def issues_api_url
issues_url = URI(@url + '/issues/')
issues_url.path.squeeze!('/')
issues_url
end
def map_to_errors(issues)
issues.map(&method(:map_to_error))
end
def issue_url(id)
issues_url = @url + "/issues/#{id}"
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)
uri.path.squeeze!('/')
# Remove trailing slash
uri = uri.to_s.gsub(/\/\z/, '')
uri
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
end
end
......@@ -3,6 +3,31 @@
module Sentry
class Client
module Issue
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
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)
......@@ -11,6 +36,37 @@ module Sentry
private
def get_issues(**keyword_args)
response = http_get(
issues_api_url,
query: list_issue_sentry_query(keyword_args)
)
{
issues: response[:body],
pagination: Sentry::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(issue_api_url(issue_id))[:body]
end
......@@ -19,6 +75,13 @@ module Sentry
http_put(issue_api_url(issue_id), params)[:body]
end
def issues_api_url
issues_url = URI("#{url}/issues/")
issues_url.path.squeeze!('/')
issues_url
end
def issue_api_url(issue_id)
issue_url = URI(url)
issue_url.path = "/api/0/issues/#{CGI.escape(issue_id.to_s)}/"
......@@ -35,6 +98,50 @@ module Sentry
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.gsub(/\/\z/, '')
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'),
......
......@@ -8,6 +8,216 @@ describe Sentry::Client::Issue do
let(:token) { 'test-token' }
let(:client) { Sentry::Client.new(sentry_url, token) }
describe '#list_issues' do
shared_examples 'issues have correct return type' do |klass|
it "returns objects of type #{klass}" do
expect(subject[:issues]).to all( be_a(klass) )
end
end
shared_examples 'issues have correct length' do |length|
it { expect(subject[:issues].length).to eq(length) }
end
let(:issues_sample_response) do
Gitlab::Utils.deep_indifferent_access(
JSON.parse(fixture_file('sentry/issues_sample_response.json'))
)
end
let(:default_httparty_options) do
{
follow_redirects: false,
headers: { "Authorization" => "Bearer test-token" }
}
end
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:issue_status) { 'unresolved' }
let(:limit) { 20 }
let(:search_term) { '' }
let(:cursor) { nil }
let(:sort) { 'last_seen' }
let(:sentry_api_response) { issues_sample_response }
let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
shared_examples 'has correct external_url' do
context 'external_url' do
it 'is constructed correctly' do
expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11')
end
end
end
context 'when response has a pagination info' do
let(:headers) do
{
link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"'
}
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response, headers: headers) }
it 'parses the pagination' do
expect(subject[:pagination]).to eq(
'previous' => { 'cursor' => '1573556671000:0:1' },
'next' => { 'cursor' => '1572959139000:0:0' }
)
end
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
: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[:issues][0].public_send(error_object)).to eq(sentry_api_response[0].dig(*sentry_response)) }
end
it_behaves_like 'has correct external_url'
end
context 'redirects' do
let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
it_behaves_like 'no Sentry redirects'
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(:sentry_request_url) do
'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(client.url).to eq(sentry_url)
expect(Gitlab::HTTP).to receive(:get).with(
URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'),
anything
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'requests with sort parameter in sentry api' do
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved&sort=freq'
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'frequency') }
it 'calls the sentry api with sort params' do
expect(Gitlab::HTTP).to receive(:get).with(
URI("#{sentry_url}/issues/"),
default_httparty_options.merge(query: { limit: 20, query: "is:unresolved", sort: "freq" })
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'with invalid sort params' do
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') }
it 'throws an error' do
expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param')
end
end
context 'Older sentry versions where keys are not present' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue[:project].delete(:id)
issue
end
end
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
it_behaves_like 'has correct external_url'
end
context 'essential keys missing in API response' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue.except(:id)
end
end
it 'raises exception' do
expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
end
end
context 'sentry api response too large' do
it 'raises exception' do
deep_size = double('Gitlab::Utils::DeepSize', valid?: false)
allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size)
expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.')
end
end
it_behaves_like 'maps Sentry exceptions'
context 'when search term is present' do
let(:search_term) { 'NoMethodError' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved NoMethodError" }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
end
context 'when cursor is present' do
let(:cursor) { '1572959139000:0:0' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&cursor=#{cursor}&query=is:unresolved" }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
end
end
describe '#issue_details' do
let(:issue_sample_response) do
Gitlab::Utils.deep_indifferent_access(
......
......@@ -3,219 +3,13 @@
require 'spec_helper'
describe Sentry::Client do
include SentryClientHelpers
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' }
let(:default_httparty_options) do
{
follow_redirects: false,
headers: { "Authorization" => "Bearer test-token" }
}
end
subject(:client) { described_class.new(sentry_url, token) }
shared_examples 'issues has correct return type' do |klass|
it "returns objects of type #{klass}" do
expect(subject[:issues]).to all( be_a(klass) )
end
end
shared_examples 'issues has correct length' do |length|
it { expect(subject[:issues].length).to eq(length) }
end
describe '#list_issues' do
let(:issues_sample_response) do
Gitlab::Utils.deep_indifferent_access(
JSON.parse(fixture_file('sentry/issues_sample_response.json'))
)
end
let(:issue_status) { 'unresolved' }
let(:limit) { 20 }
let(:search_term) { '' }
let(:cursor) { nil }
let(:sort) { 'last_seen' }
let(:sentry_api_response) { issues_sample_response }
let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
shared_examples 'has correct external_url' do
context 'external_url' do
it 'is constructed correctly' do
expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11')
end
end
end
context 'when response has a pagination info' do
let(:headers) do
{
link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"'
}
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response, headers: headers) }
it 'parses the pagination' do
expect(subject[:pagination]).to eq(
'previous' => { 'cursor' => '1573556671000:0:1' },
'next' => { 'cursor' => '1572959139000:0:0' }
)
end
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
: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[:issues][0].public_send(error_object)).to eq(sentry_api_response[0].dig(*sentry_response)) }
end
it_behaves_like 'has correct external_url'
end
context 'redirects' do
let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
it_behaves_like 'no Sentry redirects'
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(:sentry_request_url) do
'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(client.url).to eq(sentry_url)
expect(Gitlab::HTTP).to receive(:get).with(
URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'),
anything
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'requests with sort parameter in sentry api' do
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved&sort=freq'
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'frequency') }
it 'calls the sentry api with sort params' do
expect(Gitlab::HTTP).to receive(:get).with(
URI("#{sentry_url}/issues/"),
default_httparty_options.merge(query: { limit: 20, query: "is:unresolved", sort: "freq" })
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'with invalid sort params' do
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') }
it 'throws an error' do
expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param')
end
end
context 'Older sentry versions where keys are not present' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue[:project].delete(:id)
issue
end
end
it_behaves_like 'calls sentry api'
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
it_behaves_like 'has correct external_url'
end
context 'essential keys missing in API response' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue.except(:id)
end
end
it 'raises exception' do
expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
end
end
context 'sentry api response too large' do
it 'raises exception' do
deep_size = double('Gitlab::Utils::DeepSize', valid?: false)
allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size)
expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.')
end
end
it_behaves_like 'maps Sentry exceptions'
context 'when search term is present' do
let(:search_term) { 'NoMethodError' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved NoMethodError" }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
end
context 'when cursor is present' do
let(:cursor) { '1572959139000:0:0' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&cursor=#{cursor}&query=is:unresolved" }
it_behaves_like 'calls sentry api'
subject { Sentry::Client.new(sentry_url, token) }
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
end
end
it { is_expected.to respond_to :projects }
it { is_expected.to respond_to :list_issues }
it { is_expected.to respond_to :issue_details }
it { is_expected.to respond_to :issue_latest_event }
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