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 +7 -0
- data/lib/periskop/client/collector.rb +71 -0
- data/lib/periskop/client/exporter.rb +25 -0
- data/lib/periskop/client/models.rb +154 -0
- data/lib/periskop/periskop.rb +3 -0
- data/lib/periskop/rack/middleware.rb +92 -0
- metadata +48 -0
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,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: []
|