tonic-rails 0.1.0-aarch64-linux-gnu → 0.3.0-aarch64-linux-gnu

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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/README.md +204 -13
  4. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  5. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  6. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  7. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  8. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  9. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  10. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  11. data/lib/puma/plugin/tonic_rails.rb +116 -0
  12. data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
  13. data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
  14. data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
  15. data/lib/tonic/rails/base.rb +169 -23
  16. data/lib/tonic/rails/configuration.rb +27 -4
  17. data/lib/tonic/rails/health.rb +105 -0
  18. data/lib/tonic/rails/instrumentation.rb +78 -0
  19. data/lib/tonic/rails/metadata.rb +50 -0
  20. data/lib/tonic/rails/peer.rb +60 -0
  21. data/lib/tonic/rails/railtie.rb +2 -2
  22. data/lib/tonic/rails/router.rb +1 -0
  23. data/lib/tonic/rails/rpc_error.rb +73 -6
  24. data/lib/tonic/rails/server.rb +82 -12
  25. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  26. data/lib/tonic/rails/testing/errors.rb +47 -0
  27. data/lib/tonic/rails/testing/minitest.rb +44 -0
  28. data/lib/tonic/rails/testing/rspec.rb +53 -0
  29. data/lib/tonic/rails/testing.rb +35 -4
  30. data/lib/tonic/rails/version.rb +1 -1
  31. data/lib/tonic-rails.rb +4 -0
  32. metadata +16 -1
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma/plugin"
4
+
5
+ # Runs the tonic-rails gRPC server alongside Puma:
6
+ #
7
+ # # config/puma.rb
8
+ # plugin :tonic_rails
9
+ #
10
+ # The gRPC server runs as one child process of the Puma master, in single
11
+ # and cluster mode alike, so it has its own GVL and never competes with web
12
+ # workers. It starts once Puma has booted, drains gracefully when Puma
13
+ # stops or restarts, stops itself if Puma goes away, and if it dies
14
+ # unexpectedly Puma shuts down too, so a supervisor can restart both.
15
+ Puma::Plugin.create do
16
+ def start(launcher)
17
+ @launcher = launcher
18
+ unless Process.respond_to?(:fork)
19
+ log "tonic-rails: the Puma plugin needs fork(), which this platform lacks; " \
20
+ "run `bin/rails tonic_rails:serve` as a separate process instead"
21
+ return
22
+ end
23
+
24
+ @puma_pid = Process.pid
25
+ @grpc_pid = nil
26
+
27
+ events = launcher.events
28
+ if events.respond_to?(:after_booted) # Puma 7+
29
+ events.after_booted { start_grpc }
30
+ events.after_stopped { stop_grpc }
31
+ events.before_restart { stop_grpc }
32
+ else
33
+ events.on_booted { start_grpc }
34
+ events.on_stopped { stop_grpc }
35
+ events.on_restart { stop_grpc }
36
+ end
37
+
38
+ in_background { monitor_grpc }
39
+ end
40
+
41
+ private
42
+
43
+ def start_grpc
44
+ @grpc_pid = fork { run_grpc_server }
45
+ log "tonic-rails: gRPC server started (pid #{@grpc_pid})"
46
+ end
47
+
48
+ def run_grpc_server
49
+ # The web listeners belong to Puma; don't hold them open in this process.
50
+ @launcher.binder.ios.each { |io| io.close rescue nil } # rubocop:disable Style/RescueModifier
51
+ load_rails_app
52
+ Thread.new { stop_when_puma_exits }
53
+ Tonic::Rails.server.start
54
+ end
55
+
56
+ # `rails server` and preload_app! boot the app before forking; plain
57
+ # `puma` in cluster mode does not, so boot it here.
58
+ def load_rails_app
59
+ return if defined?(Rails.application) && Rails.application&.initialized?
60
+
61
+ require File.expand_path("config/environment", Dir.pwd)
62
+ end
63
+
64
+ # Puma fires its stop hooks from its TERM trap handler, so this must
65
+ # not block indefinitely: allow the server's drain timeout plus a margin,
66
+ # then kill it.
67
+ def stop_grpc
68
+ pid = @grpc_pid
69
+ return unless pid
70
+
71
+ @grpc_pid = nil
72
+ log "tonic-rails: stopping gRPC server (pid #{pid})"
73
+ Process.kill(:TERM, pid)
74
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + stop_timeout
75
+ until Process.waitpid(pid, Process::WNOHANG)
76
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
77
+ log "tonic-rails: gRPC server (pid #{pid}) did not stop in #{stop_timeout}s; killing it"
78
+ Process.kill(:KILL, pid)
79
+ Process.wait(pid)
80
+ break
81
+ end
82
+ sleep 0.05
83
+ end
84
+ rescue Errno::ESRCH, Errno::ECHILD
85
+ nil
86
+ end
87
+
88
+ def stop_timeout
89
+ drain = defined?(Tonic::Rails) ? Tonic::Rails.configuration.drain_timeout.to_f : 10.0
90
+ drain + 5
91
+ end
92
+
93
+ def monitor_grpc
94
+ loop do
95
+ sleep 1
96
+ pid = @grpc_pid
97
+ next unless pid && Process.waitpid(pid, Process::WNOHANG)
98
+
99
+ @grpc_pid = nil
100
+ log "tonic-rails: gRPC server (pid #{pid}) exited unexpectedly; stopping Puma"
101
+ Process.kill(:TERM, @puma_pid)
102
+ break
103
+ rescue Errno::ECHILD
104
+ nil
105
+ end
106
+ end
107
+
108
+ def stop_when_puma_exits
109
+ sleep 1 while Process.ppid == @puma_pid
110
+ Tonic::Rails.server.stop
111
+ end
112
+
113
+ def log(message)
114
+ @launcher.log_writer.log(message)
115
+ end
116
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/callbacks"
4
+ require "active_support/rescuable"
3
5
  require "active_support/core_ext/class/attribute"
