universal_renderer 0.5.1 → 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.
@@ -1,19 +1,77 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UniversalRenderer
4
+ # Scaffolds a working SSR setup: the initializer, the two renderer entry points,
5
+ # the SSR Vite build, the precompile hook, and the web-dyno launcher. Each file
6
+ # encodes something that is wrong by default and fails silently.
4
7
  class InstallGenerator < Rails::Generators::Base
5
8
  source_root File.expand_path("templates", __dir__)
6
9
 
10
+ class_option :frontend_dir,
11
+ type: :string,
12
+ default: "app/frontend",
13
+ desc: "Where your JavaScript lives (vite_rails' sourceCodeDir)"
14
+
15
+ class_option :skip_frontend,
16
+ type: :boolean,
17
+ default: false,
18
+ desc: "Only write the Ruby-side files"
19
+
20
+ class_option :skip_deploy,
21
+ type: :boolean,
22
+ default: false,
23
+ desc: "Skip bin/web and the assets:precompile hook"
24
+
7
25
  def copy_initializer
8
26
  template "initializer.rb", "config/initializers/universal_renderer.rb"
9
27
  end
10
28
 
29
+ def copy_frontend
30
+ return if options[:skip_frontend]
31
+
32
+ template "ssr/globals.ts", "#{frontend_dir}/ssr/globals.ts"
33
+ template "ssr/config.ts", "#{frontend_dir}/ssr/config.ts"
34
+ template "ssr/server.ts", "#{frontend_dir}/ssr/server.ts"
35
+ template "ssr/dev.ts", "#{frontend_dir}/ssr/dev.ts"
36
+ template "vite.config.ssr.mts", "vite.config.ssr.mts"
37
+ end
38
+
39
+ def copy_deploy_files
40
+ return if options[:skip_deploy] || options[:skip_frontend]
41
+
42
+ template "ssr.rake", "lib/tasks/ssr.rake"
43
+ template "web", "bin/web"
44
+ chmod "bin/web", 0o755
45
+ end
46
+
11
47
  def show_installation_notes
12
- say_status "info", "Universal Renderer installed successfully!"
48
+ say_status "info", "Universal Renderer installed."
13
49
  say_status "note", "Next steps:"
50
+ if options[:skip_frontend]
51
+ say_status "", " 1. Review config/initializers/universal_renderer.rb"
52
+ say_status "", " 2. Connect your existing renderer to that endpoint"
53
+ say_status "", " 3. Opt a controller in with `enable_ssr` or `render_ssr`"
54
+ return
55
+ end
56
+
57
+ say_status "", " 1. bun add universal-renderer (or npm/yarn)"
58
+ say_status "", " 2. Fill in #{frontend_dir}/ssr/config.ts — the render itself"
59
+ say_status "", " 3. Add the renderer to Procfile.dev:"
60
+ say_status "", " ssr: bun #{frontend_dir}/ssr/dev.ts"
61
+ say_status "", " 4. Add a build script to package.json:"
14
62
  say_status "",
15
- " 1. Edit config/initializers/universal_renderer.rb to point at your SSR server"
16
- say_status "", " 2. Ensure your Node.js/Bun SSR server is running"
63
+ ' "build:ssr": "vite -c vite.config.ssr.mts build"'
64
+ say_status "", " 5. Opt a controller in with `enable_ssr` or `render_ssr`"
65
+ return if options[:skip_deploy]
66
+
67
+ say_status "", " 6. Point Procfile's web process at bin/web, which runs"
68
+ say_status "", " the renderer alongside your app server"
69
+ end
70
+
71
+ private
72
+
73
+ def frontend_dir
74
+ options[:frontend_dir].delete_suffix("/")
17
75
  end
18
76
  end
19
77
  end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  UniversalRenderer.configure do |c|
