svcbase 0.1.16
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.rubocop.yml +63 -0
- data/.rubocop_todo.yml +7 -0
- data/.ruby-version +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +8 -0
- data/Jenkinsfile +137 -0
- data/README.md +61 -0
- data/Rakefile +17 -0
- data/bin/console +16 -0
- data/bin/setup +8 -0
- data/config/configs/base.yml +5 -0
- data/config/configs/local.yml +0 -0
- data/lib/svcbase/api/base.rb +73 -0
- data/lib/svcbase/api/etag.rb +11 -0
- data/lib/svcbase/api/requesthelpers.rb +34 -0
- data/lib/svcbase/appversion.rb +15 -0
- data/lib/svcbase/config/config_helper.rb +49 -0
- data/lib/svcbase/config.rb +50 -0
- data/lib/svcbase/corelogger.rb +52 -0
- data/lib/svcbase/dumpstats.rb +27 -0
- data/lib/svcbase/exceptions.rb +170 -0
- data/lib/svcbase/formatter.rb +90 -0
- data/lib/svcbase/ipaddr_helper.rb +34 -0
- data/lib/svcbase/middleware/apilogger.rb +184 -0
- data/lib/svcbase/middleware/dateheader.rb +16 -0
- data/lib/svcbase/middleware/requestid.rb +21 -0
- data/lib/svcbase/random.rb +31 -0
- data/lib/svcbase/server.rb +58 -0
- data/lib/svcbase/stats.rb +85 -0
- data/lib/svcbase/thresholder.rb +124 -0
- data/lib/svcbase/version.rb +5 -0
- data/lib/svcbase/worker.rb +66 -0
- data/lib/svcbase.rb +14 -0
- data/locale/en.yml +28 -0
- data/locale/zz.yml +21 -0
- data/svcbase.gemspec +51 -0
- metadata +305 -0
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/inflector'
|
4
|
+
|
5
|
+
module Core
|
6
|
+
module Exceptions
|
7
|
+
# errors
|
8
|
+
class Error < RuntimeError
|
9
|
+
# This sets the locale file scope. It may be redefined by subclasses to change
|
10
|
+
# that scope.
|
11
|
+
MESSAGE_SCOPE = 'app.errors'
|
12
|
+
|
13
|
+
attr_reader :details, :headers, :loglevel, :msgcode, :msgobj
|
14
|
+
|
15
|
+
def self.classname
|
16
|
+
name.demodulize
|
17
|
+
end
|
18
|
+
|
19
|
+
# STATUS should be redefined by subclasses. It cannot be overridden by ctor so it is fixed for a
|
20
|
+
# given subclass.
|
21
|
+
STATUS = 500
|
22
|
+
def status
|
23
|
+
self.class::STATUS
|
24
|
+
end
|
25
|
+
|
26
|
+
# Each Error class's default msgcode will be based on the class's name unless
|
27
|
+
# DEFAULT_MSGCODE is set for it or a superclass.
|
28
|
+
# Supplying a msgcode on the constructor will override the default.
|
29
|
+
DEFAULT_MSGCODE = nil
|
30
|
+
def self.default_msgcode
|
31
|
+
self::DEFAULT_MSGCODE || classname.underscore.to_sym # Exceptions::AuthError -> :auth_error
|
32
|
+
end
|
33
|
+
|
34
|
+
# Each Error class's default loglevel will be based on the class's STATUS unless
|
35
|
+
# DEFAULT_LOGLEVEL is set for it or a superclass.
|
36
|
+
# Supplying a loglevel on the constructor will override the default.
|
37
|
+
DEFAULT_LOGLEVEL = nil
|
38
|
+
def self.default_loglevel
|
39
|
+
self::DEFAULT_LOGLEVEL || ((400..499).cover?(self::STATUS) ? :error : :fatal)
|
40
|
+
end
|
41
|
+
|
42
|
+
# rubocop:disable AbcSize
|
43
|
+
def initialize(msg = nil, loglevel: nil, logmsg: nil, headers: nil, **details)
|
44
|
+
raise "new not allowed for #{self.class}" if self.class == Exceptions::Error
|
45
|
+
|
46
|
+
@msgcode = msg || details.delete(:msg) || self.class.default_msgcode
|
47
|
+
raise 'msg must be a symbol' unless @msgcode.is_a? Symbol
|
48
|
+
|
49
|
+
message = I18n.t!(@msgcode, scope: self.class::MESSAGE_SCOPE,
|
50
|
+
classname: self.class.name,
|
51
|
+
**details)
|
52
|
+
@msgobj = details.delete :msgobj
|
53
|
+
@details = details
|
54
|
+
@loglevel = loglevel || self.class.default_loglevel
|
55
|
+
@logmsg = logmsg
|
56
|
+
@headers = headers || {}
|
57
|
+
unless details.empty? || details[:message]
|
58
|
+
details[:message] ||= message
|
59
|
+
details[:code] ||= msgcode
|
60
|
+
details[:obj] ||= msgobj if msgobj
|
61
|
+
end
|
62
|
+
super message
|
63
|
+
rescue I18n::ArgumentError => e
|
64
|
+
# make sure we see these if they happen!
|
65
|
+
log.fatal e
|
66
|
+
raise
|
67
|
+
end
|
68
|
+
# rubocop:enable AbcSize
|
69
|
+
|
70
|
+
# This is called by sa_logger.
|
71
|
+
def logmsg
|
72
|
+
msg = @logmsg || message
|
73
|
+
return msg unless msg.is_a? String # this lets us log objects or exceptions too
|
74
|
+
"#{self.class.classname} - #{msg}"
|
75
|
+
end
|
76
|
+
|
77
|
+
# The response object that is returned to the caller.
|
78
|
+
def response
|
79
|
+
response = {
|
80
|
+
status: :error,
|
81
|
+
error: {
|
82
|
+
code: msgcode,
|
83
|
+
message: message
|
84
|
+
}
|
85
|
+
}
|
86
|
+
response[:error][:obj] = msgobj if msgobj
|
87
|
+
response[:error][:details] = details unless details.empty?
|
88
|
+
response
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
class NotModified < Exceptions::Error
|
93
|
+
STATUS = 304
|
94
|
+
DEFAULT_LOGLEVEL = :none
|
95
|
+
end
|
96
|
+
|
97
|
+
class BadRequest < Exceptions::Error
|
98
|
+
STATUS = 400
|
99
|
+
end
|
100
|
+
|
101
|
+
class ValidationFailed < BadRequest
|
102
|
+
DEFAULT_MSGCODE = :invalid
|
103
|
+
end
|
104
|
+
|
105
|
+
class Forbidden < Exceptions::Error
|
106
|
+
STATUS = 403
|
107
|
+
end
|
108
|
+
|
109
|
+
class NotFound < Exceptions::Error
|
110
|
+
STATUS = 404
|
111
|
+
end
|
112
|
+
|
113
|
+
class Conflict < Exceptions::Error
|
114
|
+
STATUS = 409
|
115
|
+
end
|
116
|
+
|
117
|
+
class Gone < Exceptions::Error
|
118
|
+
STATUS = 410
|
119
|
+
end
|
120
|
+
|
121
|
+
class UnsupportedMediaType < Exceptions::Error
|
122
|
+
STATUS = 415
|
123
|
+
end
|
124
|
+
|
125
|
+
class Unprocessable < Exceptions::Error
|
126
|
+
STATUS = 422
|
127
|
+
end
|
128
|
+
|
129
|
+
class TooManyRequests < Exceptions::Error
|
130
|
+
STATUS = 429
|
131
|
+
end
|
132
|
+
|
133
|
+
# Opaque errors should write their msg to the log, but should not
|
134
|
+
# return any useful data to the caller.
|
135
|
+
#
|
136
|
+
# Note that the raise syntax is different for these messages, since they
|
137
|
+
# only take a single logmsg and an optional loglevel.
|
138
|
+
#
|
139
|
+
# Examples:
|
140
|
+
# raise Exceptions::Fatal
|
141
|
+
# raise Exceptions::Fatal, 'Something is amiss'
|
142
|
+
# raise Exceptions::Fatal, 'Something is awry', loglevel: warn
|
143
|
+
# # still returns a 500 internal server error but logs at warn instead of fatal
|
144
|
+
class OpaqueError < Exceptions::Error
|
145
|
+
def initialize(logmsg = 'UNKNOWN', loglevel: nil)
|
146
|
+
raise "new not allowed for #{self.class}" if self.class == Exceptions::OpaqueError
|
147
|
+
super(self.class.default_msgcode, logmsg: logmsg, loglevel: loglevel)
|
148
|
+
end
|
149
|
+
|
150
|
+
def response
|
151
|
+
{ status: :error, error: { code: msgcode } }
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
# specialized class for 401
|
156
|
+
class Unauthorized < Exceptions::OpaqueError
|
157
|
+
STATUS = 401
|
158
|
+
DEFAULT_MSGCODE = :unauthorized # force all subclasses to return this msgcode as well
|
159
|
+
# ensure we have a WWW-Authenticate header as required by RFC
|
160
|
+
def headers
|
161
|
+
@headers.merge('WWW-Authenticate' => 'Bearer')
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
class Fatal < Exceptions::OpaqueError
|
166
|
+
STATUS = 500
|
167
|
+
DEFAULT_MSGCODE = :internal_server_error
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'request_store'
|
4
|
+
require 'time'
|
5
|
+
|
6
|
+
# add a helper method to Exception to provide a relative backtrace
|
7
|
+
class Exception
|
8
|
+
def relative_backtrace
|
9
|
+
return [] unless backtrace&.is_a?(Array)
|
10
|
+
trace = backtrace
|
11
|
+
stack = caller
|
12
|
+
while trace.last && stack.last && trace.last == stack.last
|
13
|
+
trace.pop
|
14
|
+
stack.pop
|
15
|
+
end
|
16
|
+
trace
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Log formatter
|
21
|
+
module Core
|
22
|
+
module Formatters
|
23
|
+
# Request logger
|
24
|
+
class Log
|
25
|
+
def call(severity, datetime, prog, data)
|
26
|
+
base = base_format(severity, datetime, prog)
|
27
|
+
|
28
|
+
arr = Array(data) unless data.is_a? Hash
|
29
|
+
arr = [data] if data.is_a? Hash
|
30
|
+
|
31
|
+
# need the last "\n" because .join won't do it
|
32
|
+
arr.each do |datum|
|
33
|
+
if datum.is_a? Hash
|
34
|
+
base.merge!(datum)
|
35
|
+
else
|
36
|
+
base["message"] = format(datum)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
base.to_json << "\n"
|
41
|
+
end
|
42
|
+
|
43
|
+
private def store_value(field)
|
44
|
+
f = RequestStore.store[field]
|
45
|
+
return nil if f.nil?
|
46
|
+
return nil if f.respond_to?(:empty?) && f.empty?
|
47
|
+
f
|
48
|
+
end
|
49
|
+
|
50
|
+
private def base_format(severity, datetime, prog)
|
51
|
+
req = store_value(:http_request_id)
|
52
|
+
mfa = store_value(:mfa_request_id)
|
53
|
+
uid = store_value(:uid)
|
54
|
+
sid = store_value(:session_id)
|
55
|
+
|
56
|
+
json_properties = {}
|
57
|
+
json_properties['ts'] = datetime.utc.iso8601(3)
|
58
|
+
json_properties['tid'] = Thread.current.object_id.to_s(32)
|
59
|
+
json_properties['sev'] = severity
|
60
|
+
json_properties['sid'] = sid unless sid.nil?
|
61
|
+
json_properties['req'] = req unless req.nil?
|
62
|
+
json_properties['mfa'] = mfa unless mfa.nil?
|
63
|
+
json_properties['uid'] = uid unless uid.nil?
|
64
|
+
json_properties['src'] = prog unless prog.nil?
|
65
|
+
|
66
|
+
json_properties
|
67
|
+
end
|
68
|
+
|
69
|
+
private def format(data)
|
70
|
+
if data.is_a?(String)
|
71
|
+
data
|
72
|
+
elsif data.is_a?(Exception)
|
73
|
+
format_exception(data)
|
74
|
+
else
|
75
|
+
data.inspect
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
private def format_exception(exc)
|
80
|
+
text = ["#{exc.class} - #{exc.message}", *exc.relative_backtrace]
|
81
|
+
if exc.cause
|
82
|
+
text << '--- Caused by: ---'
|
83
|
+
text << "#{exc.cause.class} - #{exc.cause.message}"
|
84
|
+
text << exc.cause.relative_backtrace
|
85
|
+
end
|
86
|
+
text.join("\n\t")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'ipaddr'
|
4
|
+
|
5
|
+
# monkey-patch IPAddr class to provide some cidr-related accessors
|
6
|
+
class IPAddr
|
7
|
+
def cidr?
|
8
|
+
full_mask = case @family
|
9
|
+
when Socket::AF_INET then IN4MASK
|
10
|
+
when Socket::AF_INET6 then IN6MASK
|
11
|
+
end
|
12
|
+
raise AddressFamilyError, 'unsupported address family' unless full_mask
|
13
|
+
@mask_addr != full_mask
|
14
|
+
end
|
15
|
+
|
16
|
+
def mask_bits
|
17
|
+
@mask_addr.to_s(2).sub(/0+$/, '').length
|
18
|
+
end
|
19
|
+
|
20
|
+
def canonical
|
21
|
+
to_s + (cidr? ? "/#{mask_bits}" : '')
|
22
|
+
end
|
23
|
+
|
24
|
+
PRIVATE_IP_RANGES = [
|
25
|
+
IPAddr.new('10.0.0.0/8'),
|
26
|
+
IPAddr.new('172.16.0.0/12'),
|
27
|
+
IPAddr.new('192.168.0.0/16'),
|
28
|
+
IPAddr.new('fc00::/7')
|
29
|
+
].freeze
|
30
|
+
|
31
|
+
def private?
|
32
|
+
@private ||= PRIVATE_IP_RANGES.any? { |rng| rng.include? self }
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,184 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_support/core_ext/object/try'
|
4
|
+
require 'svcbase/exceptions'
|
5
|
+
require 'grape'
|
6
|
+
require 'svcbase/corelogger'
|
7
|
+
require 'svcbase/stats'
|
8
|
+
require 'svcbase/thresholder'
|
9
|
+
|
10
|
+
module Core
|
11
|
+
# cribbed heavily from grape-middleware-logger
|
12
|
+
class ApiLogger < Grape::Middleware::Globals
|
13
|
+
BACKSLASH = '/'
|
14
|
+
|
15
|
+
attr_reader :logger
|
16
|
+
|
17
|
+
class << self
|
18
|
+
attr_accessor :logger, :filter
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(_unused, options = {})
|
22
|
+
super
|
23
|
+
@options[:filter] ||= self.class.filter
|
24
|
+
@logger = options[:logger] || self.class.logger || self.class.default_logger
|
25
|
+
end
|
26
|
+
|
27
|
+
# @note Error and exception handling are required for the +after+ hooks
|
28
|
+
# Exceptions are logged as a 500 status and re-raised
|
29
|
+
# Other "errors" are caught, logged and re-thrown
|
30
|
+
def call!(env)
|
31
|
+
@env = env
|
32
|
+
before
|
33
|
+
error = catch(:error) do
|
34
|
+
app_response = @app.call(@env)
|
35
|
+
@status, = *app_response
|
36
|
+
return app_response # NB: this exits the entire function, not just this block
|
37
|
+
end
|
38
|
+
# this is reached only if we caught an error! throw
|
39
|
+
@status = handle_throw(error)
|
40
|
+
# convert grape 401/500 to App 401/500 but skip logging (uncommon)
|
41
|
+
raise Core::Exceptions::Unauthorized, loglevel: :none if @status == 401
|
42
|
+
raise Core::Exceptions::Fatal, loglevel: :none if @status == 500
|
43
|
+
throw(:error, error)
|
44
|
+
rescue StandardError => e
|
45
|
+
@status = handle_exception(e)
|
46
|
+
raise
|
47
|
+
ensure
|
48
|
+
# this will run regardless of whether we exit via raise, throw, or return
|
49
|
+
after(@status)
|
50
|
+
end
|
51
|
+
|
52
|
+
private def before
|
53
|
+
@start_time = Time.now.utc
|
54
|
+
super
|
55
|
+
end
|
56
|
+
|
57
|
+
# rubocop:disable AbcSize
|
58
|
+
private def after(status)
|
59
|
+
@end_time = Time.now.utc
|
60
|
+
request_took_too_long = elapsed_time > Config.get_f!(:REQ_LOG_WARN_MILLIS, 1000)
|
61
|
+
auth_id = RequestStore.store[:auth_id]
|
62
|
+
|
63
|
+
if get_route_option(:stat_log)
|
64
|
+
identifiers = [RequestStore.store[:oid], auth_id].compact
|
65
|
+
Core::Stats.collect get_route_option(:stat_log), *identifiers, status, elapsed_time
|
66
|
+
end
|
67
|
+
|
68
|
+
# only suppress if successful and we didn't take too long
|
69
|
+
return if (status == 304 || (200..299).cover?(status)) && !request_took_too_long && suppress?(status)
|
70
|
+
|
71
|
+
r = env[Grape::Env::GRAPE_REQUEST]
|
72
|
+
|
73
|
+
info = { _status: status, _verb: r.request_method,
|
74
|
+
path: r.path, route: processed_by, api_id: auth_id,
|
75
|
+
params: filtered_parameters,
|
76
|
+
elapsed: elapsed_time,
|
77
|
+
ip: request.env['HTTP_X_FORWARDED_FOR'] || request.env['REMOTE_ADDR'],
|
78
|
+
ua: request.env['HTTP_USER_AGENT'] }
|
79
|
+
|
80
|
+
log.warn "#{processed_by} request took #{elapsed_time} msec" if request_took_too_long
|
81
|
+
log.info info
|
82
|
+
end
|
83
|
+
# rubocop:enable AbcSize
|
84
|
+
|
85
|
+
#
|
86
|
+
# Helpers
|
87
|
+
#
|
88
|
+
|
89
|
+
private def suppress?(status)
|
90
|
+
return true if get_route_option(:suppress_log)
|
91
|
+
# create either an array with the oid, or an empty array
|
92
|
+
# this will be splatted into the threshold calls below to qualify the namespace
|
93
|
+
oid = [RequestStore.store[:oid]].compact
|
94
|
+
|
95
|
+
if status == 304
|
96
|
+
threshold_304_log = get_route_option(:threshold_304_log)
|
97
|
+
Core::Thresholder.instance(threshold_304_log, *oid).limit { return true } if threshold_304_log
|
98
|
+
end
|
99
|
+
|
100
|
+
threshold_log = get_route_option(:threshold_log)
|
101
|
+
Core::Thresholder.instance(threshold_log, *oid).limit { return true } if threshold_log
|
102
|
+
|
103
|
+
false
|
104
|
+
end
|
105
|
+
|
106
|
+
private def handle_throw(exc)
|
107
|
+
# exc _should_ be a hash thrown by grape, but check just in case!
|
108
|
+
if exc.respond_to?(:[])
|
109
|
+
log.error "Error!: #{exc[:message] || 'UNKNOWN'}"
|
110
|
+
return exc[:status]
|
111
|
+
end
|
112
|
+
log.fatal "Unexpected error!: #{exc || 'UNKNOWN'}"
|
113
|
+
500
|
114
|
+
end
|
115
|
+
|
116
|
+
private def handle_exception(exc)
|
117
|
+
case exc
|
118
|
+
when Grape::Exceptions::ValidationErrors
|
119
|
+
loglevel = exc.all? { |_attr, err| err.try(:benign) } ? :debug : :error
|
120
|
+
logmsg = "Error: #{exc.message}"
|
121
|
+
when Grape::Exceptions::Base
|
122
|
+
loglevel = :error
|
123
|
+
logmsg = "Error: #{exc.message}"
|
124
|
+
when Core::Exceptions::Error
|
125
|
+
loglevel = exc.is_a?(Core::Exceptions::NotFound) && parameters['log404'] == 'false' ? :none : exc.loglevel
|
126
|
+
logmsg = exc.logmsg
|
127
|
+
else
|
128
|
+
loglevel = :none # this will get logged by the rescue_from at the top level
|
129
|
+
end
|
130
|
+
|
131
|
+
# don't log the exception here
|
132
|
+
log.send loglevel, logmsg
|
133
|
+
|
134
|
+
exc.respond_to?(:status) ? exc.status : 500
|
135
|
+
end
|
136
|
+
|
137
|
+
private def parameters
|
138
|
+
# we ignore these, these are params from the route
|
139
|
+
# request_params = env[Grape::Env::GRAPE_REQUEST_PARAMS].to_hash
|
140
|
+
@parameters ||= request
|
141
|
+
.params
|
142
|
+
.dup
|
143
|
+
.merge!(env['action_dispatch.request.request_parameters'] || {})
|
144
|
+
end
|
145
|
+
|
146
|
+
private def filtered_parameters
|
147
|
+
@options[:filter]&.filter(parameters, sensitive_params) || parameters
|
148
|
+
end
|
149
|
+
|
150
|
+
private def elapsed_time
|
151
|
+
@elapsed_time ||= ((@end_time - @start_time) * 1000).round(2)
|
152
|
+
end
|
153
|
+
|
154
|
+
private def endpoint_object
|
155
|
+
@endpoint_object ||= env[Grape::Env::API_ENDPOINT]
|
156
|
+
end
|
157
|
+
|
158
|
+
private def endpoint_options
|
159
|
+
@endpoint_options ||= (endpoint_object&.options) || {}
|
160
|
+
end
|
161
|
+
|
162
|
+
private def route_options
|
163
|
+
@route_options ||= endpoint_options[:route_options] || {}
|
164
|
+
end
|
165
|
+
|
166
|
+
private def get_route_option(key)
|
167
|
+
route_options[key] || RequestStore.store[key]
|
168
|
+
end
|
169
|
+
|
170
|
+
private def sensitive_params
|
171
|
+
@sensitive_params ||= route_options[:mask_log]
|
172
|
+
end
|
173
|
+
|
174
|
+
private def processed_by
|
175
|
+
endpoint_options[:for].to_s << '#' << endpoint_options[:path].map do |path|
|
176
|
+
path.to_s.sub(BACKSLASH, '')
|
177
|
+
end.join(BACKSLASH)
|
178
|
+
end
|
179
|
+
|
180
|
+
private def request
|
181
|
+
@request ||= ::Rack::Request.new(env)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Core
|
4
|
+
# Add Date header to output
|
5
|
+
class AddDateHeader
|
6
|
+
def initialize(app)
|
7
|
+
@app = app
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(env)
|
11
|
+
status, headers, body = @app.call(env)
|
12
|
+
headers['Date'] ||= Time.now.utc.httpdate
|
13
|
+
[status, headers, body]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'request_store'
|
4
|
+
require 'svcbase/random'
|
5
|
+
|
6
|
+
module Core
|
7
|
+
# Add per-request tracking
|
8
|
+
class RequestId
|
9
|
+
def initialize(app)
|
10
|
+
@app = app
|
11
|
+
end
|
12
|
+
|
13
|
+
def call(env)
|
14
|
+
# RequestStore gem allows us to not muck with thread local storage directly
|
15
|
+
RequestStore.store[:http_request_id] = env['HTTP_X_REQUEST_ID'] || Core::Random.short_id
|
16
|
+
status, headers, body = @app.call(env)
|
17
|
+
headers['X-Request-Id'] ||= RequestStore.store[:http_request_id]
|
18
|
+
[status, headers, body]
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'shortuuid'
|
5
|
+
require 'base64'
|
6
|
+
|
7
|
+
module Core
|
8
|
+
# random id helpers
|
9
|
+
module Random
|
10
|
+
def self.short_id
|
11
|
+
ShortUUID.shorten(SecureRandom.uuid)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.uuid
|
15
|
+
SecureRandom.uuid
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.base64_id(bytes)
|
19
|
+
Base64.strict_encode64(SecureRandom.random_bytes(bytes))
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.urlsafe_base64_id(bytes)
|
23
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(bytes), padding: false)
|
24
|
+
end
|
25
|
+
|
26
|
+
# generate an integer i such that 0 <= i < max_plus_one
|
27
|
+
def self.number(max_plus_one)
|
28
|
+
SecureRandom.random_number(max_plus_one)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'svcbase/appversion'
|
4
|
+
require 'svcbase/dumpstats'
|
5
|
+
require 'svcbase/random'
|
6
|
+
require 'svcbase/worker'
|
7
|
+
|
8
|
+
module Core
|
9
|
+
# actual server manager
|
10
|
+
class Server
|
11
|
+
include Singleton
|
12
|
+
|
13
|
+
ID = Core::Random.short_id.freeze
|
14
|
+
|
15
|
+
private def initialize
|
16
|
+
@workers = []
|
17
|
+
|
18
|
+
register_worker(Core::Workers::DumpStats.instance)
|
19
|
+
end
|
20
|
+
|
21
|
+
def register_worker(klass)
|
22
|
+
log.info('server') { "Registered server worker #{klass}" }
|
23
|
+
@workers << klass
|
24
|
+
end
|
25
|
+
|
26
|
+
def start_workers
|
27
|
+
@workers.each do |inst|
|
28
|
+
inst.worker_start
|
29
|
+
rescue StandardError => e
|
30
|
+
log.fatal('server') { ["ERROR in worker #{inst}", e] }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def stop_workers
|
35
|
+
@workers.each do |inst|
|
36
|
+
inst.worker_stop
|
37
|
+
rescue StandardError => e
|
38
|
+
log.fatal('server') { ["ERROR in worker #{inst}", e] }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def start
|
43
|
+
log.info('server') { "Server #{ID} starting" }
|
44
|
+
log.info('server') { "Commit: #{Core::Version::ID}" }
|
45
|
+
log.info('server') { "Tag: #{Core::Version::TAG}" }
|
46
|
+
|
47
|
+
start_workers
|
48
|
+
end
|
49
|
+
|
50
|
+
def stop
|
51
|
+
log.info('server') { "Server #{ID} stopping" }
|
52
|
+
|
53
|
+
stop_workers
|
54
|
+
|
55
|
+
log.info('server') { "Server #{ID} stopped" }
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Core
|
4
|
+
# Utility module to collect statistics
|
5
|
+
module Stats
|
6
|
+
@mutex = Mutex.new
|
7
|
+
@buffer = {}
|
8
|
+
|
9
|
+
# accumulate count, sum, and sum of squares to support calculation of mean and population standard deviation
|
10
|
+
# note: use the first value as a zero offset to reduce floating point precision errors
|
11
|
+
class Accumulator
|
12
|
+
attr_reader :count, :max, :min
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@count = 0
|
16
|
+
@sum = 0
|
17
|
+
@sumsqr = 0
|
18
|
+
end
|
19
|
+
|
20
|
+
def add_value(value)
|
21
|
+
@offset ||= value
|
22
|
+
@max = value if @max.nil? || value > @max
|
23
|
+
@min = value if @min.nil? || value < @max
|
24
|
+
offset_val = value - @offset
|
25
|
+
@sum += offset_val
|
26
|
+
@sumsqr += offset_val**2
|
27
|
+
@count += 1
|
28
|
+
end
|
29
|
+
|
30
|
+
def mean
|
31
|
+
@offset + @sum / @count
|
32
|
+
end
|
33
|
+
|
34
|
+
def stddev
|
35
|
+
Math.sqrt((@sumsqr - @sum**2 / @count) / @count)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# collect a numeric data point
|
40
|
+
# first n-1 args are used as hash keys, the last arg is the data value
|
41
|
+
def self.collect(*args)
|
42
|
+
raise ArgumentError unless args.length >= 2
|
43
|
+
value = args.pop
|
44
|
+
@mutex.synchronize do
|
45
|
+
# find/create the appropriate object within the buffer
|
46
|
+
obj = args.inject(@buffer) { |buf, key| buf[key.to_s] ||= {} }
|
47
|
+
# add the data value to the object's accumulator
|
48
|
+
(obj[:_data] ||= Accumulator.new).add_value(value)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# traverse the buffer and yield with each
|
53
|
+
private_class_method def self.traverse_data(hash)
|
54
|
+
stack = []
|
55
|
+
# add top level keys to the array in order
|
56
|
+
hash.keys.sort.map { |k| stack.push [[k], @buffer[k]] unless k == :_data }
|
57
|
+
until stack.empty?
|
58
|
+
keylist, obj = stack.shift
|
59
|
+
yield keylist, obj[:_data] if obj.key? :_data
|
60
|
+
# add next level of keys in reverse order to the front of the list
|
61
|
+
obj.keys.sort.reverse.map { |k| stack.unshift [keylist.dup << k, obj[k]] unless k == :_data }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.log_and_reset
|
66
|
+
msgs = ["Server: #{Server::ID}", "Commit: #{Core::Version::ID}", "Tag: #{Core::Version::TAG}"]
|
67
|
+
|
68
|
+
# app can define a #custom_messages method to customize reporting
|
69
|
+
msgs.concat(Array(custom_messages)) if respond_to? :custom_messages
|
70
|
+
|
71
|
+
@mutex.synchronize do
|
72
|
+
traverse_data(@buffer) do |keylist, data|
|
73
|
+
count = data.count
|
74
|
+
min = data.min
|
75
|
+
mean = data.mean.round(2)
|
76
|
+
max = data.max
|
77
|
+
stddev = data.stddev.round(2)
|
78
|
+
msgs << "#{keylist.join('|')}: count: #{count} min: #{min} mean: #{mean} max: #{max} stddev: #{stddev}"
|
79
|
+
end
|
80
|
+
@buffer = {}
|
81
|
+
end
|
82
|
+
msgs.each { |msg| log.info('stats') { msg } }
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|