4
6
  require "active_support/core_ext/hash/keys"
5
7
  require "active_support/core_ext/string/inflections"
@@ -25,8 +27,17 @@ module Tonic
25
27
  # is set (fastest), or string-keyed Hashes by default. Handlers may
26
28
  # return either a protobuf message or a Hash; streaming handlers
27
29
  # return any Enumerable of them.
30
+ #
31
+ # Like controllers, handlers get callbacks and exception mapping:
32
+ #
33
+ # before_rpc :authenticate, except: :Health
34
+ # rescue_from ActiveRecord::RecordNotFound, code: :not_found
35
+ # rescue_from Pundit::NotAuthorizedError, with: :forbidden
28
36
  module Base
29
37
  def self.included(base)
38
+ base.include(ActiveSupport::Callbacks)
39
+ base.include(ActiveSupport::Rescuable)
40
+ base.define_callbacks(:rpc)
30
41
  base.extend(ClassMethods)
31
42
  base.class_attribute :grpc_service_name, instance_writer: false
32
43
  base.class_attribute :grpc_rpcs, instance_writer: false, default: {}
@@ -34,6 +45,20 @@ module Tonic
34
45
  end
35
46
 
36
47
  module ClassMethods
48
+ private
49
+
50
+ def add_rpc_callback(kind, methods, only, except, block)
51
+ only &&= rpc_name_matcher(only)
52
+ except &&= rpc_name_matcher(except)
53
+ condition = lambda do |handler|
54
+ name = handler.current_rpc.underscore
55
+ (only.nil? || only.include?(name)) && (except.nil? || !except.include?(name))
56
+ end
57
+ (methods + [block].compact).each { |callback| set_callback(:rpc, kind, callback, if: condition) }
58
+ end
59
+
60
+ public
61
+
37
62
  # @param name [String] fully-qualified service name, e.g. "shop.ProductService"