4
- # External Node.js/Bun SSR server. Supports streaming via the /stream endpoint.
5
- c.url = "http://localhost:3001"
4
+ c.url = ENV.fetch("UNIVERSAL_RENDERER_URL", "http://localhost:3001")
5
+
6
+ # Keep this above the renderer's 2.5-second default.
6
7
  c.timeout = 3
7
- c.stream_path = "/stream"
8
+
8
9
  c.http.pool_size = 5
9
10
 
10
- # Blocking SSR is the default. Enable streaming per controller only when needed:
11
- # enable_ssr streaming: true
11
+ # c.sanitize = false
12
+ # c.scrubber = MyScrubber.new
13
+ # c.auto_include = false
14
+ # c.on_error = ->(error, context) { Sentry.capture_exception(error, extra: context) }
12
15
  end
@@ -0,0 +1,40 @@
1
+ // The render lifecycle: setup may await; prepare mutates shared state immediately
2
+ // before rendering; cleanup restores it. With concurrency: 1, prepare through
3
+ // cleanup never overlap.
4
+
5
+ import { renderToString } from "react-dom/server";
6
+
7
+ import type { SsrConfig } from "universal-renderer";
8
+
9
+ import { setBrowserLocation } from "./globals";
10
+
11
+ export default {
12
+ setup: async (url, props) => {
13
+ const { pathname, search } = new URL(url);
14
+ setBrowserLocation(url);
15
+
16
+ // hydrateReactQuery(props, queryClient);
17
+ // await preloadRoute(pathname);
18
+
19
+ const location = `${pathname}${search}`;
20
+ // const app = <StaticRouter location={location}><App /></StaticRouter>;
21
+
22
+ return { location, props, app: null as any };
23
+ },
24
+
25
+ prepare: (context) => {
26
+ // context.previousFlags = { ...FEATURE_FLAGS };
27
+ // Object.assign(FEATURE_FLAGS, context.props.feature_flags);
28
+ },
29
+
30
+ render: (context) => ({
31
+ body: renderToString(context.app),
32
+ // head: context.sheet.getStyleTags(),
33
+ // payload: { queryCache: dehydrate(queryClient) },
34
+ }),
35
+
36
+ cleanup: (context) => {
37
+ // Object.assign(FEATURE_FLAGS, context.previousFlags);
38
+ // context.sheet?.seal();
39
+ },
40
+ } satisfies SsrConfig<any>;
@@ -0,0 +1,9 @@
1
+ // Development renderer entry. Loads the render config through a middleware-mode
2
+ // Vite server, so plugins and path aliases apply and edits need no rebuild.
3
+ //
4
+ // Production does not go through this file. See server.ts.
5
+ import "./globals";
6
+
7
+ const { startDevServer } = await import("universal-renderer/dev");
8
+
9
+ await startDevServer({ entry: "<%= frontend_dir %>/ssr/config.ts" });
@@ -0,0 +1,141 @@
1
+ // App-owned browser stubs for SSR. Add only what your graph needs. Defining
2
+ // `window` makes browser-detection checks false process-wide; prefer fixing DOM
3
+ // access in the app where possible.
4
+
5
+ const noop = () => undefined;
6
+ const g = globalThis as any;
7
+ const MARKER = "__ssrBrowserGlobals";
8
+
9
+ export const isShimmed = (): boolean => g[MARKER] === true;
10
+ export const SSR_VIEWPORT = { width: 1024, height: 768 };
11
+
12
+ const makeLocation = (value: string) => {
13
+ const url = new URL(value);
14
+ return {
15
+ href: url.href,
16
+ protocol: url.protocol,
17
+ host: url.host,
18
+ hostname: url.hostname,
19
+ port: url.port,
20
+ pathname: url.pathname,
21
+ search: url.search,
22
+ hash: url.hash,
23
+ origin: url.origin,
24
+ assign: noop,
25
+ reload: noop,
26
+ replace: noop,
27
+ toString: () => url.href,
28
+ };
29
+ };
30
+
31
+ const makeStorage = (): Storage =>
32
+ ({
33
+ length: 0,
34
+ clear: noop,
35
+ getItem: () => null,
36
+ key: () => null,
37
+ removeItem: noop,
38
+ setItem: noop,
39
+ }) as unknown as Storage;
40
+
41
+ const makeElement = (): any => ({
42
+ style: {},
43
+ dataset: {},
44
+ children: [],
45
+ classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
46
+ setAttribute: noop,
47
+ removeAttribute: noop,
48
+ getAttribute: () => null,
49
+ appendChild: noop,
50
+ removeChild: noop,
51
+ addEventListener: noop,
52
+ removeEventListener: noop,
53
+ querySelector: () => null,
54
+ querySelectorAll: () => [],
55
+ contains: () => false,
56
+ });
57
+
58
+ export function setBrowserLocation(url: string): void {
59
+ if (!isShimmed()) return;
60
+
61
+ const location = makeLocation(url);
62
+ g.window.location = location;
63
+ g.location = location;
64
+ if (g.document) g.document.URL = location.href;
65
+ }
66
+
67
+ export function installBrowserGlobals(): boolean {
68
+ if (isShimmed()) return false;
69
+ if (typeof g.window !== "undefined" && typeof g.document !== "undefined") {
70
+ return false;
71
+ }
72
+
73
+ g[MARKER] = true;
74
+ const location = makeLocation("http://localhost:3000/");
75
+ const win: any = {
76
+ location,
77
+ localStorage: makeStorage(),
78
+ sessionStorage: makeStorage(),
79
+ navigator: { userAgent: "ssr", language: "en-US", languages: ["en-US"] },
80
+ document: {
81
+ readyState: "complete",
82
+ title: "",
83
+ cookie: "",
84
+ URL: location.href,
85
+ documentElement: makeElement(),
86
+ body: makeElement(),
87
+ head: makeElement(),
88
+ hidden: false,
89
+ visibilityState: "visible",
90
+ createElement: () => makeElement(),
91
+ getElementById: () => null,
92
+ querySelector: () => null,
93
+ querySelectorAll: () => [],
94
+ addEventListener: noop,
95
+ removeEventListener: noop,
96
+ },
97
+ history: { pushState: noop, replaceState: noop, back: noop, go: noop },
98
+ matchMedia: (media: string) => ({
99
+ matches: false,
100
+ media,
101
+ addListener: noop,
102
+ removeListener: noop,
103
+ addEventListener: noop,
104
+ removeEventListener: noop,
105
+ }),
106
+ getComputedStyle: () => ({ getPropertyValue: () => "" }),
107
+ requestAnimationFrame: (_cb: () => void) => 0,
108
+ cancelAnimationFrame: noop,
109
+ innerWidth: SSR_VIEWPORT.width,
110
+ innerHeight: SSR_VIEWPORT.height,
111
+ scrollTo: noop,
112
+ addEventListener: noop,
113
+ removeEventListener: noop,
114
+ };
115
+
116
+ win.window = win;
117
+ win.self = win;
118
+ win.top = win;
119
+
120
+ g.window = win;
121
+ for (const key of [
122
+ "document",
123
+ "navigator",
124
+ "location",
125
+ "history",
126
+ "localStorage",
127
+ "sessionStorage",
128
+ "matchMedia",
129
+ "getComputedStyle",
130
+ "requestAnimationFrame",
131
+ "cancelAnimationFrame",
132
+ "innerWidth",
133
+ "innerHeight",
134
+ ]) {
135
+ if (!(key in g)) g[key] = win[key];
136
+ }
137
+
138
+ return true;
139
+ }
140
+
141
+ installBrowserGlobals();
@@ -0,0 +1,24 @@
1
+ // Production renderer entry. Renders from the bundle vite.config.ssr.mts builds,
2
+ // with no Vite server and no per-request module transform. Development uses
3
+ // dev.ts instead.
4
+ //
5
+ // Browser globals first. See globals.ts, and delete it if your graph needs none.
6
+ import "./globals";
7
+
8
+ // Dynamic, not static: static imports all evaluate before the entry body runs, so
9
+ // the import above would land after the app graph has already touched globals.
10
+ //
11
+ // A library that snapshots `typeof window` at module scope must be imported
12
+ // statically *above* `./globals`. Leave a comment saying so, or a formatter
13
+ // re-sorting the imports undoes it.
14
+ const { default: config } = await import("./config");
15
+ const { startServer } = await import("universal-renderer");
16
+
17
+ await startServer({
18
+ ...config,
19
+ // Serialized on purpose. See config.ts.
20
+ concurrency: 1,
21
+ // Keep this below the gem's three-second default timeout: a running render
22
+ // holds its slot, so the renderer must give up before Rails falls back.
23
+ renderTimeout: 2_500,
24
+ });
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # No :environment dependency: bundling the frontend does not need Rails booted,
4
+ # and booting it during precompile only adds ways for the build to fail —
5
+ # credentials, a database that is not up yet.
6
+ # rubocop:disable Rails/RakeEnvironment
7
+ namespace :ssr do
8
+ desc "Build the standalone SSR bundle that bin/web runs (ssr-build/)"
9
+ task :build do
10
+ # Goes through the package script rather than invoking `vite` directly:
11
+ # node_modules/.bin is not on PATH during a deploy build, so a bare `vite`
12
+ # fails there while working fine in a shell with the toolchain loaded.
13
+ # Swap `bun run` for `npm run` / `yarn` to match your package manager.
14
+ sh "bun run build:ssr"
15
+ end
16
+ end
17
+ # rubocop:enable Rails/RakeEnvironment
18
+
19
+ # bin/web runs the compiled bundle, so it has to exist by the time a release
20
+ # boots. Build it with the rest of the assets.
21
+ if Rake::Task.task_defined?("assets:precompile")
22
+ Rake::Task["assets:precompile"].enhance(["ssr:build"])
23
+ else
24
+ # No asset pipeline here, so define the task rather than skipping the build
25
+ # and shipping a release with no renderer.
26
+ Rake::Task.define_task("assets:precompile" => "ssr:build")
27
+ end
@@ -0,0 +1,13 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { defineSsrConfig } from "universal-renderer/vite";
3
+
4
+ // Standalone SSR build. defineSsrConfig handles the four settings that are wrong
5
+ // by default for a Rails SSR build; read its docs before overriding them.
6
+ //
7
+ // Note what is absent: vite-plugin-rails, which targets the client manifest
8
+ // pipeline and overrides entrypoints and outDir. Your client build keeps using
9
+ // vite.config.mts, unchanged.
10
+ export default defineSsrConfig({
11
+ entry: "<%= frontend_dir %>/ssr/server.ts",
12
+ plugins: [react()],
13
+ });
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env bash
2
+ # Boots the SSR renderer alongside the app server.
3
+ set -uo pipefail
4
+
5
+ SSR_BUNDLE="${SSR_BUNDLE:-ssr-build/server.mjs}"
6
+ export SSR_PORT="${SSR_PORT:-3001}"
7
+
8
+ # Override these for non-Bun runtimes or non-Puma app servers.
9
+ SSR_RUNTIME="${SSR_RUNTIME:-bun}"
10
+ WEB_CMD="${WEB_CMD:-bundle exec puma -C config/puma.rb}"
11
+
12
+ # Set SSR_WATCHDOG=0 to disable health-based renderer restarts.
13
+ SSR_WATCHDOG="${SSR_WATCHDOG:-1}"
14
+ SSR_HEALTH_URL="${SSR_HEALTH_URL:-http://127.0.0.1:${SSR_PORT}/health}"
15
+ SSR_HEALTH_INTERVAL="${SSR_HEALTH_INTERVAL:-10}"
16
+ SSR_HEALTH_FAILURES="${SSR_HEALTH_FAILURES:-3}"
17
+
18
+ SSR_PID=""
19
+ APP_PID=""
20
+ shutting_down=0
21
+ health_failures=0
22
+
23
+ start_ssr() {
24
+ if [ ! -f "$SSR_BUNDLE" ]; then
25
+ echo "[web] $SSR_BUNDLE missing — serving client-rendered pages only." >&2
26
+ return
27
+ fi
28
+
29
+ "$SSR_RUNTIME" "$SSR_BUNDLE" &
30
+ SSR_PID=$!
31
+ echo "[web] SSR renderer started (pid $SSR_PID, port $SSR_PORT)"
32
+ }
33
+
34
+ stop_ssr() {
35
+ [ -n "$SSR_PID" ] || return 0
36
+
37
+ kill -TERM "$SSR_PID" 2>/dev/null || true
38
+ for _ in 1 2 3 4 5; do
39
+ kill -0 "$SSR_PID" 2>/dev/null || break
40
+ sleep 1
41
+ done
42
+ kill -KILL "$SSR_PID" 2>/dev/null || true
43
+ wait "$SSR_PID" 2>/dev/null || true
44
+ SSR_PID=""
45
+ }
46
+
47
+ check_ssr_health() {
48
+ [ "$SSR_WATCHDOG" = "1" ] || return 0
49
+ [ -n "$SSR_PID" ] || return 0
50
+ command -v curl >/dev/null 2>&1 || return 0
51
+
52
+ if curl -fsS --max-time 5 "$SSR_HEALTH_URL" >/dev/null 2>&1; then
53
+ health_failures=0
54
+ return 0
55
+ fi
56
+
57
+ health_failures=$((health_failures + 1))
58
+ [ "$health_failures" -ge "$SSR_HEALTH_FAILURES" ] || return 0
59
+
60
+ echo "[web] SSR renderer unhealthy after ${health_failures} checks — restarting." >&2
61
+ stop_ssr
62
+ health_failures=0
63
+ start_ssr
64
+ }
65
+
66
+ on_signal() {
67
+ shutting_down=1
68
+ if [ -n "$APP_PID" ]; then kill -TERM "$APP_PID" 2>/dev/null || true; fi
69
+ }
70
+ trap on_signal INT TERM
71
+ trap 'if [ -n "$SSR_PID" ]; then kill -KILL "$SSR_PID" 2>/dev/null || true; fi' EXIT
72
+
73
+ start_ssr
74
+
75
+ # shellcheck disable=SC2086
76
+ $WEB_CMD &
77
+ APP_PID=$!
78
+
79
+ elapsed=0
80
+ while [ "$shutting_down" -eq 0 ] && kill -0 "$APP_PID" 2>/dev/null; do
81
+ sleep 1
82
+ elapsed=$((elapsed + 1))
83
+ if [ "$shutting_down" -eq 0 ] && [ "$elapsed" -ge "$SSR_HEALTH_INTERVAL" ]; then
84
+ elapsed=0
85
+ check_ssr_health
86
+ fi
87
+ done
88
+
89
+ wait "$APP_PID"
90
+ APP_STATUS=$?
91
+ APP_PID=""
92
+
93
+ stop_ssr
94
+ exit "$APP_STATUS"
@@ -16,6 +16,9 @@ module UniversalRenderer
16
16
  # This is used for non-streaming SSR, where the entire payload is fetched
