universal_renderer 0.5.2 → 0.7.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.
@@ -8,7 +8,8 @@ module UniversalRenderer
8
8
  http_client,
9
9
  http_post_request,
10
10
  response,
11
- stream_uri
11
+ stream_uri,
12
+ &on_failure
12
13
  )
13
14
  success = false
14
15
  chunks_written = false
@@ -28,11 +29,22 @@ module UniversalRenderer
28
29
  end
29
30
  success = true
30
31
  else
32
+ error =
33
+ StandardError.new(
34
+ "SSR stream server responded with " \
35
+ "#{node_res.code} #{node_res.message}"
36
+ )
31
37
  UniversalRenderer.log do |log|
32
38
  log.error(
33
39
  "SSR stream server at #{stream_uri} responded with #{node_res.code} #{node_res.message}."
34
40
  )
35
41
  end
42
+ on_failure&.call(
43
+ error,
44
+ outcome: :http_error,
45
+ status: node_res.code.to_i,
46
+ stage: :response
47
+ )
36
48
  end
37
49
  end
38
50
  rescue StandardError => e
@@ -46,6 +58,11 @@ module UniversalRenderer
46
58
  # current persistent connection. Net::HTTP can also raise after its
47
59
  # request block returns while it finalizes that response body.
48
60
  HttpPool.close(upstream_connection) if upstream_connection
61
+ on_failure&.call(
62
+ e,
63
+ outcome: failure_outcome(e),
64
+ stage: :transfer
65
+ )
49
66
  success = chunks_written
50
67
  ensure
51
68
  response.stream.close if success && !response.stream.closed?
@@ -53,6 +70,15 @@ module UniversalRenderer
53
70
 
54
71
  success
55
72
  end
73
+
74
+ def self.failure_outcome(error)
75
+ return :timeout if error.is_a?(Net::OpenTimeout) ||
76
+ error.is_a?(Net::ReadTimeout)
77
+
78
+ :error
79
+ end
80
+
81
+ private_class_method :failure_outcome
56
82
  end
57
83
  end
58
84
  end
@@ -14,7 +14,12 @@ module UniversalRenderer
14
14
  raise ArgumentError, "SSR URL is not configured." if config.url.blank?
15
15
 
16
16
  parsed_ssr_url = URI.parse(config.url)
17
- stream_uri = URI.join(parsed_ssr_url, config.stream_path)
17
+
18
+ # URI.join with a relative path replaces the base URL's last path
19
+ # segment; see Client::Base.absolute_path.
20
+ stream_path = config.stream_path.to_s
21
+ stream_path = "/#{stream_path}" unless stream_path.start_with?("/")
22
+ stream_uri = URI.join(parsed_ssr_url, stream_path)
18
23
 
19
24
  http = HttpPool.client(stream_uri, config.timeout)
20
25
 
@@ -7,9 +7,8 @@ require_relative "stream/setup"
7
7
  module UniversalRenderer
8
8
  module Client
9
9
  class Stream
10
- extend ErrorLogger
11
- extend Execution
12
- extend Setup
10
+ # The three modules below define only singleton methods, so `extend` would
11
+ # import nothing while reading as though it had. Call sites name the module.
13
12
 
14
13
  # Orchestrates the streaming process for server-side rendering.
15
14
  #
@@ -19,9 +18,20 @@ module UniversalRenderer
19
18
  # @param response [ActionDispatch::Response] The Rails response object to stream to.
20
19
  # @return [Boolean] True if streaming was initiated, false otherwise.
21
20
  def self.call(url, props, template, response)
21
+ Instrumentation.instrument(url: url, mode: :streaming) do |event|
22
+ succeeded = perform(url, props, template, response, event)
23
+ event[:outcome] = :error if !succeeded && event[:outcome] == :ok
24
+ succeeded
25
+ end
26
+ end
27
+
28
+ def self.perform(url, props, template, response, event = {})
29
+ initialize_event(event, url)
30
+
22
31
  config = UniversalRenderer.config
23
32
 
24
33
  unless Setup.ensure_ssr_server_url_configured?(config)
34
+ event[:outcome] = :not_configured
25
35
  UniversalRenderer.log do |log|
26
36
  log.warn(
27
37
  "Stream: SSR URL (config.url) is not configured. Falling back."
@@ -43,14 +53,14 @@ module UniversalRenderer
43
53
 
44
54
  full_ssr_url_for_log = actual_stream_uri.to_s # Update for more specific logging
45
55
  rescue URI::InvalidURIError => e
46
- UniversalRenderer.log do |log|
47
- log.error(
48
- "Stream: SSR stream failed due to invalid URI ('#{config.url}'): #{e.message}"
49
- )
50
- end
56
+ event[:outcome] = :error
57
+ event[:error] = e
58
+ ErrorLogger.log_setup_error(e, config.url.to_s, event)
51
59
  return false
52
60
  rescue StandardError => e
53
- log_setup_error(e, full_ssr_url_for_log)
61
+ event[:outcome] = :error
62
+ event[:error] = e
63
+ ErrorLogger.log_setup_error(e, full_ssr_url_for_log, event)
54
64
  return false
55
65
  end
56
66
 
@@ -59,7 +69,14 @@ module UniversalRenderer
59
69
  http_post_request,
60
70
  response,
61
71
  stream_uri_obj
62
- )
72
+ ) do |error, details|
73
+ event.merge!(details)
74
+ event[:error] = error
75
+ Instrumentation.report(
76
+ error,
77
+ event.merge(target: stream_uri_obj.to_s)
78
+ )
79
+ end
63
80
  rescue Errno::ECONNREFUSED,
64
81
  Errno::EHOSTUNREACH,
65
82
  Net::OpenTimeout,
@@ -69,21 +86,41 @@ module UniversalRenderer
69
86
  uri_str_for_conn_error =
70
87
  stream_uri_obj ? stream_uri_obj.to_s : full_ssr_url_for_log
71
88
 
72
- ErrorLogger.log_connection_error(e, uri_str_for_conn_error)
89
+ event[:outcome] = failure_outcome(e)
90
+ event[:error] = e
91
+ ErrorLogger.log_connection_error(e, uri_str_for_conn_error, event)
73
92
 
74
93
  false
75
94
  rescue StandardError => e
76
95
  uri_str_for_unexpected_error =
77
96
  stream_uri_obj ? stream_uri_obj.to_s : full_ssr_url_for_log
78
97
 
98
+ event[:outcome] = :error
99
+ event[:error] = e
79
100
  ErrorLogger.log_unexpected_error(
80
101
  e,
81
102
  uri_str_for_unexpected_error,
82
- "Stream: Unexpected error during SSR stream process"
103
+ "Stream: Unexpected error during SSR stream process",
104
+ event
83
105
  )
84
106
 
85
107
  false
86
108
  end
109
+
110
+ def self.initialize_event(event, url)
111
+ event[:url] ||= url
112
+ event[:mode] ||= :streaming
113
+ event[:outcome] ||= :ok
114
+ end
115
+
116
+ def self.failure_outcome(error)
117
+ return :timeout if error.is_a?(Net::OpenTimeout) ||
118
+ error.is_a?(Net::ReadTimeout)
119
+
120
+ :error
121
+ end
122
+
123
+ private_class_method :initialize_event, :failure_outcome
87
124
  end
88
125
  end
89
126
  end
@@ -3,11 +3,8 @@
3
3
  module UniversalRenderer
4
4
  # Configuration for UniversalRenderer.
5
5
  #
6
- # This object holds plain Ruby defaults only. It never reads environment
7
- # variables itself; binding configuration to ENV is the host application's
8
- # responsibility, done in the initializer (see the generated
9
- # config/initializers/universal_renderer.rb). The documented env-var
10
- # convention is the `UNIVERSAL_RENDERER_*` prefix.
6
+ # Plain Ruby defaults only. Binding these to ENV is the host application's job,
7
+ # done in the generated initializer.
11
8
  class Configuration
12
9
  # HTTP client options.
13
10
  class Http
@@ -18,13 +15,66 @@ module UniversalRenderer
18
15
  end
19
16
  end
20
17
 
