sentry_top_errors 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []