svcbase 0.1.16
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/.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
|