38
63
  def service(name)
39
64
  self.grpc_service_name = name.to_s
@@ -97,6 +122,51 @@ module Tonic
97
122
  end
98
123
  end
99
124
 
125
+ # Run a method or block before each RPC. Raising (e.g. an RpcError)
126
+ # stops the call. The decoded `request` is available.
127
+ # @param only [Symbol, String, Array] RPC names to apply to
128
+ # @param except [Symbol, String, Array] RPC names to skip
129
+ def before_rpc(*methods, only: nil, except: nil, &block)
130
+ add_rpc_callback(:before, methods, only, except, block)
131
+ end
132
+
133
+ # Run after each RPC that completes without raising (for streaming
134
+ # responses, after the last message is sent).
135
+ def after_rpc(*methods, only: nil, except: nil, &block)
136
+ add_rpc_callback(:after, methods, only, except, block)
137
+ end
138
+
139
+ # Wrap each RPC; the method or block must yield to run it.
140
+ def around_rpc(*methods, only: nil, except: nil, &block)
141
+ add_rpc_callback(:around, methods, only, except, block)
142
+ end
143
+
144
+ # Map exceptions raised during an RPC (including callbacks) to a
145
+ # gRPC status. Same as ActiveSupport::Rescuable, plus a `code:`
146
+ # shorthand:
147
+ #
148
+ # rescue_from ActiveRecord::RecordNotFound, code: :not_found
149
+ # rescue_from Stale, code: :aborted, message: "please retry"
150
+ # rescue_from Denied, with: :deny # def deny(e) = raise RpcError...
151
+ #
152
+ # With `code:`, the client receives `message` (a String, or a Proc
153
+ # given the exception), defaulting to the exception's own message.
154
+ # Handlers given via `with:` or a block must raise an RpcError.
155
+ def rescue_from(*klasses, with: nil, code: nil, message: nil, &)
156
+ return super(*klasses, with: with, &) unless code
157
+
158
+ RpcError.normalize_code(code) # fail at definition time on typos
159
+ super(*klasses) do |exception|
160
+ text = message.respond_to?(:call) ? message.call(exception) : (message || exception.message)
161
+ raise RpcError.new(text, code: code)
162
+ end
163
+ end
164
+
165
+ # @api private
166
+ def rpc_name_matcher(names)
167
+ Array(names).map { |name| name.to_s.underscore }
168
+ end
169
+
100
170
  # @api private Called by Server#register.
101
171
  def register_on(router)
102
172
  raise NotConfiguredError, "#{self} has no service name; call `service` or `rpc_service`" unless grpc_service_name
@@ -106,11 +176,38 @@ module Tonic
106
176
  end
107
177
  end
108
178
 
179
+ # The RPC being handled, as named in the .proto (e.g. "GetItem").
180
+ # @return [String]
181
+ attr_reader :current_rpc
182
+
183
+ # The client: address, and certificate with mutual TLS.
184
+ # @return [Tonic::Rails::Peer]
185
+ attr_reader :peer
186
+
187
+ # The decoded request: a message (or Hash), or for client-streaming
188
+ # RPCs an Enumerator of them. Also passed to the handler method.
189
+ attr_reader :request
190
+
109
191
  # gRPC request metadata (lower-case header names). Binary (`-bin`)
110
192
  # values are base64-encoded, as sent on the wire.
111
193
  # @return [Hash{String=>String}]
112
194
  attr_reader :grpc_metadata
113
195
 
196
+ # Response headers (initial metadata) for this call. For streaming
197
+ # responses, set them before the first message; for unary RPCs,
198
+ # anywhere in the handler. `-bin` names take binary values.
199
+ # @return [Hash]
200
+ def response_metadata
201
+ @response_metadata ||= {}
202
+ end
203
+
204
+ # Trailing metadata, sent with the final status, whether the call
205
+ # succeeds or fails.
206
+ # @return [Hash]
207
+ def trailing_metadata
208
+ @trailing_metadata ||= {}
209
+ end
210
+
114
211
  # Milliseconds left before the client's deadline, or nil if the
