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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +2 -0
- data/lib/gritz/cli.rb +31 -5
- data/lib/gritz/configuration.rb +17 -8
- data/lib/gritz/context.rb +31 -2
- data/lib/gritz/controller.rb +14 -0
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/dispatcher.rb +9 -3
- data/lib/gritz/metrics/aggregator.rb +119 -0
- data/lib/gritz/metrics/recorder.rb +89 -0
- data/lib/gritz/middleware/exception_mapper.rb +1 -3
- data/lib/gritz/middleware/logging.rb +27 -3
- data/lib/gritz/middleware/metrics.rb +29 -0
- data/lib/gritz/middleware/stack.rb +1 -1
- data/lib/gritz/supervisor/admin_server.rb +121 -0
- data/lib/gritz/supervisor/launcher.rb +381 -0
- data/lib/gritz/supervisor/master.rb +220 -29
- data/lib/gritz/supervisor/status_channel.rb +15 -3
- data/lib/gritz/supervisor/worker_handle.rb +4 -3
- data/lib/gritz/testing/cluster.rb +43 -7
- data/lib/gritz/worker/runner.rb +108 -16
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03bd5114b9267c0bc0b5daa80d14433d4941c753321338fc5b83b2281f4d6bf1
|
|
4
|
+
data.tar.gz: 67fabc1d94c5cca35149679a2e582c983a88122a5299638d05c639387ea48fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/gritz/configuration.rb
CHANGED
|
@@ -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, "
|
|
113
|
-
raise ConfigurationError, "
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
data/lib/gritz/controller.rb
CHANGED
|
@@ -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
|
data/lib/gritz/core/version.rb
CHANGED
data/lib/gritz/dispatcher.rb
CHANGED
|
@@ -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?
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|