gritz-core 0.2.0 → 0.4.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.
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Metrics
5
+ # Master-owned totals survive worker retirement; only replay state is forgotten.
6
+ # @api private
7
+ class Aggregator
8
+ def initialize
9
+ @lock = Mutex.new
10
+ @rpc = {}
11
+ @sequences = {}
12
+ @rejected = 0
13
+ @restarts = Hash.new(0)
14
+ end
15
+
16
+ def apply(worker, envelope)
17
+ unless envelope.is_a?(Hash) && envelope[:seq].is_a?(Integer) && envelope[:seq].positive?
18
+ raise ConfigurationError, "Invalid metric sequence"
19
+ end
20
+
21
+ @lock.synchronize do
22
+ sequence = envelope[:seq]
23
+ previous = @sequences.fetch(worker, 0)
24
+ return false if sequence <= previous
25
+ raise ConfigurationError, "Metric sequence gap: expected #{previous + 1}, got #{sequence}" unless sequence == previous + 1
26
+
27
+ delta = envelope[:delta]
28
+ validate_delta!(delta)
29
+ delta[:rpc].each do |row|
30
+ labels = row.slice(:service, :method, :code)
31
+ aggregate = @rpc[labels.values]
32
+ unless aggregate
33
+ aggregate = Recorder.empty_row(**labels)
34
+ @rpc[aggregate.values_at(:service, :method, :code).freeze] = aggregate
35
+ end
36
+ Recorder::SCALARS.each { |field| aggregate[field] += row[field] }
37
+ Recorder::BUCKET_FIELDS.each do |field|
38
+ row[field].each_with_index { |count, index| aggregate[field][index] += count }
39
+ end
40
+ end
41
+ @rejected += delta[:rejected]
42
+ @sequences[worker] = sequence
43
+ true
44
+ end
45
+ end
46
+
47
+ def forget(worker) = @lock.synchronize { @sequences.delete(worker) }
48
+
49
+ def record_restart(reason:)
50
+ @lock.synchronize { @restarts[reason.to_s] += 1 }
51
+ end
52
+
53
+ def render(workers: [])
54
+ rows = workers.map { |worker| worker.is_a?(Hash) ? worker : worker.stats.merge(pid: worker.pid, index: worker.index, state: worker.state) }
55
+ @lock.synchronize do
56
+ output = +""
57
+ histogram(output, "rpc_server_duration_seconds", Recorder::DURATION_BUCKETS, :duration_buckets, :duration_sum)
58
+ histogram(output, "rpc_server_requests_per_rpc", Recorder::MESSAGE_BUCKETS, :request_buckets, :request_sum)
59
+ histogram(output, "rpc_server_responses_per_rpc", Recorder::MESSAGE_BUCKETS, :response_buckets, :response_sum)
60
+ output << "# TYPE gritz_rejected_total counter\ngritz_rejected_total #{@rejected}\n"
61
+ output << "# TYPE gritz_worker_restarts_total counter\n"
62
+ @restarts.sort.each { |reason, count| output << "gritz_worker_restarts_total{reason=\"#{escape(reason)}\"} #{count}\n" }
63
+ output << "# TYPE gritz_workers gauge\n"
64
+ Supervisor::WorkerHandle::STATES.each { |state| output << "gritz_workers{state=\"#{state}\"} #{rows.count { |row| row[:state] == state }}\n" }
65
+ serving = rows.select { |row| %w[ready draining].include?(row[:state]) }
66
+ output << "# TYPE gritz_threadpool_busy gauge\ngritz_threadpool_busy #{serving.sum { |row| row[:busy_threads] || row[:busy] || 0 }}\n"
67
+ output << "# TYPE gritz_threadpool_capacity gauge\ngritz_threadpool_capacity #{serving.sum { |row| row[:capacity] || 0 }}\n"
68
+ output << "# TYPE gritz_worker_pss_bytes gauge\n"
69
+ rows.each do |row|
70
+ next unless row[:pss_bytes]
71
+
72
+ output << "gritz_worker_pss_bytes{worker=\"#{row[:index]}\",pid=\"#{row[:pid]}\"} #{row[:pss_bytes]}\n"
73
+ end
74
+ output
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def validate_delta!(delta)
81
+ valid = delta.is_a?(Hash) && delta[:rpc].is_a?(Array) && nonnegative_integer?(delta[:rejected])
82
+ raise ConfigurationError, "Invalid metric delta" unless valid
83
+
84
+ delta[:rpc].each do |row|
85
+ valid = row.is_a?(Hash) && %i[service method].all? { |key| row[key].is_a?(String) && !row[key].empty? } &&
86
+ row[:code].is_a?(Integer) && row[:code].between?(0, 16) &&
87
+ %i[count request_sum response_sum].all? { |key| nonnegative_integer?(row[key]) } &&
88
+ row[:duration_sum].is_a?(Numeric) && row[:duration_sum].real? && row[:duration_sum].to_f.finite? && row[:duration_sum] >= 0
89
+ raise ConfigurationError, "Invalid RPC metric row" unless valid
90
+
91
+ Recorder::BUCKET_FIELDS.each do |field|
92
+ size = field == :duration_buckets ? Recorder::DURATION_BUCKETS.size : Recorder::MESSAGE_BUCKETS.size
93
+ buckets = row[field]
94
+ unless buckets.is_a?(Array) && buckets.size == size && buckets.all? { |count| nonnegative_integer?(count) } && buckets.sum == row[:count]
95
+ raise ConfigurationError, "Invalid RPC metric histogram"
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ def nonnegative_integer?(value) = value.is_a?(Integer) && value >= 0
102
+
103
+ def histogram(output, name, bounds, bucket_field, sum_field)
104
+ output << "# TYPE #{name} histogram\n"
105
+ @rpc.sort_by { |key, _row| key }.each do |_key, row|
106
+ labels = "rpc_service=\"#{escape(row[:service])}\",rpc_method=\"#{escape(row[:method])}\",rpc_grpc_status_code=\"#{row[:code]}\""
107
+ total = 0
108
+ bounds.each_with_index do |upper, index|
109
+ total += row[bucket_field][index]
110
+ output << "#{name}_bucket{#{labels},le=\"#{upper.infinite? ? '+Inf' : upper}\"} #{total}\n"
111
+ end
112
+ output << "#{name}_sum{#{labels}} #{row[sum_field]}\n#{name}_count{#{labels}} #{row[:count]}\n"
113
+ end
114
+ end
115
+
116
+ def escape(value) = value.gsub(/[\\"\n]/) { |character| { "\\" => "\\\\", '"' => '\\"', "\n" => "\\n" }.fetch(character) }
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gritz
6
+ module Metrics
7
+ # Per-worker RPC deltas; only one bounded detached batch is needed by the sender.
8
+ # @api private
9
+ class Recorder
10
+ DURATION_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, Float::INFINITY].freeze
11
+ MESSAGE_BUCKETS = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 1024, Float::INFINITY].freeze
12
+ SCALARS = %i[count duration_sum request_sum response_sum].freeze
13
+ BUCKET_FIELDS = %i[duration_buckets request_buckets response_buckets].freeze
14
+
15
+ def initialize
16
+ @lock = Mutex.new
17
+ @rpc = {}
18
+ @last_rejected = @rejected_delta = 0
19
+ end
20
+
21
+ def self.empty_row(service:, method:, code:)
22
+ { service: service.dup.freeze, method: method.dup.freeze, code: code, count: 0, duration_sum: 0.0, request_sum: 0, response_sum: 0,
23
+ duration_buckets: Array.new(DURATION_BUCKETS.size, 0), request_buckets: Array.new(MESSAGE_BUCKETS.size, 0),
24
+ response_buckets: Array.new(MESSAGE_BUCKETS.size, 0) }
25
+ end
26
+
27
+ def record_rpc(service:, method:, code:, duration:, requests:, responses:)
28
+ unless [service, method].all? { |label| label.is_a?(String) && !label.empty? } && code.is_a?(Integer) && code.between?(0, 16) &&
29
+ duration.is_a?(Numeric) && duration.real? && duration.to_f.finite? && duration >= 0 &&
30
+ [requests, responses].all? { |value| value.is_a?(Integer) && value >= 0 }
31
+ raise ArgumentError, "Invalid RPC metric observation"
32
+ end
33
+
34
+ @lock.synchronize do
35
+ row = @rpc[[service, method, code]]
36
+ unless row
37
+ row = self.class.empty_row(service: service, method: method, code: code)
38
+ @rpc[[row[:service], row[:method], code].freeze] = row
39
+ end
40
+ row[:count] += 1
41
+ row[:duration_sum] += duration.to_f
42
+ row[:request_sum] += requests
43
+ row[:response_sum] += responses
44
+ row[:duration_buckets][DURATION_BUCKETS.bsearch_index { |upper| duration <= upper }] += 1
45
+ row[:request_buckets][MESSAGE_BUCKETS.bsearch_index { |upper| requests <= upper }] += 1
46
+ row[:response_buckets][MESSAGE_BUCKETS.bsearch_index { |upper| responses <= upper }] += 1
47
+ end
48
+ end
49
+
50
+ def observe_rejected(total)
51
+ @lock.synchronize do
52
+ raise ArgumentError, "Rejected counter must be a nondecreasing integer" unless total.is_a?(Integer) && total >= @last_rejected
53
+
54
+ @rejected_delta += total - @last_rejected
55
+ @last_rejected = total
56
+ end
57
+ end
58
+
59
+ # Optional process observations and shutdown for installed worker backends.
60
+ def observe_worker(_status); end
61
+ def close(timeout: nil); end
62
+
63
+ # The caller retains this detached delta until its status channel accepts the row.
64
+ def take_delta(max_bytes: Supervisor::StatusChannel::MAX_LINE_BYTES - 4096)
65
+ raise ArgumentError, "Metric packet budget must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
66
+
67
+ @lock.synchronize do
68
+ return nil if @rpc.empty? && @rejected_delta.zero?
69
+
70
+ delta = { rpc: [], rejected: @rejected_delta }
71
+ used_bytes = JSON.generate(delta).bytesize
72
+ raise ArgumentError, "Metric packet budget is too small" if used_bytes > max_bytes
73
+
74
+ selected = []
75
+ @rpc.each do |key, row|
76
+ row_bytes = JSON.generate(row).bytesize + (selected.empty? ? 0 : 1)
77
+ if used_bytes + row_bytes > max_bytes
78
+ raise ArgumentError, "Metric row exceeds the packet budget" if selected.empty?
79
+
80
+ break
81
+ end
82
+ delta[:rpc] << row
83
+ selected << key
84
+ used_bytes += row_bytes
85
+ end
86
+ selected.each { |key| @rpc.delete(key) }
87
+ @rejected_delta = 0
88
+ delta
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,23 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require "json"
5
4
 
