gritz-core 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 023ead58f761660de79d7168434d85be2f92e583073fab043b18b18fd14035c5
4
- data.tar.gz: f319a9c5f6a190fad09e78667072ee22a5994dfe1f5c2e4f12287f3213865e1a
3
+ metadata.gz: 03bd5114b9267c0bc0b5daa80d14433d4941c753321338fc5b83b2281f4d6bf1
4
+ data.tar.gz: 67fabc1d94c5cca35149679a2e582c983a88122a5299638d05c639387ea48fa4
5
5
  SHA512:
6
- metadata.gz: d531f928a935dd66d9a3459919fff84263309e6821da90332a841170b1bc268e2ccc711394a5873590611883136d44987d0d059794520d1335d788ad37815660
7
- data.tar.gz: '08b50b33dcbb22fdde34c4052f2c15e82cfb1562bc83f450966f98879e4f0d8bc64cf50f8bb3517fae12ebfe4ac4841928f5ab5f5032a96eabd006711f8e44dc'
6
+ metadata.gz: 22ba419be6cd7bf1754eaccc7af3bde09ac0ca9dcac494f5d1932306aa73d595e0861b1c9ff1af9e694195f86d6c19e3103161a721ccd8e3f4c88ae72c93c56d
7
+ data.tar.gz: 1986b1cc2c4a32d0baf218b8268b1ced8399931c1a48379f46071f891c09d4c89cfe489fb707683dcf0d4ed239c708b3bfdd36a156efb0696f14d1764a10eeb6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Replace workers one at a time with `USR1` and recycle them by request count, memory or lifetime.
6
+ - Reload application code with `USR2`; retain the serving master if replacement startup fails, and update the PID file after readiness.
7
+ - Keep admin probes and Prometheus totals available across worker and master replacement. Add application health checks and per-worker status.
8
+ - Emit one structured completion log per RPC with JSON or logfmt output and credential redaction.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - Supervise forked workers with boot and heartbeat timeouts, automatic replacement, graceful shutdown, lifecycle hooks, dynamic worker counts and log reopening.
data/README.md CHANGED
@@ -10,6 +10,8 @@ Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and exe
10
10
 
11
11
  Controller tests can load `gritz/testing/rspec` or `gritz/testing/minitest` and run without a network or the grpc gem. See the [controller examples](https://github.com/gritzrpc/gritz#controllers) and [configuration guide](docs/guides/configuration.md).
12
12
 
13
+ Supervised servers support phased replacement, fresh-interpreter hot reexec, request/memory/lifetime recycling, Admin HTTP probes and process-wide Prometheus totals. The CLI keeps one lifecycle owner across master generations; application controllers and gRPC resources stay in serving processes.
14
+
13
15
  ## Development
14
16
 
15
17
  ```sh
data/lib/gritz/cli.rb CHANGED
@@ -2,16 +2,31 @@
2
2
 
3
3
  require "optparse"
4
4
  require "logger"
5
+ require "rbconfig"
6
+ require "socket"
5
7
 
6
8
  module Gritz
7
9
  # Starts a server, lists routes, or checks configuration and fork safety.
8
10
  # @api public
9
11
  class CLI
10
- def initialize(stdout: $stdout, stderr: $stderr, env: ENV, status_io: nil)
12
+ CHILD_BOOTSTRAP = <<~RUBY
13
+ require "socket"
14
+ fd = ENV.delete("GRITZ_INTERNAL_OWNER_FD")
15
+ exit Gritz::CLI.new(owner_io: UNIXSocket.for_fd(Integer(fd))).run(ARGV)
16
+ RUBY
17
+
18
+ def self.child_command(argv)
19
+ [RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-rgritz/core", "-e", CHILD_BOOTSTRAP, "--", *argv]
20
+ end
21
+
22
+ def initialize(stdout: $stdout, stderr: $stderr, env: ENV, status_io: nil, owner_io: nil, launch: false, launch_command: nil)
11
23
  @stdout = stdout
12
24
  @stderr = stderr
13
25
  @env = env
14
26
  @status_io = status_io
27
+ @owner_channel = Supervisor::StatusChannel.new(owner_io) if owner_io
28
+ @launch = launch
29
+ @launch_command = launch_command
15
30
  end
16
31
 
17
32
  def run(argv)
@@ -39,12 +54,17 @@ module Gritz
39
54
  raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check].include?(command)
40
55
  raise ConfigurationError, "Unexpected argument #{args.first}" unless args.empty?
41
56
 
57
+ logger = Logger.new(@stdout)
58
+ logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
59
+ if command == "start" && @launch && !@owner_channel
60
+ return Supervisor::Launcher.new(command: @launch_command || self.class.child_command(argv), env: @env,
61
+ stdout: @stdout, stderr: @stderr, logger: logger, status_io: @status_io).run
62
+ end
63
+
42
64
  path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
43
65
  guard = ForkGuard.activate(mode: :record) if command != "routes"
44
66
  require "gritz/native" if command != "routes"
45
67
  config = Configuration.load(path: path, env: @env, overrides: overrides)
46
- logger = Logger.new(@stdout)
47
- logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
48
68
  if command == "check"
49
69
  config.preload! if config.preload_app?
50
70
  guard.violations.each { |violation| @stderr.puts(violation.message) }
@@ -61,11 +81,16 @@ module Gritz
61
81
  guard.violations.each { |violation| logger.warn(violation.message) } if config.fork_guard == :warn
62
82
  end
63
83
  @stdout.sync = true if @stdout.respond_to?(:sync=)
64
- return Supervisor::Master.new(config, logger: logger, status_io: @status_io).run if config.workers.positive?
84
+ @owner_channel&.write(type: "configured", pid: Process.pid, workers: config.workers, bind: config.bind,
85
+ admin_bind: config.admin_bind, min_ready_workers: config.min_ready_workers, pid_file: config.pid_file,
86
+ reexec_timeout: config.reexec_timeout, drain_delay: config.drain_delay, shutdown_timeout: config.shutdown_timeout)
87
+ if config.workers.positive?
88
+ return Supervisor::Master.new(config, logger: logger, status_io: @status_io, owner_channel: @owner_channel).run
89
+ end
65
90
 
66
91
  ForkGuard.deactivate
67
92
  config.preload! if config.preload_app?
68
- return Worker::Runner.new(index: 0, config: config, logger: logger).run
93
+ return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel).run
69
94
  else
70
95
  config.preload! if config.preload_app?
71
96
  router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
@@ -81,6 +106,7 @@ module Gritz
81
106
  1
82
107
  ensure
83
108
  ForkGuard.deactivate if guard
109
+ @owner_channel&.close
84
110
  end
85
111
  end
86
112
  end
@@ -18,7 +18,8 @@ module Gritz
18
18
  keepalive_permit_without_calls: true,
19
19
  max_receive_message_size: 4 * 1024 * 1024,
20
20
  max_send_message_size: 4 * 1024 * 1024, max_metadata_size: 8192,
21
- metrics_backend: :pipe, log_format: :json, worker_recycle: {}, tls: {}
21
+ metrics_backend: :pipe, log_format: :json, log_redact: [], worker_recycle: {}, tls: {},
22
+ pid_file: "", reexec_timeout: 60.0
22
23
  }.freeze
23
24
  ENUMS = {
24
25
  transport: %i[native async], listener_strategy: %i[reuseport inherited_fd port_per_worker],
@@ -63,7 +64,7 @@ module Gritz
63
64
  when Integer then Integer(value, 10)
64
65
  when Float then Float(value)
65
66
  when Symbol then value.to_sym
66
- when Hash then JSON.parse(value, symbolize_names: true)
67
+ when Hash, Array then JSON.parse(value, symbolize_names: true)
67
68
  when true, false
68
69
  return true if %w[true 1].include?(value)
69
70
  return false if %w[false 0].include?(value)
@@ -86,7 +87,7 @@ module Gritz
86
87
  end
87
88
  raise ConfigurationError, "Invalid #{name}: #{value.inspect}" unless valid
88
89
  end
89
- %i[shutdown_timeout worker_boot_timeout worker_timeout status_interval].each do |name|
90
+ %i[shutdown_timeout worker_boot_timeout worker_timeout status_interval reexec_timeout].each do |name|
90
91
  raise ConfigurationError, "#{name} must be positive" unless public_send(name).positive?
91
92
  end
92
93
  ENUMS.each do |name, allowed|
@@ -98,6 +99,12 @@ module Gritz
98
99
  raise ConfigurationError, "controllers must be an Array of classes" unless controllers.is_a?(Array) && controllers.all?(Class)
99
100
  raise ConfigurationError, "middleware must be a Stack" unless middleware.is_a?(Middleware::Stack)
100
101
  raise ConfigurationError, "preload_app must be boolean" unless [true, false].include?(preload_app)
102
+ unless log_redact.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
103
+ raise ConfigurationError, "log_redact must contain metadata names"
104
+ end
105
+ unless health_checks.all? { |name, check| (name.is_a?(String) || name.is_a?(Symbol)) && check.respond_to?(:call) }
106
+ raise ConfigurationError, "health_checks must contain named callbacks"
107
+ end
101
108
 
102
109
  self
103
110
  end
@@ -109,11 +116,13 @@ module Gritz
109
116
  validate!
110
117
  raise ConfigurationError, "This release supports transport :native" unless transport == :native
111
118
  raise ConfigurationError, "This release supports listener_strategy :reuseport" unless listener_strategy == :reuseport
112
- raise ConfigurationError, "TLS is planned for v0.3" unless tls.empty?
113
- raise ConfigurationError, "worker_recycle is planned for v0.3" unless worker_recycle.empty?
114
- raise ConfigurationError, "Health checks are planned for v0.3" unless health_checks.empty?
115
- raise ConfigurationError, "This release supports log_format :json" unless log_format == :json
116
- raise ConfigurationError, "This release reserves metrics_backend :pipe; metrics export is planned for v0.3" unless metrics_backend == :pipe
119
+ raise ConfigurationError, "This release supports metrics_backend :pipe" unless metrics_backend == :pipe
120
+ raise ConfigurationError, "This release supports phased_restart_surge 1" unless phased_restart_surge == 1
121
+
122
+ unless worker_recycle.empty?
123
+ raise ConfigurationError, "worker_recycle requires workers > 0" unless workers.positive?
124
+ raise ConfigurationError, "worker_recycle requires a fixed bind port" if bind.end_with?(":0")
125
+ end
117
126
  raise ConfigurationError, "grpc_fork_support requires workers > 0" if workers.zero? && fork_mode != :clean
118
127
  raise ConfigurationError, "at least one controller must be registered" if controllers.empty?
119
128
 
data/lib/gritz/context.rb CHANGED
@@ -4,13 +4,19 @@ module Gritz
4
4
  # Request state inherited by child fibers and threads using Ruby Fiber storage.
5
5
  # @api public
6
6
  class Context
7
- attr_reader :call, :logger, :store
7
+ attr_reader :call, :logger, :store, :metrics, :worker, :log_format, :log_redact,
8
+ :requests_count, :responses_count, :bytes_in, :bytes_out
8
9
  attr_accessor :request_id, :trace_context
9
10
 
10
- def initialize(call:, logger:)
11
+ def initialize(call:, logger:, metrics: nil, worker: nil, log_format: :json, log_redact: [])
11
12
  @call = call
12
13
  @logger = logger
13
14
  @store = {}
15
+ @metrics = metrics
16
+ @worker = worker
17
+ @log_format = log_format
18
+ @log_redact = log_redact.map(&:to_s)
19
+ @requests_count = @responses_count = @bytes_in = @bytes_out = 0
14
20
  end
15
21
 
16
22
  def self.current = Fiber[:gritz_context]
@@ -46,5 +52,28 @@ module Gritz
46
52
 
47
53
  message
48
54
  end
55
+
56
+ # @api private
57
+ def record_received(message)
58
+ return unless message
59
+
60
+ size = encoded_size(message)
61
+ @requests_count += 1
62
+ @bytes_in += size
63
+ end
64
+
65
+ # @api private
66
+ def record_sent(message)
67
+ size = encoded_size(message)
68
+ @responses_count += 1
69
+ @bytes_out += size
70
+ end
71
+
72
+ private
73
+
74
+ def encoded_size(message)
75
+ # ponytail: byte accounting encodes again; instrument native serialization if the Phase 6 overhead budget is exceeded.
76
+ message.class.respond_to?(:encode) ? message.class.encode(message).bytesize : message.to_s.bytesize
77
+ end
49
78
  end
50
79
  end
@@ -96,6 +96,10 @@ module Gritz
96
96
  def initialize(call, context)
97
97
  @call = call
98
98
  @context = context
99
+ unless context.method.client_streaming?
100
+ @message = @call.read
101
+ @context.record_received(@message)
102
+ end
99
103
  end
100
104
 
101
105
  def message
@@ -104,14 +108,23 @@ module Gritz
104
108
  return @message if defined?(@message)
105
109
 
106
110
  @message = @call.read
111
+ @context.record_received(@message)
112
+ @message
107
113
  end
108
114
 
109
115
  def each_message
110
116
  return enum_for(__method__) unless block_given?
111
117
 
118
+ unless @context.method.client_streaming?
119
+ value = message
120
+ yield value if value
121
+ return
122
+ end
123
+
112
124
  @call.each_message do |message|
113
125
  @context.check_deadline!
114
126
  @context.check_cancelled!
127
+ @context.record_received(message)
115
128
  yield message
116
129
  end
117
130
  end
@@ -133,6 +146,7 @@ module Gritz
133
146
  @context.check_cancelled!
134
147
  @context.validate_response!(message)
135
148
  @call.write(message)
149
+ @context.record_sent(message)
136
150
  end
137
151
  end
138
152
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Core
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -8,9 +8,11 @@ module Gritz
8
8
  class Dispatcher
9
9
  attr_reader :router
10
10
 
11
- def initialize(router:, middleware: Middleware::Stack.default, logger: Logger.new($stdout))
11
+ def initialize(router:, middleware: Middleware::Stack.default, logger: Logger.new($stdout),
12
+ metrics: nil, worker: nil, log_format: :json, log_redact: [])
12
13
  @router = router
13
14
  @logger = logger
15
+ @context_options = { metrics: metrics, worker: worker, log_format: log_format, log_redact: log_redact }
14
16
  endpoint = lambda do |context|
15
17
  context.check_deadline!
16
18
  context.check_cancelled!
@@ -18,13 +20,17 @@ module Gritz
18
20
  result = descriptor.controller.new(context:).process(descriptor.action)
19
21
  context.check_deadline!
20
22
  context.check_cancelled!
21
- descriptor.server_streaming? ? result : context.validate_response!(result)
23
+ unless descriptor.server_streaming?
24
+ context.validate_response!(result)
25
+ context.record_sent(result)
26
+ end
27
+ result
22
28
  end
23
29
  @app = middleware ? middleware.build(endpoint) : endpoint
24
30
  end
25
31
 
26
32
  def call(call)
27
- @app.call(Context.new(call:, logger: @logger))
33
+ @app.call(Context.new(call:, logger: @logger, **@context_options))
28
34
  end
29
35
  end
30
36
  end
@@ -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,89 @@
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
+ # The caller retains this detached delta until its status channel accepts the row.
60
+ def take_delta(max_bytes: Supervisor::StatusChannel::MAX_LINE_BYTES - 4096)
61
+ raise ArgumentError, "Metric packet budget must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
62
+
63
+ @lock.synchronize do
64
+ return nil if @rpc.empty? && @rejected_delta.zero?
65
+
66
+ delta = { rpc: [], rejected: @rejected_delta }
67
+ used_bytes = JSON.generate(delta).bytesize
68
+ raise ArgumentError, "Metric packet budget is too small" if used_bytes > max_bytes
69
+
70
+ selected = []
71
+ @rpc.each do |key, row|
72
+ row_bytes = JSON.generate(row).bytesize + (selected.empty? ? 0 : 1)
73
+ if used_bytes + row_bytes > max_bytes
74
+ raise ArgumentError, "Metric row exceeds the packet budget" if selected.empty?
75
+
76
+ break
77
+ end
78
+ delta[:rpc] << row
79
+ selected << key
80
+ used_bytes += row_bytes
81
+ end
82
+ selected.each { |key| @rpc.delete(key) }
83
+ @rejected_delta = 0
84
+ delta
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -1,7 +1,6 @@
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
@@ -32,8 +31,7 @@ module Gritz
32
31
  end
33
32
 
34
33
  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))
34
+ context.store[:gritz_error] = { error_id: error_id, error: e.class.name, message: e.message, backtrace: e.backtrace }
37
35
  raise Errors::Internal.new(@expose_errors ? e.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
38
36
  end
39
37
  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)