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 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
@@ -0,0 +1,4 @@
1
+ sentry_cache
2
+ index.html
3
+ Gemfile.lock
4
+ try_sentry_checker.rb
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/README.md ADDED
@@ -0,0 +1,9 @@
1
+ ## Sentry Top Errors checker
2
+
3
+ * Find most frequent errors in sentry
4
+ * Find new erros that happen more than N times
5
+
6
+ Useful for systems with large number of components
7
+
8
+
9
+ TODO: write readme, better design, screenshots
@@ -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,11 @@
1
+ require 'excon'
2
+ require 'json'
3
+ require 'yaml'
4
+ require 'time'
5
+
6
+ module SentryTopErrors
7
+ end
8
+
9
+ require_relative 'sentry_top_errors/api_response'
10
+ require_relative 'sentry_top_errors/sentry_client'
11
+ require_relative 'sentry_top_errors/reporter'
@@ -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: []