6
5
  module Gritz
7
6
  module Middleware
8
7
  # Converts application exceptions to safe RPC errors.
9
8
  # @api public
10
9
  class ExceptionMapper
11
- def initialize(app, mappings: {}, expose_errors: false)
10
+ def initialize(app, mappings: {}, expose_errors: false, passthrough_remote_errors: false)
12
11
  @app = app
13
12
  @mappings = mappings
14
13
  @expose_errors = expose_errors
14
+ @passthrough_remote_errors = passthrough_remote_errors
15
15
  end
16
16
 
17
17
  def call(context)
18
18
  @app.call(context)
19
- rescue Gritz::Error
20
- raise
19
+ rescue Gritz::Error => e
20
+ raise unless e.remote? && !@passthrough_remote_errors
21
+
22
+ raise_internal(context, e)
21
23
  rescue StandardError => e
22
24
  mapping = @mappings.find { |klass, _| e.is_a?(klass) }&.last
23
25
  if mapping
@@ -31,10 +33,15 @@ module Gritz
31
33
  raise Errors::InvalidArgument, "invalid record"
32
34
  end
33
35
 
36
+ raise_internal(context, e)
37
+ end
38
+
39
+ private
40
+
41
+ def raise_internal(context, error)
34
42
  error_id = SecureRandom.uuid