115
212
  # client did not set one.
116
213
  # @return [Integer, nil]
@@ -137,44 +234,74 @@ module Tonic
137
234
  # (`#push`) for server-streaming RPCs.
138
235
  # @return [String, nil] the encoded unary response, or nil when
139
236
  # messages were streamed through `sender`
140
- def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil)
237
+ def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil, peer: nil)
141
238
  spec = self.class.grpc_rpcs[rpc_name.to_s]
142
239
  raise RpcError.new("method not implemented", code: :unimplemented) unless spec
143
240
  raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
144
241
 
242
+ @current_rpc = rpc_name.to_s
243
+ @peer = peer || Peer.new
145
244
  @grpc_metadata = metadata
245
+ @grpc_sender = sender
246
+ @grpc_headers_sent = false
146
247
  @grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
147
248
 
148
- request = if spec[:request_stream]
149
- Enumerator.new do |y|
150
- while (message = read_request(reader, spec))
151
- y << message
152
- end
153
- end
249
+ @request = build_request(reader, spec)
250
+ response = nil
251
+ run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
252
+ response
253
+ rescue RpcError
254
+ raise
255
+ rescue StandardError => e
256
+ rescue_exception(e)
257
+ ensure
258
+ attach_trailing_metadata
259
+ end
260
+
261
+ private
262
+
263
+ # rescue_from handlers must raise; anything they don't handle (or a
264
+ # handler that returns normally) becomes a logged INTERNAL error.
265
+ def rescue_exception(exception)
266
+ if rescue_with_handler(exception)
267
+ Tonic::Rails.log_exception(
268
+ TypeError.new("rescue_from handler for #{exception.class} returned instead of raising an RpcError"),
269
+ "#{self.class}##{current_rpc}",
270
+ )
154
271
  else
155
- read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
272
+ Tonic::Rails.log_exception(exception, "#{self.class}##{current_rpc}")
156
273
  end
274
+ raise RpcError.new("internal error", code: :internal)
275
+ end
157
276
 
158
- result = public_send(spec[:handler], request)
277
+ def build_request(reader, spec)
278
+ unless spec[:request_stream]
279
+ return read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
280
+ end
159
281
 
160
- if spec[:response_stream]
161
- unless result.respond_to?(:each)
162
- raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
282
+ Enumerator.new do |y|
283
+ while (message = read_request(reader, spec))
284
+ y << message
163
285
  end
164
-
165
- result.each { |item| push_response(sender, encode_response(item, spec)) }
166
- nil
167
- else
168
- encode_response(result, spec)
169
286
  end
170
- rescue RpcError
171
- raise
172
- rescue StandardError => e
173
- Tonic::Rails.log_exception(e, "#{self.class}##{rpc_name}")
174
- raise RpcError.new("internal error", code: :internal)
175
287
  end
176
288
 
177
- private
289
+ # Unary: the encoded response. Streaming: nil, after pushing each
290
+ # message through the sender.
291
+ def respond(result, spec, sender)
292
+ unless spec[:response_stream]
293
+ encoded = encode_response(result, spec)
294
+ send_response_headers
295
+ return encoded
296
+ end
297
+
298
+ unless result.respond_to?(:each)
299
+ raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
300
+ end
301
+
302
+ result.each { |item| push_response(sender, encode_response(item, spec)) }
303
+ nil
304
+ end
178
305
 
179
306
  def read_request(reader, spec)
180
307
  bytes = reader.next
@@ -205,11 +332,30 @@ module Tonic
205
332
  end
206
333
 
207
334
  def push_response(sender, bytes)
335
+ send_response_headers
208
336
  sender.push(bytes)
209
337
  rescue IOError
210
338
  # The client went away; nothing left to deliver to.