17
17
  # before the main application view is rendered.
18
18
  #
19
+ # Emits a `render.universal_renderer` notification for every attempt and
20
+ # routes failures through `config.on_error`.
21
+ #
19
22
  # @param url [String] The URL of the page to render on the SSR server.
20
23
  # This should typically be the `request.original_url` from the controller.
21
24
  # @param props [Hash] A hash of props to be passed to the SSR service.
@@ -25,54 +28,106 @@ module UniversalRenderer
25
28
  # (HTTP 2xx). Returns `nil` when the request fails or the SSR service is
26
29
  # unreachable.
27
30
  def self.call(url, props)
28
- ssr_url = UniversalRenderer.config.url
29
- return if ssr_url.blank?
30
-
31
- timeout = UniversalRenderer.config.timeout
32
-
33
- begin
34
- uri = URI.parse(ssr_url)
35
- request = Net::HTTP::Post.new(uri.request_uri)
36
- request.body = { url: url, props: props }.to_json
37
- request["Content-Type"] = "application/json"
38
-
39
- response = HttpPool.request(uri, timeout, request)
40
-
41
- if response.is_a?(Net::HTTPSuccess)
42
- raw_data = JSON.parse(response.body).deep_symbolize_keys
43
-
44
- # Map the keys we care about to the Struct. The Node service might
45
- # send `:body_html` instead of `:body`; favour the latter if
46
- # present but fall back gracefully.
47
- UniversalRenderer::SSR::Response.new(
48
- head: raw_data[:head],
49
- body: raw_data[:body] || raw_data[:body_html],
50
- body_attrs: raw_data[:body_attrs]
51
- )
52
- else
53
- UniversalRenderer.log do |log|
54
- log.error(
55
- "SSR fetch request to #{ssr_url} failed: #{response.code} - #{response.message} (URL: #{url})"
56
- )
57
- end
58
- nil
59
- end
60
- rescue Net::OpenTimeout, Net::ReadTimeout => e
61
- UniversalRenderer.log do |log|
62
- log.error(
63
- "SSR fetch request to #{ssr_url} timed out: #{e.class.name} - #{e.message} (URL: #{url})"
64
- )
31
+ config = UniversalRenderer.config
32
+ ssr_url = config.url
33
+
34
+ Instrumentation.instrument(url: url, mode: :blocking) do |event|
35
+ if ssr_url.blank?
36
+ event[:outcome] = :not_configured
37
+ next nil
65
38
  end