21
- attr_accessor :url, :timeout, :stream_path
18
+ # Origin of the SSR service, e.g. "http://localhost:3001".
19
+ attr_accessor :url
20
+
21
+ # Open and read timeout, in seconds, for every request to the SSR service.
22
+ #
23
+ # Keep this above the renderer's own `renderTimeout` (2.5s by default). A
24
+ # render that outlives this timeout keeps its concurrency slot until it
25
+ # finishes, so the renderer must give up first.
26
+ attr_accessor :timeout
27
+
28
+ # Path the blocking renderer is mounted at. Must match the `paths.render`
29
+ # option given to `createServer`, whose default mounts `/` and `/static`.
30
+ #
31
+ # Defaults to nil, meaning the path already in `url`. A relative value is
32
+ # treated as absolute, since joining it relatively would replace the last
33
+ # path segment of `url`.
34
+ attr_accessor :render_path
35
+
36
+ # Path the streaming renderer is mounted at on the SSR service. Must match
37
+ # the `paths.stream` option given to `createServer` in the NPM package.
38
+ # Normalized the same way as `render_path`.
39
+ attr_accessor :stream_path
40
+
41
+ # Whether `ssr_head`/`ssr_body` run the renderer's HTML through Loofah.
42
+ #
43
+ # {SSR::Scrubber} is a blocklist, so this is defense in depth over HTML your
44
+ # own renderer produced, not a boundary against attacker-controlled markup.
45
+ # It also parses and rewrites the whole document on every request. On by
46
+ # default, because the cost is bounded and the mistake it catches is not.
47
+ attr_accessor :sanitize
48
+
49
+ # Scrubber instance used when `sanitize` is true. Defaults to
50
+ # {UniversalRenderer::SSR::Scrubber}; assign your own Loofah::Scrubber to
51
+ # widen or narrow what survives.
52
+ attr_accessor :scrubber
53
+
54
+ # Whether the Rails engine includes {UniversalRenderer::Renderable} into
55
+ # every ActionController::Base descendant. Turn it off and include the
56
+ # concern only in the controllers that render server-side.
57
+ attr_accessor :auto_include
58
+
59
+ # Optional callable invoked as `call(error, context)` when a configured
60
+ # render fails, where `context` carries at least `:url` and `:outcome`. A
61
+ # missing `url` reports `:not_configured` through the notification only.
62
+ #
63
+ # Errors are always logged; this exists so they can also reach an exception
64
+ # tracker, since every failure falls back to client rendering silently.
65
+ attr_accessor :on_error
66
+
22
67
  attr_reader :http
23
68
 
24
69
  def initialize
25
70
  @url = nil
26
71
  @timeout = 3
72
+ @render_path = nil
27
73
  @stream_path = "/stream"
74
+ @sanitize = true
75
+ @scrubber = nil
76
+ @auto_include = true
77
+ @on_error = nil
28
78
  @http = Http.new
29
79
  end
30
80
  end
@@ -1,7 +1,14 @@
1
1
  module UniversalRenderer
2
2
  class Engine < ::Rails::Engine
3
- ActiveSupport.on_load(:action_controller_base) do
4
- include UniversalRenderer::Renderable
3
+ # After config initializers, so `config.auto_include = false` is honoured
4
+ # whenever ActionController::Base happens to load.
5
+ initializer "universal_renderer.renderable",
6
+ after: :load_config_initializers do
7
+ ActiveSupport.on_load(:action_controller_base) do
8
+ if UniversalRenderer.config.auto_include
9
+ include UniversalRenderer::Renderable
10
+ end
11
+ end
5
12
  end
6
13
  end
7
14
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UniversalRenderer
4
+ # Every SSR failure falls back to client-side rendering silently, which is
5
+ # right for availability and useless for operations. These two hooks are the
6
+ # signal that SSR stopped working.
7
+ module Instrumentation
8
+ # ActiveSupport::Notifications event name. Subscribe to record hit rate and
9
+ # latency:
10
+ #
11
+ # ActiveSupport::Notifications.subscribe("render.universal_renderer") do |event|
12
+ # StatsD.timing("ssr.duration", event.duration, tags: ["outcome:#{event.payload[:outcome]}"])
13
+ # end
14
+ #
15
+ # Payload keys: `:url`, `:mode` (`:blocking` or `:streaming`), `:outcome`
16
+ # (`:ok`, `:not_configured`, `:http_error`, `:timeout`, or `:error`),
17
+ # and `:status` / `:error` where applicable.
18
+ NOTIFICATION = "render.universal_renderer"
19
+
20
+ module_function
21
+
22
+ # Instruments one render attempt. The block receives the mutable payload so
23
+ # it can record the outcome it reached.
24
+ def instrument(url:, mode:)
25
+ payload = { url: url, mode: mode, outcome: :ok }
26
+
27
+ ActiveSupport::Notifications.instrument(NOTIFICATION, payload) do
28
+ yield payload
29
+ end
30
+ end
31
+
32
+ # Hands an error to `config.on_error`, if one is configured. A raising
33
+ # callback must not turn a degraded render into a failed request.
34
+ def report(error, context)
35
+ callback = UniversalRenderer.config.on_error
36
+ return unless callback
37
+
38
+ callback.call(error, context)
39
+ rescue StandardError => e
40
+ UniversalRenderer.log do |log|
41
+ log.error(
42
+ "on_error callback raised: #{e.class.name} - #{e.message}"
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end