211
339
  raise RpcError.new("client cancelled the stream", code: :cancelled)
212
340
  end
341
+
342
+ def send_response_headers
343
+ return if @grpc_headers_sent
344
+
345
+ @grpc_headers_sent = true
346
+ return if @response_metadata.nil? || @response_metadata.empty? || @grpc_sender.nil?
347
+
348
+ @grpc_sender.send_headers(Metadata.encode(@response_metadata))
349
+ end
350
+
351
+ # Runs in `ensure`, so it must never raise over the call's outcome.
352
+ def attach_trailing_metadata
353
+ return if @trailing_metadata.nil? || @trailing_metadata.empty? || @grpc_sender.nil?
354
+
355
+ @grpc_sender.add_trailers(Metadata.encode(@trailing_metadata))
356
+ rescue StandardError => e
357
+ Tonic::Rails.log_exception(e, "#{self.class} trailing metadata")
358
+ end
213
359
  end
214
360
  end
215
361
  end
@@ -47,7 +47,8 @@ module Tonic
47
47
  # Concurrent HTTP/2 streams per connection. 0 = unlimited. Default 32.
48
48
  attr_accessor :concurrency_limit_per_connection
49
49
 
50
- # Log every request, not just errors, to stderr. Default false.
50
+ # Log one line per RPC (path, status, duration) through `logger`.
51
+ # Unexpected exceptions are always logged. Default false.
51
52
  attr_accessor :log_requests
52
53
 
53
54
  # Callables wrapping every RPC, outermost first:
@@ -68,6 +69,17 @@ module Tonic
68
69
  # HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
69
70
  attr_accessor :keepalive_interval, :keepalive_timeout
70
71
 
72
+ # Callable checked every `health_check_interval` seconds (inside the
73
+ # Rails executor). While it returns false or raises, grpc.health.v1
74
+ # reports NOT_SERVING so readiness probes and load balancers route
75
+ # around this instance. Default nil (always SERVING while up).
76
+ #
77
+ # config.health_check = -> { ActiveRecord::Base.connection.active? }
78
+ attr_accessor :health_check
79
+
80
+ # Seconds between health checks. Default 5.
81
+ attr_accessor :health_check_interval
82
+
71
83
  # Look handler classes up again on each request so code reloading
72
84
  # works. The Railtie enables this when Rails reloading is on.
73
85
  attr_accessor :reload_handlers
@@ -93,6 +105,8 @@ module Tonic
93
105
  @keepalive_interval = 15
94
106
  @keepalive_timeout = 5
95
107
  @reload_handlers = false
108
+ @health_check = nil
109
+ @health_check_interval = 5
96
110
  end
97
111
 
98
112
  def tls?
@@ -112,15 +126,24 @@ module Tonic
112
126
  raise ArgumentError, "max_message_size must be at most 4 GiB" if max_message_size > 0xFFFF_FFFF
113
127
  raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if tls_cert_path.nil? != tls_key_path.nil?
114
128
  raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
115
- unless interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
116
- raise ArgumentError, "interceptors must be an Array of callables"
117
- end
129
+
130
+ validate_callables!
118
131
 
119
132
  self
120
133
  end
121
134
 
122
135
  private
123
136
 
137
+ def validate_callables!
138
+ raise ArgumentError, "health_check must respond to #call" if health_check && !health_check.respond_to?(:call)
139
+ unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
140
+ raise ArgumentError, "health_check_interval must be a positive number, got #{health_check_interval.inspect}"
141
+ end
142
+ return if interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
143
+
144
+ raise ArgumentError, "interceptors must be an Array of callables"
145
+ end
146
+
124
147
  def positive_integer!(name)
125
148
  value = public_send(name)
