sentry_top_errors 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +4 -0
- data/Gemfile +3 -0
- data/README.md +9 -0
- data/bin/sentry_top_errors +27 -0
- data/lib/sentry_top_errors/api_response.rb +45 -0
- data/lib/sentry_top_errors/reporter.rb +171 -0
- data/lib/sentry_top_errors/sentry_client.rb +81 -0
- data/lib/sentry_top_errors.rb +11 -0
- data/senrty_top_errors.gemspec +15 -0
- metadata +87 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 451faf0b74e71cc7d9ab082b35427881ce43d99d1908246fb4d2a111a16fe7bc
|
4
|
+
data.tar.gz: 9684d29db554a2fbd8a660124d1cef85b2500afc6037d1bc8a4234b46a894831
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b3dc5a3f0d23d95570d8f6627cdbec58da0a4213ca5c14085a775be7ba2c55154e6d1581ebf8dc67fb18df141c07b582051d2ff08e8c2952df4a39751f9078f8
|
7
|
+
data.tar.gz: 8f9eb1cf3e1170c894ac8360a30cc0a636865bc5627e51e6e97d79ce4f7ef1ec131203a727b623785130ceea36f9a92528cacd992729bb60297f4f5c5236c039
|
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require_relative '../lib/sentry_top_errors'
|
4
|
+
|
5
|
+
SENTRY_KEY = ENV['SENTRY_KEY']
|
6
|
+
|
7
|
+
if !SENTRY_KEY || SENTRY_KEY == ''
|
8
|
+
STDERR.puts "Env var SENTRY_KEY is reuired"
|
9
|
+
exit 1
|
10
|
+
end
|
11
|
+
|
12
|
+
REPORT_TYPE = ENV['SENTRY_REPORT'] ? ENV['SENTRY_REPORT'].to_sym : :text
|
13
|
+
|
14
|
+
if REPORT_TYPE != :text && REPORT_TYPE != :tabs && REPORT_TYPE != :table && REPORT_TYPE != :html
|
15
|
+
STDERR.puts "Env var SENTRY_REPORT should be one of those: text, tabs, table, html. Default is text"
|
16
|
+
exit 1
|
17
|
+
end
|
18
|
+
|
19
|
+
client = SentryTopErrors::SentryClient.new(sentry_key: SENTRY_KEY, enable_cache: !!ENV['SEMTRY_DATA_CACHE'])
|
20
|
+
|
21
|
+
if ENV['DROP_CACHE']
|
22
|
+
client.drop_cache
|
23
|
+
end
|
24
|
+
|
25
|
+
reporter = SentryTopErrors::Reporter.new(client: client, report_type: :html)
|
26
|
+
reporter.fetch_data
|
27
|
+
puts r.generate_report
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module SentryTopErrors::ApiResponse
|
2
|
+
def self.new_from_res(http_res)
|
3
|
+
if http_res.status == 429 || http_res.status >= 500
|
4
|
+
raise "Failed response #{http_res.status} - #{http_res.body}"
|
5
|
+
end
|
6
|
+
|
7
|
+
new_obj = begin
|
8
|
+
if http_res.body.start_with?('[') && http_res.body.end_with?(']')
|
9
|
+
SentryTopErrors::ApiResponse::FromArray.new.concat(JSON.parse(http_res.body))
|
10
|
+
else
|
11
|
+
SentryTopErrors::ApiResponse::FromHash.new().merge!(JSON.parse(http_res.body))
|
12
|
+
end
|
13
|
+
rescue JSON::ParserError => error
|
14
|
+
puts "Failed to parse response #{http_res.body}"
|
15
|
+
end
|
16
|
+
|
17
|
+
new_obj.response = http_res
|
18
|
+
new_obj
|
19
|
+
end
|
20
|
+
|
21
|
+
module ExtMethods
|
22
|
+
def http_success?
|
23
|
+
@response.status < 400
|
24
|
+
end
|
25
|
+
|
26
|
+
def status
|
27
|
+
@response.status
|
28
|
+
end
|
29
|
+
|
30
|
+
def headers
|
31
|
+
@response.headers
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
class FromArray < ::Array
|
36
|
+
attr_accessor :response
|
37
|
+
include SentryTopErrors::ApiResponse::ExtMethods
|
38
|
+
end
|
39
|
+
|
40
|
+
class FromHash < ::Hash
|
41
|
+
attr_accessor :response
|
42
|
+
include SentryTopErrors::ApiResponse::ExtMethods
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
@@ -0,0 +1,171 @@
|
|
1
|
+
require 'progress_bar'
|
2
|
+
|
3
|
+
class SentryTopErrors::Reporter
|
4
|
+
def initialize(client:, prod_regex: /^p-/, threshold_24h: 30, threshold_new: 10, new_days_num: 1, report_type: :html)
|
5
|
+
@client = client
|
6
|
+
@prod_regex = prod_regex
|
7
|
+
@threshold_24h = threshold_24h
|
8
|
+
@threshold_new = threshold_new
|
9
|
+
@new_days_num = new_days_num
|
10
|
+
@report_type = report_type
|
11
|
+
end
|
12
|
+
|
13
|
+
def fetch_data
|
14
|
+
puts "### Fetching sentry data"
|
15
|
+
bar = ProgressBar.new(100)
|
16
|
+
bar.write
|
17
|
+
|
18
|
+
@projects = @client.list_projects
|
19
|
+
bar.increment!
|
20
|
+
bar.max = @projects.size + 1
|
21
|
+
|
22
|
+
@issues = {}
|
23
|
+
|
24
|
+
queue = Thread::Queue.new
|
25
|
+
@projects.each do |project|
|
26
|
+
queue << project
|
27
|
+
end
|
28
|
+
threads = []
|
29
|
+
|
30
|
+
2.times do
|
31
|
+
threads << Thread.new do
|
32
|
+
while !queue.empty? && project = queue.pop
|
33
|
+
begin
|
34
|
+
@issues[project['slug']] = @client.list_issues(project['organization']['slug'], project['slug'], stats_period: '24h')
|
35
|
+
bar.increment!
|
36
|
+
rescue => error
|
37
|
+
bar.puts "ERROR when fetching issues for project #{project['slug']}"
|
38
|
+
bar.puts "#{error.class} #{error.message}\n#{error.backtrace.join("\n")}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
threads.each(&:join)
|
45
|
+
|
46
|
+
puts "### Fetching done"
|
47
|
+
end
|
48
|
+
|
49
|
+
def generate_report
|
50
|
+
groups = {}
|
51
|
+
|
52
|
+
issue_counts = []
|
53
|
+
new_issues = []
|
54
|
+
|
55
|
+
@projects.each do |project|
|
56
|
+
issues = @issues[project['slug']]
|
57
|
+
next if issues == nil
|
58
|
+
|
59
|
+
is_production = !!(project['name'] =~ @prod_regex)
|
60
|
+
|
61
|
+
issues.each do |issue|
|
62
|
+
count_in_24h = issue['stats'].values.first.map {|row| row.last }.sum
|
63
|
+
issue_summary = {
|
64
|
+
project_name: project['name'],
|
65
|
+
project_url: project.dig('organization', 'links', 'organizationUrl') + "/projects/#{project['slug']}",
|
66
|
+
issue: issue['title'],
|
67
|
+
culprit: issue['culprit'],
|
68
|
+
issue_link: issue['permalink'],
|
69
|
+
issue_count: count_in_24h,
|
70
|
+
is_production: is_production
|
71
|
+
}
|
72
|
+
|
73
|
+
if count_in_24h > @threshold_24h
|
74
|
+
issue_counts << issue_summary
|
75
|
+
end
|
76
|
+
|
77
|
+
first_seen = Time.parse(issue['firstSeen'])
|
78
|
+
age_in_days = (Time.now - first_seen) / 86400.0
|
79
|
+
if age_in_days < @new_days_num.to_f && count_in_24h > @threshold_new
|
80
|
+
new_issues << issue_summary
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# sort desc, with higher number on top, separate production and staging
|
86
|
+
issue_counts.sort_by! do |issue|
|
87
|
+
[issue[:is_production] ? 1 : 0, issue[:issue_count]]
|
88
|
+
end
|
89
|
+
issue_counts.reverse!
|
90
|
+
|
91
|
+
new_issues.sort_by! do |issue|
|
92
|
+
[issue[:is_production] ? 1 : 0, issue[:issue_count]]
|
93
|
+
end
|
94
|
+
new_issues.reverse!
|
95
|
+
|
96
|
+
render_result(issue_counts, new_issues)
|
97
|
+
end
|
98
|
+
|
99
|
+
def render_result(issue_counts, new_issues)
|
100
|
+
if @report_type == :tabs || @report_type == :text
|
101
|
+
result = "\nRepeating issues\n\n"
|
102
|
+
issue_counts.each do |issue|
|
103
|
+
result << [issue[:issue_count], issue[:project_name], issue[:issue], issue[:culprit], issue[:issue_link]].join("\t") + "\n"
|
104
|
+
end
|
105
|
+
|
106
|
+
result << "\nNew issues\n\n"
|
107
|
+
new_issues.each do |issue|
|
108
|
+
result << [issue[:issue_count], issue[:project_name], issue[:issue], issue[:culprit], issue[:issue_link]].join("\t") + "\n"
|
109
|
+
end
|
110
|
+
return result
|
111
|
+
elsif @report_type == :table
|
112
|
+
require 'terminal-table'
|
113
|
+
require 'colorize'
|
114
|
+
|
115
|
+
all_table = Terminal::Table.new(headings: ["count", "project", "issue", "cause", "link"]) do |t|
|
116
|
+
issue_counts.each do |issue|
|
117
|
+
t << [
|
118
|
+
issue[:is_production] ? issue[:issue_count].to_s.colorize(:yellow) : issue[:issue_count],
|
119
|
+
issue[:project_name], pad_and_justify(issue[:issue], 70),
|
120
|
+
pad_and_justify(issue[:culprit], 40),
|
121
|
+
issue[:issue_link]
|
122
|
+
]
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
new_table = Terminal::Table.new(headings: ["count", "project", "issue", "cause", "link"]) do |t|
|
127
|
+
new_issues.each do |issue|
|
128
|
+
t << [
|
129
|
+
issue[:is_production] ? issue[:issue_count].to_s.colorize(:yellow) : issue[:issue_count],
|
130
|
+
issue[:project_name], pad_and_justify(issue[:issue], 70),
|
131
|
+
pad_and_justify(issue[:culprit], 40),
|
132
|
+
issue[:issue_link]
|
133
|
+
]
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
return "Repeating issues:\n#{all_table}\n\nNew issues\n#{new_table}"
|
138
|
+
|
139
|
+
elsif @report_type == :html
|
140
|
+
tpl_dir = File.join(File.expand_path(File.dirname(__FILE__) + "/../.."), "templates")
|
141
|
+
html_content = File.read(tpl_dir + "/index.html")
|
142
|
+
html_content.sub!(%{['ALL_ISSUE_COUNTS_PLACEHOLDER']}, JSON.pretty_generate(issue_counts))
|
143
|
+
html_content.sub!(%{['NEW_ISSUE_COUNTS_PLACEHOLDER']}, JSON.pretty_generate(new_issues))
|
144
|
+
|
145
|
+
puts "Saved index.html"
|
146
|
+
File.write("./index.html", html_content)
|
147
|
+
|
148
|
+
:ok
|
149
|
+
else
|
150
|
+
raise "Unknown report_type #{@report_type}"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def pad_and_justify(str, width, justify = :l)
|
155
|
+
new_str = []
|
156
|
+
str.split("\n").each do |s1|
|
157
|
+
s1.split('').each_slice(width).map {|x| x.join}.each do |s2|
|
158
|
+
new_str <<
|
159
|
+
case justify
|
160
|
+
when :l
|
161
|
+
s2.ljust(width)
|
162
|
+
when :r
|
163
|
+
s2.rjust(width)
|
164
|
+
when :c
|
165
|
+
s2.center(width)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
new_str * "\n"
|
170
|
+
end
|
171
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
class SentryTopErrors::SentryClient
|
2
|
+
SENTRY_HOST = 'https://sentry.io'
|
3
|
+
HTTP_OPTIONNS = {
|
4
|
+
tcp_nodelay: true,
|
5
|
+
connect_timeout: 10,
|
6
|
+
read_timeout: 40,
|
7
|
+
idempotent: true,
|
8
|
+
retry_limit: 3
|
9
|
+
}
|
10
|
+
|
11
|
+
def initialize(sentry_key: , enable_cache: false)
|
12
|
+
@sentry_key = sentry_key
|
13
|
+
@enable_cache = enable_cache
|
14
|
+
end
|
15
|
+
|
16
|
+
def http(method, path, options = {})
|
17
|
+
@conn ||= Excon.new(SENTRY_HOST, HTTP_OPTIONNS)
|
18
|
+
options[:headers] ||= {}
|
19
|
+
options[:headers]['Authorization'] ||= "Bearer #{@sentry_key}"
|
20
|
+
response = @conn.request({method: method, path: path}.merge(options))
|
21
|
+
|
22
|
+
SentryTopErrors::ApiResponse.new_from_res(response)
|
23
|
+
end
|
24
|
+
|
25
|
+
def get_all_with_cursor(http_method, url_path, http_options = {})
|
26
|
+
objects = http(http_method, url_path, http_options)
|
27
|
+
link_header = objects.headers['link']
|
28
|
+
|
29
|
+
while link_header && link_header =~ /rel="next"/
|
30
|
+
cursor_match = link_header.match(/results="true";\s+cursor="([\d:]+?)"$/)
|
31
|
+
if cursor_match
|
32
|
+
http_options[:query] ||= {}
|
33
|
+
http_options[:query][:cursor] = cursor_match[1]
|
34
|
+
next_objects = http(http_method, url_path, http_options)
|
35
|
+
objects.concat(next_objects)
|
36
|
+
link_header = next_objects.headers['link']
|
37
|
+
else
|
38
|
+
link_header = ""
|
39
|
+
# puts "No next link in response"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
objects
|
44
|
+
end
|
45
|
+
|
46
|
+
def list_projects
|
47
|
+
cached(:all_projects) do
|
48
|
+
get_all_with_cursor(:get, 'api/0/projects/')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def list_issues(org_slug, project_slug, stats_period: '24h')
|
53
|
+
cached("list_issues_#{project_slug}") do
|
54
|
+
query = {statsPeriod: stats_period}
|
55
|
+
get_all_with_cursor(:get, "api/0/projects/#{org_slug}/#{project_slug}/issues/", query: query)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def cached(key, &block)
|
60
|
+
if @enable_cache
|
61
|
+
cache_file = "sentry_cache/#{key}.yaml"
|
62
|
+
if File.exist?(cache_file)
|
63
|
+
YAML.load_file(cache_file, aliases: true, permitted_classes: [
|
64
|
+
SentryTopErrors::ApiResponse::FromArray, SentryTopErrors::ApiResponse::FromHash, Symbol, Excon::Response, Excon::Headers
|
65
|
+
])
|
66
|
+
else
|
67
|
+
result = block.call()
|
68
|
+
File.write(cache_file, YAML.dump(result))
|
69
|
+
result
|
70
|
+
end
|
71
|
+
else
|
72
|
+
block.call()
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def drop_cache
|
77
|
+
Dir.glob("sentry_cache/*.yaml").each do |file|
|
78
|
+
File.unlink(file)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = "sentry_top_errors"
|
3
|
+
s.version = "0.1.0"
|
4
|
+
s.summary = "Generate top errors report for sentry"
|
5
|
+
s.description = ""
|
6
|
+
s.author = "Pavel Evstigneev"
|
7
|
+
s.email = "pavel.evst@gmail.com"
|
8
|
+
s.homepage = "http://github.com/Paxa/sentry_top_errors"
|
9
|
+
s.executables = ["sentry_top_errors"]
|
10
|
+
s.files = `git ls-files`.split("\n")
|
11
|
+
s.licenses = ['MIT', 'GPL-2.0']
|
12
|
+
|
13
|
+
s.add_runtime_dependency 'excon', "~> 0.100.0"
|
14
|
+
s.add_runtime_dependency 'progress_bar', '>= 1.0.5', '< 2.0.0'
|
15
|
+
end
|
metadata
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sentry_top_errors
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Pavel Evstigneev
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2023-07-17 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: excon
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.100.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 0.100.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: progress_bar
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 1.0.5
|
34
|
+
- - "<"
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: 2.0.0
|
37
|
+
type: :runtime
|
38
|
+
prerelease: false
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
40
|
+
requirements:
|
41
|
+
- - ">="
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: 1.0.5
|
44
|
+
- - "<"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: 2.0.0
|
47
|
+
description: ''
|
48
|
+
email: pavel.evst@gmail.com
|
49
|
+
executables:
|
50
|
+
- sentry_top_errors
|
51
|
+
extensions: []
|
52
|
+
extra_rdoc_files: []
|
53
|
+
files:
|
54
|
+
- ".gitignore"
|
55
|
+
- Gemfile
|
56
|
+
- README.md
|
57
|
+
- bin/sentry_top_errors
|
58
|
+
- lib/sentry_top_errors.rb
|
59
|
+
- lib/sentry_top_errors/api_response.rb
|
60
|
+
- lib/sentry_top_errors/reporter.rb
|
61
|
+
- lib/sentry_top_errors/sentry_client.rb
|
62
|
+
- senrty_top_errors.gemspec
|
63
|
+
homepage: http://github.com/Paxa/sentry_top_errors
|
64
|
+
licenses:
|
65
|
+
- MIT
|
66
|
+
- GPL-2.0
|
67
|
+
metadata: {}
|
68
|
+
post_install_message:
|
69
|
+
rdoc_options: []
|
70
|
+
require_paths:
|
71
|
+
- lib
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: '0'
|
82
|
+
requirements: []
|
83
|
+
rubygems_version: 3.4.15
|
84
|
+
signing_key:
|
85
|
+
specification_version: 4
|
86
|
+
summary: Generate top errors report for sentry
|
87
|
+
test_files: []
|