66
- nil
67
- rescue StandardError => e
68
- UniversalRenderer.log do |log|
69
- log.error(
70
- "SSR fetch request to #{ssr_url} failed: #{e.class.name} - #{e.message} (URL: #{url})"
71
- )
39
+
40
+ perform(ssr_url, config, url, props, event)
41
+ end
42
+ end
43
+
44
+ def self.perform(ssr_url, config, url, props, event)
45
+ # A nil render_path means the path in `url` is already the endpoint.
46
+ parsed = URI.parse(ssr_url)
47
+ uri =
48
+ if config.render_path.present?
49
+ URI.join(parsed, absolute_path(config.render_path))
50
+ else
51
+ parsed
72
52
  end
73
- nil
53
+
54
+ request = Net::HTTP::Post.new(uri.request_uri)
55
+ request.body = { url: url, props: props }.to_json
56
+ request["Content-Type"] = "application/json"
57
+
58
+ response = HttpPool.request(uri, config.timeout, request)
59
+
60
+ unless response.is_a?(Net::HTTPSuccess)
61
+ event[:outcome] = :http_error
62
+ event[:status] = response.code.to_i
63
+ fail_render(
64
+ url,
65
+ uri,
66
+ event,
67
+ "responded with #{response.code} #{response.message}"
68
+ )
69
+ return nil
70
+ end
71
+
72
+ build_response(JSON.parse(response.body))
73
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
74
+ event[:outcome] = :timeout
75
+ event[:error] = e
76
+ fail_render(url, uri, event, "timed out: #{e.class.name} - #{e.message}", e)
77
+ nil
78
+ rescue StandardError => e
79
+ event[:outcome] = :error
80
+ event[:error] = e
81
+ fail_render(url, uri, event, "failed: #{e.class.name} - #{e.message}", e)
82
+ nil
83
+ end
84
+
85
+ # String keys on purpose: `payload` can be a large dehydrated query cache,
86
+ # and deep-symbolizing it would walk and re-allocate all of it per render.
87
+ #
88
+ # The three keys that reach a view helper are type-checked, so a renderer
89
+ # answering 200 with the wrong shape falls back like any other failed
90
+ # render instead of raising mid-layout. `perform` rescues the TypeError.
91
+ def self.build_response(data)
92
+ unless data.is_a?(Hash)
93
+ raise TypeError,
94
+ "SSR service returned #{data.class.name}, expected a JSON object"
95
+ end
96
+
97
+ UniversalRenderer::SSR::Response.new(
98
+ head: string_or_nil(data["head"]),
99
+ body: string_or_nil(data["body"]),
100
+ body_attrs: (data["body_attrs"] if data["body_attrs"].is_a?(Hash)),
101
+ payload: data["payload"]
102
+ )
103
+ end
104
+
105
+ def self.string_or_nil(value)
106
+ value if value.is_a?(String)
107
+ end
108
+
109
+ # URI.join replaces the base URL's last path segment when the joined path is
110
+ # relative, which would post renders to the wrong endpoint.
111
+ def self.absolute_path(path)
112
+ path = path.to_s
113
+ path.start_with?("/") ? path : "/#{path}"
114
+ end
115
+
116
+ def self.fail_render(url, uri, event, message, error = nil)
117
+ target = uri ? uri.to_s : UniversalRenderer.config.url.to_s
118
+
119
+ UniversalRenderer.log do |log|
120
+ log.error("SSR fetch request to #{target} #{message} (URL: #{url})")
74
121
  end
