errordeck 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.
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Errordeck
6
+ class Stacktrace
7
+ attr_accessor :abs_path, :function, :in_app, :filename, :lineno, :module, :vars, :context_line, :pre_context,
8
+ :post_context
9
+
10
+ def initialize(project_root, line)
11
+ @abs_path = line.file
12
+ @function = line.method
13
+ @in_app = line.file.start_with?(project_root)
14
+ @filename = File.basename(line.file)
15
+ @lineno = line.line
16
+ @module = line.module_name
17
+ @vars = {}
18
+ set_contexts(line.file, line.line)
19
+ end
20
+
21
+ def as_json(*_options)
22
+ {
23
+ abs_path: @abs_path,
24
+ function: @function,
25
+ in_app: @in_app,
26
+ filename: @filename,
27
+ lineno: @lineno,
28
+ module: @module,
29
+ vars: @vars,
30
+ context_line: @context_line,
31
+ pre_context: @pre_context,
32
+ post_context: @post_context
33
+ }
34
+ end
35
+
36
+ def to_json(*options)
37
+ JSON.generate(as_json, *options)
38
+ end
39
+
40
+ def self.parse_from_backtrace(backtrace, project_root = nil)
41
+ project_root ||= File.expand_path(File.join(File.dirname(__FILE__), "..", ".."))
42
+ error_backtrace = Errordeck::Backtrace.parse(backtrace)
43
+ return nil if error_backtrace.nil?
44
+
45
+ error_backtrace.lines.map { |line| new(project_root, line) }
46
+ end
47
+
48
+ private
49
+
50
+ def set_contexts(file, line_number)
51
+ return unless File.exist?(file)
52
+
53
+ source = File.readlines(file)
54
+ return if source.empty?
55
+
56
+ range_start = [line_number - 5, 0].max
57
+ range_end = [line_number + 3, source.length - 1].min
58
+
59
+ @pre_context = source[range_start...line_number - 1]
60
+ @context_line = source[line_number - 1]
61
+ @post_context = source[line_number...range_end + 1]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rack middleware to capture request context and exception to send to errordeck
4
+ # Compare this snippet from lib/errordeck/middleware/rack.rb:
5
+
6
+ module Errordeck
7
+ module Middleware
8
+ module Rack
9
+ def self.new(app)
10
+ ->(env) { call(env, app) }
11
+ end
12
+
13
+ def self.call(env, app)
14
+ Errordeck.wrap do |b|
15
+ b.set_request(env)
16
+ b.set_transaction(env["PATH_INFO"])
17
+
18
+ begin
19
+ app.call(env)
20
+ rescue Exception => e
21
+ b.capture(e)
22
+ raise e
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ module Middleware
5
+ module Rails
6
+ class ErrordeckMiddleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(env)
12
+ dup.call!(env)
13
+ end
14
+
15
+ def call!(env)
16
+ response = @app.call(env)
17
+ rescue Exception => e
18
+ notify_exception(env, e)
19
+ raise e
20
+ else
21
+ exception = collect_exception(env)
22
+ notify_exception(env, exception) if exception
23
+ response
24
+ end
25
+
26
+ private
27
+
28
+ def notify_exception(env, exception)
29
+ return unless exception
30
+
31
+ Errordeck.wrap do |b|
32
+ b.set_action_context(env)
33
+ b.capture(exception)
34
+ end
35
+ end
36
+
37
+ def collect_exception(env)
38
+ return nil unless env
39
+
40
+ env["action_dispatch.exception"] || env["sinatra.error"] || env["rack.exception"]
41
+ end
42
+ end
43
+ end
44
+ end
45
+
46
+ class Railtie < Rails::Railtie
47
+ initializer "errordeck.middleware.rails" do |app|
48
+ app.config.middleware.insert 0, Errordeck::Middleware::Rails::ErrordeckMiddleware
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # check if rack is defined and require middleware
4
+ if defined?(Rack)
5
+ require_relative "middleware/rack"
6
+ if defined?(Rack::Builder)
7
+ # Rack 2.0
8
+ Rack::Builder.include Errordeck::Middleware::Rack
9
+ end
10
+ end
11
+ # check if rails is defined and require middleware
12
+ if defined?(Rails) && Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new("3.2") && defined?(Rails::Railtie)
13
+ require_relative "middleware/rails"
14
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Errordeck
6
+ class RequestHandler
7
+ attr_accessor :request
8
+
9
+ def initialize(request)
10
+ @request = request
11
+ end
12
+
13
+ def self.set_request_from_env(rack_env)
14
+ if defined?(Sinatra)
15
+ Sinatra::Request.new(rack_env)
16
+ elsif defined?(Rack)
17
+ Rack::Request.new(rack_env)
18
+ else
19
+ request_hash(rack_env)
20
+ end
21
+ end
22
+
23
+ def self.request_hash(rack_env)
24
+ request = {}
25
+ rack_env = rack_env.dup.transform_keys { |k| k.to_s.upcase }
26
+
27
+ if rack_env["REQUEST_URI"] || rack_env["URL"]
28
+ url = URI.parse(rack_env["REQUEST_URI"] || rack_env["URL"])
29
+ url.scheme ||= rack_env["RACK.URL_SCHEME"] || "https"
30
+ url.host ||= rack_env["HTTP_HOST"] || "localhost"
31
+ else
32
+ scheme = rack_env["RACK.URL_SCHEME"] || "https"
33
+ host = rack_env["HTTP_HOST"] || "localhost"
34
+ path = rack_env["PATH_INFO"] || "/"
35
+ query_string = rack_env["QUERY_STRING"] || ""
36
+ url = URI.parse("#{scheme}://#{host}#{path}")
37
+ url.query = query_string unless query_string.empty?
38
+ end
39
+
40
+ request[:url] = url.to_s
41
+ request[:query_string] = url.query || ""
42
+ request[:params] = rack_env["PARAMS"] || parse_query_string(request[:query_string])
43
+ request[:method] = rack_env["REQUEST_METHOD"] || "GET"
44
+ request[:cookies] =
45
+ rack_env["HTTP_COOKIE"] || rack_env["COOKIES"] ? parse_query_string(rack_env["HTTP_COOKIE"] || rack_env["COOKIES"]) : {}
46
+ request[:headers] = (rack_env["HEADERS"] || rack_env).select { |k, _| k.start_with?("HTTP_") }
47
+ request
48
+ end
49
+
50
+ def self.parse_from_rack_env(rack_env)
51
+ request = set_request_from_env(rack_env.dup)
52
+ new(request)
53
+ end
54
+
55
+ def method
56
+ if @request.respond_to?(:request_method)
57
+ @request.request_method
58
+ else
59
+ @request[:method]
60
+ end
61
+ end
62
+
63
+ def url
64
+ if @request.respond_to?(:url)
65
+ @request.url
66
+ else
67
+ @request[:url]
68
+ end
69
+ end
70
+
71
+ def params
72
+ if @request.respond_to?(:params)
73
+ (request.env["action_dispatch.request.parameters"] || request.params).to_hash || {}
74
+ else
75
+ @request[:params] || {}
76
+ end
77
+ end
78
+
79
+ def controller
80
+ params["controller"]
81
+ end
82
+
83
+ def action
84
+ params["action"]
85
+ end
86
+
87
+ def query_string
88
+ @request[:query_string] || @request.query_string
89
+ end
90
+
91
+ def cookies
92
+ @request[:cookies] || @request.cookies || {}
93
+ end
94
+
95
+ def headers
96
+ @request[:headers] || @request.env || {}
97
+ end
98
+
99
+ def to_hash
100
+ {
101
+ method: method,
102
+ url: url,
103
+ query_string: query_string,
104
+ cookies: cookies,
105
+ headers: headers,
106
+ params: params
107
+ }
108
+ end
109
+
110
+ def self.parse_query_string(query_string)
111
+ return query_string if query_string.is_a?(Hash)
112
+
113
+ query_string.split("&").each_with_object({}) do |pair, hash|
114
+ key, value = pair.split("=").map { |part| CGI.unescape(part) }
115
+ if hash.key?(key)
116
+ hash[key] = Array(hash[key])
117
+ hash[key] << value
118
+ else
119
+ hash[key] = value
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ module Scrubber
5
+ SENSITIVE_PARAMS = %w[password password_confirmation email secret token session].freeze
6
+ SENSITIVE_HEADERS = %w[Authorization Cookie Set-Cookie].freeze
7
+ SENSITIVE_VALUE = "[FILTERED]"
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ module Scrubber
5
+ class Cookie
6
+ # scrub a cookie
7
+ def initialize(cookie)
8
+ @cookie = cookie
9
+ end
10
+
11
+ # scrub a cookie
12
+ def scrub
13
+ scrub_cookie(@cookie)
14
+ end
15
+
16
+ private
17
+
18
+ def scrub_cookie(cookie)
19
+ return nil if cookie.nil?
20
+
21
+ cookie.each do |key, _value|
22
+ cookie[key] = Errordeck::Scrubber::SENSITIVE_VALUE if Errordeck::Scrubber::SENSITIVE_PARAMS.include?(key)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ module Scrubber
5
+ class Header
6
+ # scrub an HTTP header
7
+ def initialize(header)
8
+ @header = header
9
+ end
10
+
11
+ # scrub an HTTP header
12
+ def scrub
13
+ scrub_header(@header)
14
+ end
15
+
16
+ private
17
+
18
+ def scrub_header(header)
19
+ return nil if header.nil?
20
+
21
+ header.to_h do |key, value|
22
+ if Errordeck::Scrubber::SENSITIVE_HEADERS.include?(key)
23
+ [key, Errordeck::Scrubber::SENSITIVE_VALUE]
24
+ else
25
+ [key, value]
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ module Scrubber
5
+ class QueryParam
6
+ # scrub a query parameter
7
+ def initialize(query, filter = nil)
8
+ @query = query
9
+ @filter = filter || Errordeck::Scrubber::SENSITIVE_PARAMS
10
+ end
11
+
12
+ # scrub a query parameter
13
+ def scrub
14
+ scrub_query(@query)
15
+ end
16
+
17
+ private
18
+
19
+ def scrub_query(query)
20
+ return nil if query.nil?
21
+
22
+ query.split("&").map do |param|
23
+ key, value = param.split("=")
24
+ if @filter.include?(key)
25
+ "#{key}=#{Errordeck::Scrubber::SENSITIVE_VALUE}"
26
+ else
27
+ param
28
+ end
29
+ end.join("&")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require all in this file's directory
4
+ require_relative "base"
5
+ require_relative "url"
6
+ require_relative "query_param"
7
+ require_relative "header"
8
+ require_relative "cookie"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ module Scrubber
5
+ class Url
6
+ def initialize(url, filter = nil)
7
+ @url = url
8
+ @filter = filter || Errordeck::Scrubber::SENSITIVE_PARAMS
9
+ end
10
+
11
+ # remove sensitive data from url
12
+ def scrub
13
+ uri = URI.parse(@url)
14
+ uri.query = Errordeck::Scrubber::QueryParam.new(uri.query, @filter).scrub
15
+ uri.to_s
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Errordeck
4
+ class Wrapper
5
+ attr_accessor :context
6
+ attr_reader :error_event, :transaction, :request, :user, :tags, :modules
7
+
8
+ def initialize
9
+ @error_event = nil
10
+ @transaction = nil
11
+ @request = nil
12
+ @user = nil
13
+ @tags = nil
14
+ @message = false
15
+ @already_sent = false
16
+ if Gem::Specification.respond_to?(:map)
17
+ @modules = Gem::Specification.to_h do |spec|
18
+ [spec.name, spec.version.to_s]
19
+ end
20
+ end
21
+ @context = Context.context
22
+ end
23
+
24
+ def send_event
25
+ return if @error_event.nil? && @message == true
26
+ return if @already_sent
27
+
28
+ uri = URI.parse("https://app.errordeck.com/api/#{config.project_id}/store")
29
+ http = Net::HTTP.new(uri.host, uri.port)
30
+ http.use_ssl = true
31
+ request = Net::HTTP::Post.new(uri.request_uri, "Authorization" => "Bearer #{config.token}")
32
+ request["Content-Type"] = "application/json"
33
+ event_json = @error_event&.to_json
34
+ response = http.request(request, event_json)
35
+ @already_sent = true
36
+ rescue StandardError => e
37
+ raise Error, "Error sending issue to Errordeck: #{e.message}"
38
+ end
39
+
40
+ def capture(exception, user = nil, tags = nil)
41
+ @message = false
42
+ @error_event = generate_from_exception(exception)
43
+ @error_event.user = user || @user
44
+ @error_event.tags = tags || @tags
45
+ @error_event.request = @request
46
+ @error_event
47
+ end
48
+
49
+ def message(level, message, extra = nil)
50
+ @message = true
51
+ @error_event = generate_boxing_event(level, message, extra)
52
+ @error_event.user = @user
53
+ @error_event.tags = @tags
54
+ @error_event.request = @request
55
+ @error_event
56
+ end
57
+
58
+ def set_action_context(env)
59
+ request_handler = RequestHandler.parse_from_rack_env(env)
60
+ @request = Request.parse_from_request_handler(request_handler)
61
+ end
62
+
63
+ def set_transaction(transaction = nil)
64
+ @transaction = transaction
65
+ end
66
+
67
+ def set_request(env = nil)
68
+ @request = Request.parse_from_rack_env(env)
69
+ end
70
+
71
+ def user_context=(user)
72
+ @user = user
73
+ end
74
+
75
+ def tags_context=(tags)
76
+ @tags = tags
77
+ end
78
+
79
+ private
80
+
81
+ def generate_from_exception(exception)
82
+ exceptions = Errordeck::Exception.parse_from_exception(exception, project_root)
83
+ Event.new(
84
+ level: config.level || exception_severity(exception),
85
+ transaction: transaction,
86
+ server_name: server_name_env,
87
+ release: config.release,
88
+ dist: config.dist,
89
+ environment: config.environment || find_environment,
90
+ message: exception.message,
91
+ modules: modules,
92
+ exceptions: exceptions,
93
+ contexts: context
94
+ )
95
+ end
96
+
97
+ def project_root
98
+ return Rails.root.to_s if defined?(Rails)
99
+ return Sinatra::Application.root.to_s if defined?(Sinatra)
100
+ return Rack::Directory.new("").root.to_s if defined?(Rack)
101
+ return Bundler.root.to_s if defined?(Bundler)
102
+
103
+ File.expand_path(File.join(File.dirname(__FILE__), "..", ".."))
104
+ end
105
+
106
+ def generate_boxing_event(level, message, extra = nil)
107
+ Event.new(
108
+ level: level,
109
+ transaction: transaction,
110
+ server_name: server_name_env,
111
+ release: config.release,
112
+ dist: config.dist,
113
+ environment: config.environment,
114
+ message: message,
115
+ modules: modules,
116
+ extra: extra,
117
+ contexts: context
118
+ )
119
+ end
120
+
121
+ def find_environment
122
+ # check if rails env is set or sinatra env is set
123
+ if defined?(Rails)
124
+ Rails.env
125
+ elsif defined?(Sinatra)
126
+ Sinatra::Base.environment
127
+ else
128
+ "production"
129
+ end
130
+ end
131
+
132
+ def exception_severity(exception)
133
+ case exception.class.to_s
134
+ when "SystemExit", "SignalException", "Interrupt", "NoMemoryError", "SecurityError"
135
+ :critical
136
+ when "RuntimeError", "TypeError", "LoadError", "NameError", "ArgumentError", "IndexError", "KeyError", "RangeError",
137
+ "NoMethodError", "FrozenError", "SocketError", "EncodingError", "Encoding::InvalidByteSequenceError",
138
+ "Encoding::UndefinedConversionError", "ZeroDivisionError", "SystemCallError", "Errno::EACCES",
139
+ "Errno::EADDRINUSE", "Errno::ECONNREFUSED", "Errno::ECONNRESET", "Errno::EEXIST", "Errno::EHOSTUNREACH",
140
+ "Errno::EINTR", "Errno::EINVAL", "Errno::EISDIR", "Errno::ENETDOWN", "Errno::ENETUNREACH", "Errno::ENOENT",
141
+ "Errno::ENOMEM", "Errno::ENOSPC", "Errno::ENOTCONN", "Errno::ENOTDIR", "Errno::EPIPE", "Errno::ERANGE",
142
+ "Errno::ETIMEDOUT", "Errno::ENOTEMPTY"
143
+ :error
144
+ when "Warning", "SecurityWarning", "DeprecatedError", "DeprecationWarning", "RuntimeWarning", "SyntaxError",
145
+ "NameError::UndefinedVariable", "LoadError::MissingFile", "NoMethodError::MissingMethod",
146
+ "ArgumentError::InvalidValue", "ArgumentError::MissingRequiredParameter", "IndexError::OutOfRange",
147
+ "ActiveRecord::RecordNotFound", "Mongoid::Errors::DocumentNotFound", "Redis::CommandError", "Net::ReadTimeout",
148
+ "Faraday::TimeoutError"
149
+ :warning
150
+ else
151
+ :error
152
+ end
153
+ end
154
+
155
+ def config
156
+ Errordeck.configuration
157
+ end
158
+
159
+ def server_name_env
160
+ # set server_name context
161
+ config.server_name || ENV.fetch("SERVER_NAME", nil)
162
+ end
163
+ end
164
+ end
data/lib/errordeck.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "errordeck/version"
8
+ require_relative "errordeck/configuration"
9
+ Dir["#{File.dirname(__FILE__)}/errordeck/errordeck/**/*.rb"].sort.each { |file| require file }
10
+ require_relative "errordeck/request_handler"
11
+ require_relative "errordeck/wrapper"
12
+ require_relative "errordeck/scrubber/scrubber"
13
+ require_relative "errordeck/plugin_require"
14
+
15
+ module Errordeck
16
+ class Error < StandardError; end
17
+
18
+ class << self
19
+ def info(message:, extra: nil)
20
+ generate_event(level: "info", message: message, extra: extra)
21
+ end
22
+
23
+ def warning(message:, extra: nil)
24
+ generate_event(level: "warning", message: message, extra: extra)
25
+ end
26
+
27
+ def error(message:, extra: nil)
28
+ generate_event(level: "error", message: message, extra: extra)
29
+ end
30
+
31
+ def fatal(message:, extra: nil)
32
+ generate_event(level: "fatal", message: message, extra: extra)
33
+ end
34
+
35
+ def generate_event(level:, message:, extra: nil, capture: true)
36
+ wrap(capture) do |b|
37
+ b.message(level, message, extra)
38
+ end
39
+ end
40
+
41
+ def message(level:, message:, extra: nil, capture: true)
42
+ wrap(capture) do |b|
43
+ b.message(level, message, extra)
44
+ end
45
+ end
46
+
47
+ def capture(exception:, user: nil, tags: nil, capture: true)
48
+ wrap(capture) do |b|
49
+ b.user_context = user if user
50
+ b.tags_context = tags if tags
51
+ b.capture(exception)
52
+ end
53
+ end
54
+
55
+ def wrap(capture = true)
56
+ wrapper = Wrapper.new
57
+ begin
58
+ yield(wrapper)
59
+ wrapper.send_event if capture
60
+ rescue Exception => e
61
+ wrapper.capture(e)
62
+ wrapper.send_event if capture
63
+ raise e
64
+ end
65
+ end
66
+ end
67
+ end