svcbase 0.1.16

Sign up to get free protection for your applications and to get access to all the features.
@@ -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