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.
@@ -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