periskop-client 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: 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: []