122
+
123
+ Instrumentation.report(
124
+ error || StandardError.new("SSR fetch request #{message}"),
125
+ event.merge(target: target)
126
+ )
75
127
  end
128
+
129
+ private_class_method :perform, :build_response, :string_or_nil,
130
+ :fail_render, :absolute_path
76
131
  end
77
132
  end
78
133
  end
@@ -2,7 +2,7 @@ module UniversalRenderer
2
2
  module Client
3
3
  class Stream
4
4
  module ErrorLogger
5
- def self.log_setup_error(error, target_uri_string)
5
+ def self.log_setup_error(error, target_uri_string, context = {})
6
6
  backtrace_info = error.backtrace&.first || "No backtrace available"
7
7
  UniversalRenderer.log do |log|
8
8
  log.error(
@@ -10,17 +10,24 @@ module UniversalRenderer
10
10
  "#{error.class.name} - #{error.message} at #{backtrace_info}"
11
11
  )
12
12
  end
13
+ report(error, target_uri_string, :setup, context)
13
14
  end
14
15
 
15
- def self.log_connection_error(error, target_uri_string)
16
+ def self.log_connection_error(error, target_uri_string, context = {})
16
17
  UniversalRenderer.log do |log|
17
18
  log.error(
18
19
  "SSR stream connection to #{target_uri_string} failed: #{error.class.name} - #{error.message}"
19
20
  )
20
21
  end
22
+ report(error, target_uri_string, :connection, context)
21
23
  end
22
24
 
23
- def self.log_unexpected_error(error, target_uri_string, context_message)
25
+ def self.log_unexpected_error(
26
+ error,
27
+ target_uri_string,
28
+ context_message,
29
+ context = {}
30
+ )
24
31
  backtrace_info = error.backtrace&.first || "No backtrace available"
25
32
  UniversalRenderer.log do |log|
26
33
  log.error(
@@ -28,6 +35,21 @@ module UniversalRenderer
28
35
  "#{error.class.name} - #{error.message} at #{backtrace_info}"
29
36
  )
30
37
  end
38
+ report(error, target_uri_string, :unexpected, context)
39
+ end
40
+
41
+ # Streaming failures fall back to client-side rendering just as blocking
42
+ # ones do, so they need the same escape hatch to an exception tracker.
43
+ def self.report(error, target_uri_string, stage, context = {})
44
+ Instrumentation.report(
45
+ error,
46
+ context.merge(
47
+ mode: :streaming,
48
+ outcome: context.fetch(:outcome, :error),
49
+ stage: stage,
50
+ target: target_uri_string
51
+ )
52
+ )
31
53
  end
32
54
  end
33
55
  end