35
- context.logger.error(JSON.generate(error_id:, request_id: context.request_id, error: e.class.name,
36
- message: e.message, backtrace: e.backtrace))
37
- raise Errors::Internal.new(@expose_errors ? e.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
43
+ context.store[:gritz_error] = { error_id: error_id, error: error.class.name, message: error.message, backtrace: error.backtrace }
44
+ raise Errors::Internal.new(@expose_errors ? error.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
38
45
  end
39
46
  end
40
47
  end
@@ -23,9 +23,33 @@ module Gritz
23
23
  raise
24
24
  ensure
25
25
  duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
26
- context.logger.info(JSON.generate(request_id: context.request_id, service: context.method.service,
27
- method: context.method.name, code:, duration_ms: duration.round(3),
28
- peer: context.peer, pid: Process.pid))
26
+ fields = { request_id: context.request_id, service: context.method.service,
27
+ method: context.method.name, code: code, duration_ms: duration.round(3),
28
+ peer: context.peer, worker: context.worker, pid: Process.pid, bytes_in: context.bytes_in, bytes_out: context.bytes_out }
29
+ fields.merge!(context.store.fetch(:gritz_error, {}))
30
+ fields = prepare(fields, context.log_redact)
31
+ message = if context.log_format == :logfmt
32
+ fields.map do |key, value|
33
+ value = value.to_s if value.is_a?(Symbol)
34
+ value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
35
+ "#{key}=#{JSON.generate(value)}"
36
+ end.join(" ")
37
+ else
38
+ JSON.generate(fields)
39
+ end
40
+ context.logger.info(message)
41
+ end
42
+
43
+ private
44
+
45
+ def prepare(value, redacted)
46
+ case value
47
+ when Hash
48
+ value.to_h { |key, item| [key, redacted.include?(key.to_s) ? "[FILTERED]" : prepare(item, redacted)] }
49
+ when Array then value.map { |item| prepare(item, redacted) }
50
+ when String then value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
51
+ else value
52
+ end
29
53
  end
30
54
  end
31
55
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Middleware
5
+ # One completion observation wraps the complete unary or streaming action.
6
+ # @api public
7
+ class Metrics
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(context)
13
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ code = 0
15
+ @app.call(context)
16
+ rescue Gritz::Error => e
17
+ code = e.grpc_code
18
+ raise
19
+ rescue StandardError
20
+ code = 13
21
+ raise
22
+ ensure
23
+ context.metrics&.record_rpc(service: context.method.service, method: context.method.name, code: code,
24
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
25
+ requests: context.requests_count, responses: context.responses_count)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -18,7 +18,7 @@ module Gritz
18
18
  end
19
19
 
20
20
  def self.default
21
- new.use(RequestId).use(Context).use(Logging).use(ExceptionMapper)
21
+ new.use(RequestId).use(Context).use(Metrics).use(Logging).use(ExceptionMapper)
22
22
  end
23
23
 
24
24
  def use(middleware, **options, &block)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "json"
5
+
6
+ module Gritz
7
+ module Supervisor
8
+ # Small, bounded HTTP probe listener polled by its owning process.
9
+ # @api private
10
+ class AdminServer
11
+ MAX_CLIENTS = 32
12
+ MAX_HEADER_BYTES = 8192
13
+ CLIENT_TIMEOUT = 2.0
14
+ attr_reader :io, :address
15
+
16
+ def initialize(bind:, status:, ready:, metrics:, logger:)
17
+ host, port = bind.match(/\A(\[[^\]]+\]|[^:]+):(\d+)\z/).captures
18
+ host = host.delete_prefix("[").delete_suffix("]")
19
+ @io = TCPServer.new(host, Integer(port))
20
+ @address = "#{host.include?(':') ? "[#{host}]" : host}:#{@io.addr[1]}"
21
+ @status = status
22
+ @ready = ready
23
+ @metrics = metrics
24
+ @logger = logger
25
+ @clients = {}
26
+ end
27
+
28
+ def ios = [@io, *@clients.keys]
29
+
30
+ def poll
31
+ 4.times do
32
+ socket = @io.accept_nonblock(exception: false)
33
+ break if socket == :wait_readable
34
+
35
+ if @clients.size >= MAX_CLIENTS
36
+ socket.close
37
+ else
38
+ @clients[socket] = { input: +"".b, output: nil, deadline: monotonic + CLIENT_TIMEOUT }
39
+ end
40
+ rescue Errno::ECONNABORTED
41
+ next
42
+ end
43
+ @clients.each do |socket, client|
44
+ if monotonic >= client[:deadline]
45
+ drop(socket)
46
+ next
47
+ end
48
+ read_request(socket, client) unless client[:output]
49
+ write_response(socket, client) if client[:output]
50
+ rescue IOError, SystemCallError
51
+ drop(socket)
52
+ end
53
+ end
54
+
55
+ def close
56
+ @clients.each_key(&:close)
57
+ @clients.clear
58
+ @io.close unless @io.closed?
59
+ end
60
+
61
+ private
62
+
63
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+
65
+ def drop(socket)
66
+ @clients.delete(socket)
67
+ socket.close unless socket.closed?
68
+ end
69
+
70
+ def read_request(socket, client)
71
+ chunk = socket.read_nonblock(4096, exception: false)
72
+ return if chunk == :wait_readable
73
+ return drop(socket) if chunk.nil?
74
+
75
+ client[:input] << chunk
76
+ if client[:input].bytesize > MAX_HEADER_BYTES
77
+ respond(client, 431, "Request headers too large\n")
78
+ elsif client[:input].include?("\r\n\r\n")
79
+ method, path, version = client[:input].lines.first.split
80
+ if !%w[HTTP/1.0 HTTP/1.1].include?(version)
81
+ respond(client, 400, "Invalid request\n")
82
+ elsif method != "GET"
83
+ respond(client, 405, "GET required\n")
84
+ else
85
+ route(client, path)
86
+ end
87
+ end
88
+ end
89
+
90
+ def route(client, path)
91
+ case path
92
+ when "/livez" then respond(client, 200, "ok\n")
93
+ when "/readyz"
94
+ ready = @ready.call
95
+ respond(client, ready ? 200 : 503, ready ? "ready\n" : "not ready\n")
96
+ when "/status" then respond(client, 200, "#{JSON.generate(@status.call)}\n", "application/json")
97
+ when "/metrics" then respond(client, 200, @metrics.call, "text/plain; version=0.0.4")
98
+ else respond(client, 404, "Not found\n")
99
+ end
100
+ rescue StandardError => e
101
+ @logger.error("Admin #{path}: #{e.message}")
102
+ respond(client, 500, "Internal error\n")
103
+ end
104
+
105
+ def respond(client, code, body, content_type = "text/plain")
106
+ reasons = { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 405 => "Method Not Allowed",
107
+ 431 => "Request Header Fields Too Large", 500 => "Internal Server Error", 503 => "Service Unavailable" }
108
+ client[:output] =
109
+ "HTTP/1.1 #{code} #{reasons.fetch(code)}\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
110
+ end
111
+
112
+ def write_response(socket, client)
113
+ written = socket.write_nonblock(client[:output], exception: false)
114
+ return if written == :wait_writable
115
+
116
+ client[:output] = client[:output].byteslice(written..)
117
+ drop(socket) if client[:output].empty?
118
+ end
119
+ end
120
+ end
121
+ end