periskop-client 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: 0d35f32e41d59a38412f7c5a424fe1586c836313c48af76ffa2aadbddd36b15b
4
+ data.tar.gz: 6a03127bfa7a1f521d2719557330c8dc9c4d2d4f11f24c8da612ce2249e16302
5
+ SHA512:
6
+ metadata.gz: 1e7fdfa8becf464beba2e157dc5baf55d7a71f23f628b64e43d7f39ec830a67e497fe02bddaff931b37b56279abcbf20fd6144827286a1d1b734ce7b2f6172fb
7
+ data.tar.gz: 4c50cdae5ac0f5c619567074ef53470157b730c6005799c365a47decb99ec740beee943a10bad70fcbf322db9e7d293082b1455723802ccd374028d89e8b6ce7
@@ -0,0 +1,71 @@
1
+ require 'periskop/client/models'
2
+ require 'json'
3
+ require 'securerandom'
4
+
5
+ module Periskop
6
+ module Client
7
+ # ExceptionCollector collects reported exceptions and aggregates them
8
+ class ExceptionCollector
9
+ def initialize
10
+ @aggregated_exceptions_dict = {}
11
+ @uuid = SecureRandom.uuid
12
+ end
13
+
14
+ attr_reader :aggregated_exceptions_dict
15
+
16
+ def aggregated_exceptions
17
+ Payload.new(@aggregated_exceptions_dict.values, @uuid)
18
+ end
19
+
20
+ # Report an exception
21
+ # Params:
22
+ # exception:: captured exception
23
+ def report(exception)
24
+ add_exception(exception, nil)
25
+ end
26
+
27
+ # Report an exception with context
28
+ # Params:
29
+ # exception:: captured exception
30
+ # context:: HTTP context of the exception
31
+ def report_with_context(exception, context)
32
+ add_exception(exception, context)
33
+ end
34
+
35
+ private
36
+
37
+ def add_exception(exception, context)
38
+ exception_instance = ExceptionInstance.new(
39
+ exception.class.name,
40
+ exception.message,
41
+ exception.backtrace,
42
+ get_cause(exception)
43
+ )
44
+ exception_with_context = ExceptionWithContext.new(
45
+ exception_instance,
46
+ context,
47
+ Periskop::Client::SEVERITY_ERROR
48
+ )
49
+ aggregation_key = exception_with_context.aggregation_key()
50
+
51
+ unless @aggregated_exceptions_dict.key?(aggregation_key)
52
+ aggregated_exception = AggregatedException.new(
53
+ aggregation_key,
54
+ Periskop::Client::SEVERITY_ERROR
55
+ )
56
+ @aggregated_exceptions_dict.store(aggregation_key, aggregated_exception)
57
+ end
58
+ aggregated_exception = @aggregated_exceptions_dict[aggregation_key]
59
+ aggregated_exception.add_exception(exception_with_context)
60
+ end
61
+
62
+ def get_cause(exception)
63
+ if RUBY_VERSION > '2.0'
64
+ return exception.cause
65
+ end
66
+
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ require 'net/http'
2
+ require 'uri'
3
+
4
+ module Periskop
5
+ module Client
6
+ # Exporter exposes in json format all collected exceptions from the specified `collector`
7
+ class Exporter
8
+ def initialize(collector)
9
+ @collector = collector
10
+ end
11
+
12
+ def export
13
+ @collector.aggregated_exceptions.to_json
14
+ end
15
+
16
+ def push_to_gateway(addr)
17
+ uri = URI.parse("#{addr}/errors")
18
+ http = Net::HTTP.new(uri.host, uri.port)
19
+ request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
20
+ request.body = export
21
+ http.request(request)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,154 @@
1
+ require 'time'
2
+ require 'securerandom'
3
+ require 'digest'
4
+
5
+ module Periskop
6
+ module Client
7
+ SEVERITY_INFO = "info"
8
+ SEVERITY_WARNING = "warning"
9
+ SEVERITY_ERROR = "error"
10
+ # ExceptionInstance has all metadata of a reported exception
11
+ class ExceptionInstance
12
+ attr_accessor :class, :message, :stacktrace, :cause
13
+
14
+ def initialize(cls, message, stacktrace, cause)
15
+ @class = cls
16
+ @message = message
17
+ @stacktrace = stacktrace
18
+ @cause = cause
19
+ end
20
+
21
+ def as_json(_options = {})
22
+ {
23
+ class: @class,
24
+ message: @message,
25
+ stacktrace: @stacktrace,
26
+ cause: @cause
27
+ }
28
+ end
29
+
30
+ def to_json(*options)
31
+ as_json(*options).to_json(*options)
32
+ end
33
+ end
34
+
35
+ # HTTPContext represents data from HTTP context of an exception
36
+ class HTTPContext
37
+ attr_accessor :request_method
38
+
39
+ def initialize(request_method, request_url, request_headers, request_body)
40
+ @request_method = request_method
41
+ @request_url = request_url
42
+ @request_headers = request_headers
43
+ @request_body = request_body
44
+ end
45
+
46
+ def as_json(_options = {})
47
+ {
48
+ request_method: @request_method,
49
+ request_url: @request_url,
50
+ request_headers: @request_headers,
51
+ request_body: @request_body
52
+ }
53
+ end
54
+
55
+ def to_json(*options)
56
+ as_json(*options).to_json(*options)
57
+ end
58
+ end
59
+
60
+ # ExceptionWithContext represents a reported exception with HTTP context
61
+ class ExceptionWithContext
62
+ attr_accessor :exception_instance, :http_context
63
+
64
+ NUM_HASH_CHARS = 8
65
+ MAX_TRACES = 5
66
+
67
+ def initialize(exception_instance, http_context, severity)
68
+ @exception_instance = exception_instance
69
+ @http_context = http_context
70
+ @severity = severity
71
+ @uuid = SecureRandom.uuid
72
+ @timestamp = Time.now.utc.iso8601
73
+ end
74
+
75
+ # Generates the aggregation key with a hash using the last MAX_TRACES
76
+ def aggregation_key
77
+ stacktrace_head = @exception_instance.stacktrace.first(MAX_TRACES).join('')
78
+ error_hash = Digest::MD5.hexdigest(stacktrace_head)[0..NUM_HASH_CHARS - 1]
79
+ "#{@exception_instance.class}@#{error_hash}"
80
+ end
81
+
82
+ def as_json(_options = {})
83
+ {
84
+ error: @exception_instance,
85
+ http_context: @http_context,
86
+ severity: @severity,
87
+ uuid: @uuid,
88
+ timestamp: @timestamp
89
+ }
90
+ end
91
+
92
+ def to_json(*options)
93
+ as_json(*options).to_json(*options)
94
+ end
95
+ end
96
+
97
+ # AggregatedException represents the aggregation of a group of exceptions
98
+ class AggregatedException
99
+ attr_accessor :latest_errors, :total_count
100
+
101
+ MAX_ERRORS = 10
102
+
103
+ def initialize(aggregation_key, severity)
104
+ @aggregation_key = aggregation_key
105
+ @latest_errors = []
106
+ @total_count = 0
107
+ @severity = severity
108
+ @created_at = Time.now.utc.iso8601
109
+ end
110
+
111
+ # Add exception to the list of latest errors up to MAX_ERRORS
112
+ def add_exception(exception_with_context)
113
+ if @latest_errors.size >= MAX_ERRORS
114
+ @latest_errors.shift
115
+ end
116
+ @latest_errors.push(exception_with_context)
117
+ @total_count += 1
118
+ end
119
+
120
+ def as_json(_options = {})
121
+ {
122
+ aggregation_key: @aggregation_key,
123
+ created_at: @created_at,
124
+ total_count: @total_count,
125
+ severity: @severity,
126
+ latest_errors: @latest_errors
127
+ }
128
+ end
129
+
130
+ def to_json(*options)
131
+ as_json(*options).to_json(*options)
132
+ end
133
+ end
134
+
135
+ # Payload represents the aggregated structure of errors
136
+ class Payload
137
+ def initialize(aggregated_errors, target_uuid)
138
+ @aggregated_errors = aggregated_errors
139
+ @target_uuid = target_uuid
140
+ end
141
+
142
+ def as_json(_options = {})
143
+ {
144
+ aggregated_errors: @aggregated_errors,
145
+ target_uuid: @target_uuid
146
+ }
147
+ end
148
+
149
+ def to_json(*options)
150
+ as_json(*options).to_json(*options)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,3 @@
1
+ # Periskop module
2
+ module Periskop
3
+ end
@@ -0,0 +1,92 @@
1
+ require 'periskop/client/collector'
2
+ require 'periskop/client/exporter'
3
+ require 'periskop/client/models'
4
+
5
+ module Periskop
6
+ module Rack
7
+ class Middleware
8
+ attr_accessor :collector
9
+
10
+ def initialize(app, options = {})
11
+ @app = app
12
+ @pushgateway_address = options.fetch(:pushgateway_address)
13
+ options[:collector] ||= Periskop::Client::ExceptionCollector.new
14
+ @collector = options.fetch(:collector)
15
+
16
+ @exporter =
17
+ if @pushgateway_address
18
+ @exporter = Periskop::Client::Exporter.new(@collector)
19
+ end
20
+ end
21
+
22
+ def call(env)
23
+ begin
24
+ response = @app.call(env)
25
+ rescue Exception => ex
26
+ report_push(env, ex)
27
+ raise(ex)
28
+ end
29
+
30
+ maybe_ex = framework_exception(env)
31
+ report_push(env, maybe_ex) if maybe_ex
32
+
33
+ response
34
+ end
35
+
36
+ private
37
+
38
+ # Web framework middlewares often store rescued exceptions inside the
39
+ # Rack env, but Rack doesn't have a standard key for it:
40
+ #
41
+ # - Rails uses action_dispatch.exception: https://goo.gl/Kd694n
42
+ # - Sinatra uses sinatra.error: https://goo.gl/LLkVL9
43
+ # - Goliath uses rack.exception: https://goo.gl/i7e1nA
44
+ def framework_exception(env)
45
+ env['rack.exception'] ||
46
+ env['sinatra.error'] ||
47
+ env['action_dispatch.exception']
48
+ end
49
+
50
+ def find_request(env)
51
+ if defined?(ActionDispatch::Request)
52
+ ActionDispatch::Request.new(env)
53
+ elsif defined?(Sinatra::Request)
54
+ Sinatra::Request.new(env)
55
+ else
56
+ ::Rack::Request.new(env)
57
+ end
58
+ end
59
+
60
+ def get_http_headers(request_env)
61
+ header_prefixes = %w[
62
+ HTTP_
63
+ CONTENT_TYPE
64
+ CONTENT_LENGTH
65
+ ].freeze
66
+
67
+ request_env.map.with_object({}) do |(key, value), headers|
68
+ if header_prefixes.any? { |prefix| key.to_s.start_with?(prefix) }
69
+ headers[key] = value
70
+ end
71
+ headers
72
+ end
73
+ end
74
+
75
+ def get_http_context(env)
76
+ request = find_request(env)
77
+ Periskop::Client::HTTPContext.new(request.request_method, request.url, get_http_headers(request.env), nil)
78
+ end
79
+
80
+ def report_push(env, maybe_ex)
81
+ ex =
82
+ if maybe_ex.is_a?(Exception)
83
+ maybe_ex
84
+ else
85
+ RuntimeError.new(maybe_ex.to_s)
86
+ end
87
+ @collector.report_with_context(ex, get_http_context(env))
88
+ @exporter&.push_to_gateway(@pushgateway_address)
89
+ end
90
+ end
91
+ end
92
+ end
metadata ADDED
@@ -0,0 +1,48 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: periskop-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Julio Zynger
8
+ - Marc Tuduri
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2022-01-14 00:00:00.000000000 Z
13
+ dependencies: []
14
+ description: Periskop client for Ruby
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/periskop/client/collector.rb
21
+ - lib/periskop/client/exporter.rb
22
+ - lib/periskop/client/models.rb
23
+ - lib/periskop/periskop.rb
24
+ - lib/periskop/rack/middleware.rb
25
+ homepage:
26
+ licenses:
27
+ - Apache
28
+ metadata: {}
29
+ post_install_message:
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 3.1.2
45
+ signing_key:
46
+ specification_version: 4
47
+ summary: Periskop client for Ruby
48
+ test_files: []