Commit 599681ca authored by Jan Provaznik's avatar Jan Provaznik

Merge branch '34943-graphql-sentry-details' into 'master'

GraphQL for Sentry error details

Closes #34943

See merge request gitlab-org/gitlab!19733
parents 425c041e fb085280
# frozen_string_literal: true
module Resolvers
module ErrorTracking
class SentryDetailedErrorResolver < BaseResolver
argument :id, GraphQL::ID_TYPE,
required: true,
description: 'ID of the Sentry issue'
def resolve(**args)
project = object
current_user = context[:current_user]
issue_id = GlobalID.parse(args[:id]).model_id
# Get data from Sentry
response = ::ErrorTracking::IssueDetailsService.new(
project,
current_user,
{ issue_id: issue_id }
).execute
issue = response[:issue]
issue.gitlab_project = project if issue
issue
end
end
end
end
# frozen_string_literal: true
module Types
module ErrorTracking
class SentryDetailedErrorType < ::Types::BaseObject
graphql_name 'SentryDetailedError'
present_using SentryDetailedErrorPresenter
authorize :read_sentry_issue
field :id, GraphQL::ID_TYPE,
null: false,
description: "ID (global ID) of the error"
field :sentry_id, GraphQL::STRING_TYPE,
method: :id,
null: false,
description: "ID (Sentry ID) of the error"
field :title, GraphQL::STRING_TYPE,
null: false,
description: "Title of the error"
field :type, GraphQL::STRING_TYPE,
null: false,
description: "Type of the error"
field :user_count, GraphQL::INT_TYPE,
null: false,
description: "Count of users affected by the error"
field :count, GraphQL::INT_TYPE,
null: false,
description: "Count of occurrences"
field :first_seen, Types::TimeType,
null: false,
description: "Timestamp when the error was first seen"
field :last_seen, Types::TimeType,
null: false,
description: "Timestamp when the error was last seen"
field :message, GraphQL::STRING_TYPE,
null: true,
description: "Sentry metadata message of the error"
field :culprit, GraphQL::STRING_TYPE,
null: false,
description: "Culprit of the error"
field :external_url, GraphQL::STRING_TYPE,
null: false,
description: "External URL of the error"
field :sentry_project_id, GraphQL::ID_TYPE,
method: :project_id,
null: false,
description: "ID of the project (Sentry project)"
field :sentry_project_name, GraphQL::STRING_TYPE,
method: :project_name,
null: false,
description: "Name of the project affected by the error"
field :sentry_project_slug, GraphQL::STRING_TYPE,
method: :project_slug,
null: false,
description: "Slug of the project affected by the error"
field :short_id, GraphQL::STRING_TYPE,
null: false,
description: "Short ID (Sentry ID) of the error"
field :status, Types::ErrorTracking::SentryErrorStatusEnum,
null: false,
description: "Status of the error"
field :frequency, [Types::ErrorTracking::SentryErrorFrequencyType],
null: false,
description: "Last 24hr stats of the error"
field :first_release_last_commit, GraphQL::STRING_TYPE,
null: true,
description: "Commit the error was first seen"
field :last_release_last_commit, GraphQL::STRING_TYPE,
null: true,
description: "Commit the error was last seen"
field :first_release_short_version, GraphQL::STRING_TYPE,
null: true,
description: "Release version the error was first seen"
field :last_release_short_version, GraphQL::STRING_TYPE,
null: true,
description: "Release version the error was last seen"
def first_seen
DateTime.parse(object.first_seen)
end
def last_seen
DateTime.parse(object.last_seen)
end
def project_id
Gitlab::GlobalId.build(model_name: 'Project', id: object.project_id).to_s
end
end
end
end
# frozen_string_literal: true
module Types
module ErrorTracking
# rubocop: disable Graphql/AuthorizeTypes
class SentryErrorFrequencyType < ::Types::BaseObject
graphql_name 'SentryErrorFrequency'
field :time, Types::TimeType,
null: false,
description: "Time the error frequency stats were recorded"
field :count, GraphQL::INT_TYPE,
null: false,
description: "Count of errors received since the previously recorded time"
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
# frozen_string_literal: true
module Types
module ErrorTracking
class SentryErrorStatusEnum < ::Types::BaseEnum
graphql_name 'SentryErrorStatus'
description 'State of a Sentry error'
value 'RESOLVED', value: 'resolved', description: 'Error has been resolved'
value 'RESOLVED_IN_NEXT_RELEASE', value: 'resolvedInNextRelease', description: 'Error has been ignored until next release'
value 'UNRESOLVED', value: 'unresolved', description: 'Error is unresolved'
value 'IGNORED', value: 'ignored', description: 'Error has been ignored'
end
end
end
......@@ -145,5 +145,11 @@ module Types
null: true,
description: 'Build pipelines of the project',
resolver: Resolvers::ProjectPipelinesResolver
field :sentry_detailed_error,
Types::ErrorTracking::SentryDetailedErrorType,
null: true,
description: 'Detailed version of a Sentry error on the project',
resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
end
end
# frozen_string_literal: true
module ErrorTracking
class DetailedErrorPolicy < BasePolicy
delegate { @subject.gitlab_project }
end
end
# frozen_string_literal: true
class SentryDetailedErrorPresenter < Gitlab::View::Presenter::Delegated
presents :error
FrequencyStruct = Struct.new(:time, :count, keyword_init: true)
def frequency
utc_offset = Time.zone_offset('UTC')
error.frequency.map do |f|
FrequencyStruct.new(time: Time.at(f[0], in: utc_offset), count: f[1])
end
end
end
---
title: GraphQL for Sentry rror details
merge_request: 19733
author:
type: added
......@@ -4400,6 +4400,16 @@ type Project {
"""
requestAccessEnabled: Boolean
"""
Detailed version of a Sentry error on the project
"""
sentryDetailedError(
"""
ID of the Sentry issue
"""
id: ID!
): SentryDetailedError
"""
Indicates if shared runners are enabled on the project
"""
......@@ -4886,6 +4896,150 @@ type RootStorageStatistics {
wikiSize: Int!
}
type SentryDetailedError {
"""
Count of occurrences
"""
count: Int!
"""
Culprit of the error
"""
culprit: String!
"""
External URL of the error
"""
externalUrl: String!
"""
Commit the error was first seen
"""
firstReleaseLastCommit: String
"""
Release version the error was first seen
"""
firstReleaseShortVersion: String
"""
Timestamp when the error was first seen
"""
firstSeen: Time!
"""
Last 24hr stats of the error
"""
frequency: [SentryErrorFrequency!]!
"""
ID (global ID) of the error
"""
id: ID!
"""
Commit the error was last seen
"""
lastReleaseLastCommit: String
"""
Release version the error was last seen
"""
lastReleaseShortVersion: String
"""
Timestamp when the error was last seen
"""
lastSeen: Time!
"""
Sentry metadata message of the error
"""
message: String
"""
ID (Sentry ID) of the error
"""
sentryId: String!
"""
ID of the project (Sentry project)
"""
sentryProjectId: ID!
"""
Name of the project affected by the error
"""
sentryProjectName: String!
"""
Slug of the project affected by the error
"""
sentryProjectSlug: String!
"""
Short ID (Sentry ID) of the error
"""
shortId: String!
"""
Status of the error
"""
status: SentryErrorStatus!
"""
Title of the error
"""
title: String!
"""
Type of the error
"""
type: String!
"""
Count of users affected by the error
"""
userCount: Int!
}
type SentryErrorFrequency {
"""
Count of errors received since the previously recorded time
"""
count: Int!
"""
Time the error frequency stats were recorded
"""
time: Time!
}
"""
State of a Sentry error
"""
enum SentryErrorStatus {
"""
Error has been ignored
"""
IGNORED
"""
Error has been resolved
"""
RESOLVED
"""
Error has been ignored until next release
"""
RESOLVED_IN_NEXT_RELEASE
"""
Error is unresolved
"""
UNRESOLVED
}
type Submodule implements Entry {
flatPath: String!
id: ID!
......
......@@ -664,6 +664,7 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `repository` | Repository | Git repository of the project |
| `mergeRequest` | MergeRequest | A single merge request of the project |
| `issue` | Issue | A single issue of the project |
| `sentryDetailedError` | SentryDetailedError | Detailed version of a Sentry error on the project |
### ProjectPermissions
......@@ -751,6 +752,39 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `packagesSize` | Int! | The packages size in bytes |
| `wikiSize` | Int! | The wiki size in bytes |
### SentryDetailedError
| Name | Type | Description |
| --- | ---- | ---------- |
| `id` | ID! | ID (global ID) of the error |
| `sentryId` | String! | ID (Sentry ID) of the error |
| `title` | String! | Title of the error |
| `type` | String! | Type of the error |
| `userCount` | Int! | Count of users affected by the error |
| `count` | Int! | Count of occurrences |
| `firstSeen` | Time! | Timestamp when the error was first seen |
| `lastSeen` | Time! | Timestamp when the error was last seen |
| `message` | String | Sentry metadata message of the error |
| `culprit` | String! | Culprit of the error |
| `externalUrl` | String! | External URL of the error |
| `sentryProjectId` | ID! | ID of the project (Sentry project) |
| `sentryProjectName` | String! | Name of the project affected by the error |
| `sentryProjectSlug` | String! | Slug of the project affected by the error |
| `shortId` | String! | Short ID (Sentry ID) of the error |
| `status` | SentryErrorStatus! | Status of the error |
| `frequency` | SentryErrorFrequency! => Array | Last 24hr stats of the error |
| `firstReleaseLastCommit` | String | Commit the error was first seen |
| `lastReleaseLastCommit` | String | Commit the error was last seen |
| `firstReleaseShortVersion` | String | Release version the error was first seen |
| `lastReleaseShortVersion` | String | Release version the error was last seen |
### SentryErrorFrequency
| Name | Type | Description |
| --- | ---- | ---------- |
| `time` | Time! | Time the error frequency stats were recorded |
| `count` | Int! | Count of errors received since the previously recorded time |
### Submodule
| Name | Type | Description |
......
......@@ -4,6 +4,7 @@ module Gitlab
module ErrorTracking
class DetailedError
include ActiveModel::Model
include GlobalID::Identification
attr_accessor :count,
:culprit,
......@@ -13,6 +14,7 @@ module Gitlab
:first_release_short_version,
:first_seen,
:frequency,
:gitlab_project,
:id,
:last_release_last_commit,
:last_release_short_version,
......@@ -26,6 +28,10 @@ module Gitlab
:title,
:type,
:user_count
def self.declarative_policy_class
'ErrorTracking::DetailedErrorPolicy'
end
end
end
end
......@@ -2,13 +2,13 @@
FactoryBot.define do
factory :detailed_error_tracking_error, class: Gitlab::ErrorTracking::DetailedError do
id { 'id' }
id { '1' }
title { 'title' }
type { 'error' }
user_count { 1 }
count { 2 }
first_seen { Time.now }
last_seen { Time.now }
first_seen { Time.now.iso8601 }
last_seen { Time.now.iso8601 }
message { 'message' }
culprit { 'culprit' }
external_url { 'http://example.com/id' }
......@@ -18,7 +18,11 @@ FactoryBot.define do
project_slug { 'project_name' }
short_id { 'ID' }
status { 'unresolved' }
frequency { [] }
frequency do
[
[Time.now.to_i, 10]
]
end
first_release_last_commit { '68c914da9' }
last_release_last_commit { '9ad419c86' }
first_release_short_version { 'abc123' }
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
include GraphqlHelpers
let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
let(:issue_details_service) { spy('ErrorTracking::IssueDetailsService') }
before do
project.add_developer(current_user)
allow(ErrorTracking::IssueDetailsService)
.to receive(:new)
.and_return issue_details_service
end
describe '#resolve' do
let(:args) { { id: issue_global_id(1234) } }
it 'fetches the data via the sentry API' do
resolve_error(args)
expect(issue_details_service).to have_received(:execute)
end
context 'error matched' do
let(:detailed_error) { build(:detailed_error_tracking_error) }
before do
allow(issue_details_service).to receive(:execute)
.and_return({ issue: detailed_error })
end
it 'resolves to a detailed error' do
expect(resolve_error(args)).to eq detailed_error
end
it 'assigns the gitlab project' do
expect(resolve_error(args).gitlab_project).to eq project
end
end
it 'resolves to nil if no match' do
allow(issue_details_service).to receive(:execute)
.and_return({ issue: nil })
result = resolve_error(args)
expect(result).to eq nil
end
end
def resolve_error(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
def issue_global_id(issue_id)
Gitlab::ErrorTracking::DetailedError.new(id: issue_id).to_global_id.to_s
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['SentryDetailedError'] do
it { expect(described_class.graphql_name).to eq('SentryDetailedError') }
it { expect(described_class).to require_graphql_authorizations(:read_sentry_issue) }
it 'exposes the expected fields' do
expected_fields = %i[
id
sentryId
title
type
userCount
count
firstSeen
lastSeen
message
culprit
externalUrl
sentryProjectId
sentryProjectName
sentryProjectSlug
shortId
status
frequency
firstReleaseLastCommit
lastReleaseLastCommit
firstReleaseShortVersion
lastReleaseShortVersion
]
is_expected.to have_graphql_fields(*expected_fields)
end
end
......@@ -22,8 +22,7 @@ describe GitlabSchema.types['Project'] do
only_allow_merge_if_pipeline_succeeds request_access_enabled
only_allow_merge_if_all_discussions_are_resolved printing_merge_request_link_enabled
namespace group statistics repository merge_requests merge_request issues
issue pipelines
removeSourceBranchAfterMerge
issue pipelines removeSourceBranchAfterMerge sentryDetailedError
]
is_expected.to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
describe SentryDetailedErrorPresenter do
let(:error) { build(:detailed_error_tracking_error) }
let(:presenter) { described_class.new(error) }
describe '#frequency' do
subject { presenter.frequency }
it 'returns an array of frequency structs' do
expect(subject).to include(a_kind_of(SentryDetailedErrorPresenter::FrequencyStruct))
end
it 'converts the times into UTC time objects' do
time = subject.first.time
expect(time).to be_a(Time)
expect(time.strftime('%z')).to eq '+0000'
end
it 'returns the correct counts' do
count = subject.first.count
expect(count).to eq error.frequency.first[1]
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting a detailed sentry error' do
include GraphqlHelpers
let_it_be(:project) { create(:project, :repository) }
let_it_be(:project_setting) { create(:project_error_tracking_setting, project: project) }
let_it_be(:current_user) { project.owner }
let_it_be(:sentry_detailed_error) { build(:detailed_error_tracking_error) }
let(:sentry_gid) { sentry_detailed_error.to_global_id.to_s }
let(:fields) do
<<~QUERY
#{all_graphql_fields_for('SentryDetailedError'.classify)}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('sentryDetailedError', { id: sentry_gid }, fields)
)
end
let(:error_data) { graphql_data['project']['sentryDetailedError'] }
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
context 'when data is loading via reactive cache' do
before do
post_graphql(query, current_user: current_user)
end
it "is expected to return an empty error" do
expect(error_data).to eq nil
end
end
context 'reactive cache returns data' do
before do
expect_any_instance_of(ErrorTracking::ProjectErrorTrackingSetting)
.to receive(:issue_details)
.and_return({ issue: sentry_detailed_error })
post_graphql(query, current_user: current_user)
end
it "is expected to return a valid error" do
expect(error_data['id']).to eql sentry_gid
expect(error_data['sentryId']).to eql sentry_detailed_error.id.to_s
expect(error_data['status']).to eql sentry_detailed_error.status.upcase
expect(error_data['firstSeen']).to eql sentry_detailed_error.first_seen
expect(error_data['lastSeen']).to eql sentry_detailed_error.last_seen
end
it 'is expected to return the frequency correctly' do
expect(error_data['frequency'].count).to eql sentry_detailed_error.frequency.count
first_frequency = error_data['frequency'].first
expect(Time.parse(first_frequency['time'])).to eql Time.at(sentry_detailed_error.frequency[0][0], in: 0)
expect(first_frequency['count']).to eql sentry_detailed_error.frequency[0][1]
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