126
149
  return if value.is_a?(Integer) && value.positive?
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # What grpc.health.v1 reports, per service ("" = the whole server).
6
+ #
7
+ # A service reports SERVING only while the app hasn't marked it
8
+ # NOT_SERVING (Server#not_serving!) *and* the optional
9
+ # `config.health_check` passes. Neither overrides the other.
10
+ # @api private
11
+ class Health
12
+ def initialize(configuration, router)
13
+ @configuration = configuration
14
+ @router = router
15
+ @marks = {}
16
+ @check_passing = true
17
+ @native = nil
18
+ @checker = nil
19
+ end
20
+
21
+ def mark(services, serving)
22
+ names = services.empty? ? names_to_report : services.map(&:to_s)
23
+ names.each { |name| @marks[name] = serving }
24
+ publish(names)
25
+ end
26
+
27
+ # Start reporting through a running native server.
28
+ def attach(native)
29
+ @native = native
30
+ @check_passing = true
31
+ publish(names_to_report)
32
+ start_checker
33
+ end
34
+
35
+ # Stop the checker before shutdown flips everything NOT_SERVING, so
36
+ # a passing check can't flip it back.
37
+ def detach
38
+ stop_checker
39
+ @native = nil
40
+ end
41
+
42
+ private
43
+
44
+ def names_to_report
45
+ ["", *@router.service_names, *@marks.keys].uniq
46
+ end
47
+
48
+ def publish(names)
49
+ native = @native
50
+ return unless native
51
+
52
+ names.each { |name| native.set_health(name, @check_passing && @marks.fetch(name, true)) }
53
+ end
54
+
55
+ def start_checker
56
+ check = @configuration.health_check
57
+ return unless check
58
+
59
+ @wakeup = Thread::Queue.new
60
+ @checker = Thread.new do
61
+ Thread.current.name = "tonic-rails-health"
62
+ loop do
63
+ passing = run_check(check)
64
+ changed(passing) if passing != @check_passing
65
+ break if @wakeup.pop(timeout: @configuration.health_check_interval)
66
+ end
67
+ end
68
+ end
69
+
70
+ def stop_checker
71
+ return unless @checker
72
+
73
+ @wakeup << :stop
74
+ @checker.join(@configuration.drain_timeout.to_f + 5)
75
+ @checker = nil
76
+ end
77
+
78
+ def changed(passing)
79
+ @check_passing = passing
80
+ publish(names_to_report)
81
+ logger = @configuration.logger
82
+ return unless logger
83
+
84
+ if passing
85
+ logger.info("tonic-rails: health check passing; SERVING")
86
+ else
87
+ logger.warn("tonic-rails: health check failing; NOT_SERVING")
88
+ end
89
+ end
90
+
91
+ # Runs inside the Rails executor so database connections are returned.
92
+ def run_check(check)
93
+ result = if defined?(::Rails.application) && ::Rails.application
94
+ ::Rails.application.executor.wrap { check.call }
95
+ else
96
+ check.call
97
+ end
98
+ result ? true : false
99
+ rescue StandardError => e
100
+ Tonic::Rails.log_exception(e, "health check")
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/notifications"
5
+
6
+ module Tonic
7
+ module Rails
8
+ # Every RPC is wrapped in an ActiveSupport::Notifications event, so APM
9
+ # agents, OpenTelemetry and your own subscribers can observe it:
10
+ #
11
+ # ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
12
+ # event.payload[:path] # "/demo.v1.ItemService/GetItem"
13
+ # event.payload[:service] # "demo.v1.ItemService"
14
+ # event.payload[:rpc] # "GetItem"
15
+ # event.payload[:metadata] # request metadata Hash
16
+ # event.payload[:code] # gRPC status code, 0 = OK
17
+ # event.payload[:status] # :ok, :not_found, :internal, ...
18
+ # event.duration # milliseconds
19
+ # end
20
+ #
21
+ # When a call fails, :exception_object holds the RpcError. If a handler
22
+ # raised something unexpected, that RpcError's #cause is the original
23
+ # exception.
24
+ module Instrumentation
25
+ EVENT = "process_rpc.tonic_rails"
26
+
27
+ module_function
28
+
29
+ # @api private
30
+ def instrument(path, metadata)
31
+ # Without subscribers, skip building the payload: this runs on
32
+ # every RPC.
33
+ return yield unless ActiveSupport::Notifications.notifier.listening?(EVENT)
34
+
35
+ service, rpc = path.delete_prefix("/").split("/", 2)
36
+ payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil }
37
+ ActiveSupport::Notifications.instrument(EVENT, payload) do
38
+ result = yield
39
+ payload[:code] = 0
40
+ payload[:status] = :ok
41
+ result
42
+ rescue RpcError => e
43
+ payload[:code] = e.code
44
+ payload[:status] = e.code_name
45
+ raise
46
+ rescue StandardError
47
+ payload[:code] = RpcError::CODES[:internal]
48
+ payload[:status] = :internal
49
+ raise
50
+ end
51
+ end
52
+
53
+ # Logs one line per RPC through the configured logger, e.g.
54
+ # gRPC /demo.v1.ItemService/GetItem OK in 1.4ms
55
+ # gRPC /demo.v1.ItemService/GetItem NOT_FOUND in 0.9ms: item 7 not found
56
+ # Enabled by `config.log_requests = true`.
57
+ class RequestLogger
58
+ def initialize(logger)
59
+ @logger = logger
60
+ end
61
+
62
+ def call(event)
63
+ payload = event.payload
64
+ status = (payload[:status] || :unknown).to_s.upcase
65
+ line = format("gRPC %<path>s %<status>s in %<ms>.1fms", path: payload[:path], status: status, ms: event.duration)
66
+ error = payload[:exception_object]
67
+ line = "#{line}: #{error.message}" if error && payload[:code] != 0
68
+ @logger.info(line)
69
+ end
70
+
71
+ # @return [Object] the subscription, for #unsubscribe
72
+ def subscribe
73
+ ActiveSupport::Notifications.subscribe(EVENT, self)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Converts handler-supplied gRPC metadata into header pairs.
6
+ #
7
+ # Names are lower-cased and must be valid gRPC metadata names; names
8
+ # ending in "-bin" carry binary values, which are base64-encoded as the
9
+ # gRPC spec requires. Other values must be printable ASCII.
10
+ module Metadata
11
+ NAME = /\A[0-9a-z_.-]+\z/
12
+ PRINTABLE = /\A[\x20-\x7e]*\z/
13
+ RESERVED = %w[content-type te user-agent content-length connection transfer-encoding].freeze
14
+
15
+ module_function
16
+
17
+ # @param metadata [Hash{String,Symbol=>String,Array<String>}]
18
+ # @return [Hash{String=>String}]
19
+ # @raise [ArgumentError] for invalid or reserved names and values
20
+ def encode(metadata)
21
+ metadata.each_with_object({}) do |(name, value), out|
22
+ name = name.to_s.downcase
23
+ validate_name!(name)
24
+ out[name] = encode_value(name, value)
25
+ end
26
+ end
27
+
28
+ def validate_name!(name)
29
+ raise ArgumentError, "invalid gRPC metadata name #{name.inspect}" unless NAME.match?(name)
30
+ return unless RESERVED.include?(name) || (name.start_with?("grpc-") && name != "grpc-status-details-bin")
31
+
32
+ raise ArgumentError, "gRPC metadata name #{name.inspect} is reserved"
33
+ end
34
+
35
+ def encode_value(name, value)
36
+ values = Array(value).map(&:to_s)
37
+ if name.end_with?("-bin")
38
+ values.map { |v| [v].pack("m0") }.join(",")
39
+ else
40
+ values.each do |v|
41
+ next if PRINTABLE.match?(v)
42
+
43
+ raise ArgumentError, "gRPC metadata #{name} must be printable ASCII; use a -bin name for binary"
44
+ end
45
+ values.join(",")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end