quilt_rails 1.8.0 → 1.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 393d55d7b0940edb22882fff283debdc403bd991172f4f4fc80c8f11e9e63ca7
4
- data.tar.gz: 54eab8702f1f45dcba856e79474ccbde6f575e200b4a4fbb094ffb822d5040cb
3
+ metadata.gz: 698a173ee3b10092312909bbee61feba9982397e474043ed6a3a77a548b2836c
4
+ data.tar.gz: 99d5d7844a7d1b1850df281589b5ebfa1b08ac830845f650bacd99722799dc4f
5
5
  SHA512:
6
- metadata.gz: 000d1fee3b1fc1650f9f7ef9eefe5794facbdd3b05907e7d0b5e72214e7486a76a1c07f9eedd02593c3e366f6ffdcd7bf8592d1db744cdfb6d876cc3a48caec7
7
- data.tar.gz: 9444a83ad67ff6298e10554bf3bf33c3a0db9b22bfd6f19f8cf918c6d31d4ed0f62cc369b3fe44867cf2bab41bf5242c991e0820c63b9c671b0313a9d4a8cb2f
6
+ metadata.gz: 2967e5754b5c2525da40d52fd3acc00097fbb6c3011c417c5d2118748880560bf32f7d2262eaa531e324aa53d2c9166a990da87658b418ff8a8803c677581ab7
7
+ data.tar.gz: 5fe683e1107e708a0a6e3bb169d37761b84b73e8dea0e296d7d469f168a4d783654961c1fb500b663efddbeeaf6834797bc917571c3696d54ff6d8d3f53b5740
data/README.md CHANGED
@@ -1,63 +1,94 @@
1
1
  # quilt_rails
2
2
 
3
- A turn-key solution for integrating server-rendered react into your Rails app using Quilt libraries.
3
+ A turn-key solution for integrating Quilt client-side libraries into your Rails app, with support for server-side-rendering using [`@shopify/react-server`](https://www.npmjs.com/package/@shopify/react-server), integration with [`@shopify/sewing-kit`](https://www.npmjs.com/package/@shopify/sewing-kit) for building, testing and linting, and front-end performance tracking through [`@shopify/performance`](https://www.npmjs.com/package/@shopify/performance).
4
4
 
5
5
  ## Table of Contents
6
6
 
7
- 1. [Quick Start](#quick-start)
8
- 1. [Generate Rails boilerplate](#generate-rails-boilerplate)
9
- 1. [Add Ruby dependencies](#add-ruby-dependencies)
10
- 1. [Generate Quilt boilerplate](#generate-quilt-boilerplate)
11
- 1. [Try it out](#try-it-out)
12
- 1. [Manual Install](#manual-installation)
13
- 1. [Install Dependencies](#install-dependencies)
14
- 1. [Setup the Rails app](#setup-the-rails-app)
15
- 1. [Add JavaScript](#add-javascript)
16
- 1. [Run the server](#run-the-server)
17
- 1. [Application Layout](#application-layout)
18
- 1. [API](#api)
19
- 1. [ReactRenderable](#reactrenderable)
20
- 1. [Engine](#engine)
21
- 1. [Generators](#generators)
22
- 1. [Advanced Use](#advanced-use)
23
- 1. [Testing](#testing)
24
- 1. [Interacting with the request and response in React code](#interacting-with-the-request-and-response-in-react-code)
25
- 1. [Dealing with isomorphic state](#dealing-with-isomorphic-state)
26
- 1. [Customizing the node server](#customizing-the-node-server)
27
-
28
- ## Quick Start
7
+ - [Server-side-rendering](#server-side-rendering)
8
+ - [Quick start](#server-side-rendering-quick-start)
9
+ - [Generate Rails boilerplate](#generate-rails-boilerplate)
10
+ - [Add Ruby dependencies](#add-ruby-dependencies)
11
+ - [Generate Quilt boilerplate](#generate-quilt-boilerplate)
12
+ - [Try it out](#try-it-out)
13
+ - [Manual Install](#manual-installation)
14
+ - [Install Dependencies](#install-dependencies)
15
+ - [Setup the Rails app](#setup-the-rails-app)
16
+ - [Add JavaScript](#add-javascript)
17
+ - [Run the server](#run-the-server)
18
+ - [Application Layout](#application-layout)
19
+ - [Advanced Use](#advanced-use)
20
+ - [Testing](#testing)
21
+ - [Interacting with the request and response in React code](#interacting-with-the-request-and-response-in-react-code)
22
+ - [Dealing with isomorphic state](#dealing-with-isomorphic-state)
23
+ - [Customizing the node server](#customizing-the-node-server)
24
+ - [Performance tracking a React app](#performance-tracking-a-react-app)
25
+ - [Install dependencies](#install-dependencies)
26
+ - [Setup an endpoint for performance reports](setup-an-endpoint-for-performance-reports)
27
+ - [Add annotations](#add-annotations)
28
+ - [Send the report](#send-the-report)
29
+ - [Verify in development](#verify-in-development)
30
+ - [Configure StatsD for production](#configure-statsd-for-production)
31
+ - [API](#api)
32
+ - [ReactRenderable](#reactrenderable)
33
+ - [PerformanceReportable](#performanceReportable)
34
+ - [Engine](#engine)
35
+ - [Generators](#generators)
36
+
37
+ ## Server-side-rendering
38
+
39
+ ### Alpha functionality - do not use in high-traffic production applications
40
+
41
+ **Warning:** quilt_rails's server-side-rendering module `ReactRenderable` does not work at scale. Improvements to its architecture are being investigated. In its current state, it can be used for:
42
+
43
+ - Workshop applications
44
+ - Proof of concept applications
45
+ - Low traffic applications
46
+
47
+ For a description of the current architecture's problems, see [this Github comment](https://github.com/Shopify/quilt/issues/1059#issuecomment-539195340).
48
+
49
+ The ["decide on a scalable quilt_rails architecture" issue](https://github.com/Shopify/quilt/issues/1100) will track discussion of future architectures.
50
+
51
+ To scale up existing quilt_rails applications, skip server-side queries in your components. e.g.:
52
+
53
+ ```ts
54
+ useQuery(MyQuery, {
55
+ skip: typeof document === 'undefined',
56
+ });
57
+ ```
58
+
59
+ ### Quick start
29
60
 
30
61
  Using the magic of generators, we can spin up a basic app with a few console commands.
31
62
 
32
- ### Generate Rails boilerplate
63
+ #### Generate Rails boilerplate
33
64
 
34
65
  `dev init`
35
66
 
36
67
  When prompted, choose `rails`. This will generate a basic Rails application scaffold.
37
68
 
38
- ### Add Ruby dependencies
69
+ #### Add Ruby dependencies
39
70
 
40
71
  `bundle add sewing_kit quilt_rails`
41
72
 
42
73
  This will install our ruby dependencies and update the project's gemfile.
43
74
 
44
- ### Generate Quilt boilerplate
75
+ #### Generate Quilt boilerplate
45
76
 
46
77
  `rails generate quilt:install`
47
78
 
48
79
  This will install the Node dependencies, provide a basic React app (in TypeScript) and mounts the Quilt engine inside of `config/routes.rb`.
49
80
 
50
- ### Try it out
81
+ #### Try it out
51
82
 
52
83
  `dev server`
53
84
 
54
85
  Will run the application, starting up both servers and compiling assets.
55
86
 
56
- ## Manual Installation
87
+ ### Manual installation
57
88
 
58
89
  An application can also be setup manually using the following steps.
59
90
 
60
- ### Install Dependencies
91
+ #### Install dependencies
61
92
 
62
93
  ```sh
63
94
  # Add core Node dependencies
@@ -70,11 +101,11 @@ yarn
70
101
  dev up
71
102
  ```
72
103
 
73
- ### Setup the Rails app
104
+ #### Setup the Rails app
74
105
 
75
106
  There are 2 ways to consume this package.
76
107
 
77
- #### Option 1: Mount the Engine
108
+ ##### Option 1: Mount the Engine
78
109
 
79
110
  Add the engine to `routes.rb`.
80
111
 
@@ -96,7 +127,7 @@ Rails.application.routes.draw do
96
127
  end
97
128
  ```
98
129
 
99
- #### Option 2: Add a React controller and routes
130
+ ##### Option 2: Add a React controller and routes
100
131
 
101
132
  Create a `ReactController` to handle react requests.
102
133
 
@@ -117,7 +148,7 @@ Add routes to default to the `ReactController`.
117
148
  root 'react#index'
118
149
  ```
119
150
 
120
- ### Add JavaScript
151
+ #### Add JavaScript
121
152
 
122
153
  `sewing_kit` looks for the top level component of your React app in `app/ui/index`. The component exported from this component (and any imported JS/CSS) will be built into a `main` bundle, and used to render the initial server-rendered markup.
123
154
 
@@ -135,15 +166,15 @@ function App() {
135
166
  export default App;
136
167
  ```
137
168
 
138
- ### Run the server
169
+ #### Run the server
139
170
 
140
171
  `dev server`
141
172
 
142
173
  Will run the application, starting up both servers and compiling assets.
143
174
 
144
- ## Application layout
175
+ ### Application layout
145
176
 
146
- ### Minimal
177
+ #### Minimal
147
178
 
148
179
  The basic layout for an app using `quilt_rails` and friends will have a `ui` folder nested inside the normal Rails `app` folder, containing at _least_ an index.js file exporting a React component.
149
180
 
@@ -158,7 +189,7 @@ The basic layout for an app using `quilt_rails` and friends will have a `ui` fol
158
189
  └─- react_controller.rb (see above)
159
190
  ```
160
191
 
161
- ### Rails and React
192
+ #### Rails and React
162
193
 
163
194
  A more complex application will want a more complex layout. The following shows scalable locations for:
164
195
 
@@ -199,59 +230,9 @@ A more complex application will want a more complex layout. The following shows
199
230
  └── Home.{js|ts}
200
231
  ```
201
232
 
202
- ## API
203
-
204
- ### ReactRenderable
205
-
206
- The `ReactRenderable` mixin is intended to be used in Rails controllers, and provides only the `render_react` method. This method handles proxying to a running `@shopify/react-server`.
207
-
208
- ```ruby
209
- class ReactController < ApplicationController
210
- include Quilt::ReactRenderable
211
-
212
- def index
213
- render_react
214
- end
215
- end
216
- ```
217
-
218
- ### Engine
219
-
220
- `Quilt::Engine` provides a preconfigured controller which consumes `ReactRenderable` and provides an index route which uses it.
233
+ ### Advanced use
221
234
 
222
- ```ruby
223
- # config/routes.rb
224
- Rails.application.routes.draw do
225
- # ...
226
- mount Quilt::Engine, at: '/path/to/react'
227
- end
228
- ```
229
-
230
- ### Configuration
231
-
232
- The `configure` method allows customization of the address the service will proxy to for UI rendering.
233
-
234
- ```ruby
235
- # config/initializers/quilt.rb
236
- Quilt.configure do |config|
237
- config.react_server_host = "localhost:3000"
238
- config.react_server_protocol = 'https'
239
- end
240
- ```
241
-
242
- ### Generators
243
-
244
- #### `quilt:install`
245
-
246
- Installs the Node dependencies, provide a basic React app (in TypeScript) and mounts the Quilt engine in `config/routes.rb`.
247
-
248
- #### `sewing_kit:install`
249
-
250
- Adds a basic `sewing-kit.config.ts` file.
251
-
252
- ## Advanced use
253
-
254
- ### Testing
235
+ #### Testing
255
236
 
256
237
  For fast tests with consistent results, test front-end components using the tools provided by sewing-kit instead of Rails integration tests.
257
238
 
@@ -259,7 +240,7 @@ Use [`sewing-kit test`](https://github.com/Shopify/sewing-kit/blob/master/docs/c
259
240
 
260
241
  For testing React applications we provide and support [`@shopify/react-testing`](https://github.com/Shopify/quilt/tree/master/packages/react-testing).
261
242
 
262
- #### Example
243
+ ##### Example
263
244
 
264
245
  Given a component `MyComponent.tsx`
265
246
 
@@ -286,17 +267,17 @@ describe('MyComponent', () => {
286
267
  });
287
268
  ```
288
269
 
289
- #### Customizing the test environment
270
+ ##### Customizing the test environment
290
271
 
291
272
  Often you will want to hook up custom polyfills, global mocks, or other logic that needs to run either before the initialization of the test environment, or once for each test suite.
292
273
 
293
274
  By default, sewing-kit will look for such test setup files under `/app/ui/tests`. Check out the [documentation](https://github.com/Shopify/sewing-kit/blob/master/docs/plugins/jest.md#smart-defaults) for more details.
294
275
 
295
- ### Interacting with the request and response in React code
276
+ ##### Interacting with the request and response in React code
296
277
 
297
278
  React-server sets up [@shopify/react-network](https://github.com/Shopify/quilt/blob/master/packages/react-network) automatically, so most interactions with the request or response can be done from inside the React app.
298
279
 
299
- #### Example: getting headers
280
+ ##### Example: getting headers
300
281
 
301
282
  ```tsx
302
283
  // app/ui/index.tsx
@@ -319,7 +300,7 @@ function App() {
319
300
  export default App;
320
301
  ```
321
302
 
322
- #### Example: redirecting
303
+ ##### Example: redirecting
323
304
 
324
305
  ```tsx
325
306
  // app/ui/index.tsx
@@ -337,13 +318,13 @@ function App() {
337
318
  export default App;
338
319
  ```
339
320
 
340
- ### Isomorphic state
321
+ #### Isomorphic state
341
322
 
342
323
  With SSR enabled React apps, state must be serialized on the server and deserialized on the client to keep it consistent. When using `@shopify/react-server`, the best tool for this job is [`@shopify/react-html`](https://github.com/Shopify/quilt/tree/master/packages/react-html)'s [`useSerialized`](https://github.com/Shopify/quilt/tree/master/packages/react-html#in-your-application-code) hook.
343
324
 
344
325
  `useSerialized` can be used to implement [universal-providers](https://github.com/Shopify/quilt/tree/master/packages/react-universal-provider#what-is-a-universal-provider-), allowing application code to manage what is persisted between the server and client without adding any custom code to client or server entrypoints. We offer some for common use cases such as [CSRF](https://github.com/Shopify/quilt/tree/master/packages/react-csrf-universal-provider), [GraphQL](https://github.com/Shopify/quilt/tree/master/packages/react-graphql-universal-provider), [I18n](https://github.com/Shopify/quilt/tree/master/packages/react-i18n-universal-provider), and the [Shopify App Bridge](https://github.com/Shopify/quilt/tree/master/packages/react-app-bridge-universal-provider).
345
326
 
346
- ### Customizing the node server
327
+ #### Customizing the node server
347
328
 
348
329
  By default, sewing-kit bundles in `@shopify/react-server-webpack-plugin` for `quilt_rails` applications to get apps up and running fast without needing to manually write any node server code. If what it provides is not sufficient, a custom server can be defined by adding a `server.js` or `server.ts` file to the app folder.
349
330
 
@@ -380,7 +361,7 @@ const app = createServer({
380
361
  export default app;
381
362
  ```
382
363
 
383
- ### Fixing rejected CSRF tokens for new user sessions
364
+ #### Fixing rejected CSRF tokens for new user sessions
384
365
 
385
366
  If a React component calls back to a Rails endpoint (e.g., `/graphql`), Rails may throw a `Can't verify CSRF token authenticity` exception. This stems from the Rails CSRF tokens not persisting until after the first `UiController` call ends.
386
367
 
@@ -404,3 +385,336 @@ class GraphqlController < ApplicationController
404
385
  end
405
386
  end
406
387
  ```
388
+
389
+ ## Performance tracking a React app
390
+
391
+ Using [`Quilt::Performance::Reportable`](#performanceReportable) and [@shopify/react-performance](https://www.npmjs.com/package/@shopify/react-performance) it's easy to add performance tracking to apps using[`sewing_kit`](https://www.npmjs.com/package/@shopify/sewing-kit) for client-side-rendering or `quilt_rails` for server-side-rendering.
392
+
393
+ ### Install dependencies
394
+
395
+ 1. Install the gem (if your app is not already using `quilt_rails`).
396
+
397
+ ```bash
398
+ bundle add quilt_rails
399
+ ```
400
+
401
+ 2. Install `@shopify/react-performance`, the library we will use to annotate our React application and send performance reports to our server.
402
+
403
+ ```bash
404
+ yarn add @shopify/react-performance
405
+ ```
406
+
407
+ ### Setup an endpoint for performance reports
408
+
409
+ If your application is not using `Quilt::Engine`, you will need to manually configure the server-side portion of performance tracking. If it _is_ using the engine, the following will be done automatically.
410
+
411
+ 1. Add a `PerformanceController` and the corresponding routes to your Rails app.
412
+
413
+ ```ruby
414
+ # app/controllers/performance_report_controller.rb
415
+
416
+ class PerformanceReportController < ActionController::Base
417
+ include Quilt::Performance::Reportable
418
+ protect_from_forgery with: :null_session
419
+
420
+ def create
421
+ process_report
422
+
423
+ render json: { result: 'success' }, status: 200
424
+ rescue ActionController::ParameterMissing => error
425
+ render json: { error: error.message, status: 422 }
426
+ end
427
+ end
428
+ ```
429
+
430
+ 2. Add a route pointing at the controller.
431
+
432
+ ```ruby
433
+ # config/routes.rb
434
+
435
+ post '/performance_report', to: 'performance_report#create'
436
+
437
+ # rest of routes
438
+ ```
439
+
440
+ ### Add annotations
441
+
442
+ Add a [`<PerformanceMark />`](https://github.com/Shopify/quilt/tree/master/packages/react-performance#performancemark) to each of your route-level components.
443
+
444
+ ```tsx
445
+ // app/ui/features/Home/Home.tsx
446
+ import {PerformanceMark} from '@shopify/react-performance';
447
+
448
+ export function Home() {
449
+ return (
450
+ <div>
451
+ My Cool Home Page
452
+ {/* tell the library the page has finished rendering completely */}
453
+ <PerformanceMark stage="complete" id="Home" />
454
+ </div>
455
+ );
456
+ }
457
+ ```
458
+
459
+ ### Send the report
460
+
461
+ Add a [`usePerformanceReport`](https://github.com/Shopify/quilt/tree/master/packages/react-performance#usePerformanceReport) call to your top-level `<App />` component.
462
+
463
+ ```tsx
464
+ // app/ui/foundation/App/App.tsx
465
+ import {usePerformanceReport} from '@shopify/react-performance';
466
+
467
+ export function App() {
468
+ // send the report to the server
469
+ usePerformanceReport('/performance_report');
470
+
471
+ return <>{/* your app JSX goes here*/}</>;
472
+ }
473
+ ```
474
+
475
+ For more details on how to use the APIs from `@shopify/react-performance` check out its [documentation](https://github.com/Shopify/quilt/tree/master/packages/react-performance).
476
+
477
+ ### Verify in development
478
+
479
+ By default `quilt_rails` will not send metrics in development mode. To verify your app is setup correctly you can check in your network tab when visiting your application and see that POST requests are sent to `/performance_report`, and recieve a `200 OK` response.
480
+
481
+ If you want more insight into what distributions _would_ be sent in production, you can use the `on_distribution` callback provided by the library to setup logging.
482
+
483
+ ```ruby
484
+ # app/controllers/performance_report_controller.rb
485
+
486
+ class PerformanceReportController < ActionController::Base
487
+ include Quilt::Performance::Reportable
488
+ protect_from_forgery with: :null_session
489
+
490
+ def create
491
+ # customize process_report's behaviour with a block
492
+ process_report do |client|
493
+ client.on_distribution do |name, value, tags|
494
+ # We log out the details of each distribution that would be sent in production.
495
+ Rails.logger.debug("Distribution: #{name}, #{value}, #{tags}")
496
+ end
497
+ end
498
+
499
+ render json: { result: 'success' }, status: 200
500
+ rescue ActionController::ParameterMissing => error
501
+ render json: { error: error.message, status: 422 }
502
+ end
503
+ end
504
+ ```
505
+
506
+ Now you can check your Rails console output and verify that metrics are reported as expected.
507
+
508
+ ### Configure StatsD for production
509
+
510
+ > Attention Shopifolk! If using `dev` your `StatsD` endpoint will already be configured for you in production. You should not need to do the following. ✨
511
+
512
+ To tell `Quilt::Performance::Reportable` where to send it's distributions, setup the environment variables detailed [documentation](https://github.com/Shopify/statsd-instrument#configuration).
513
+
514
+ ## API
515
+
516
+ ### ReactRenderable
517
+
518
+ The `ReactRenderable` mixin is intended to be used in Rails controllers, and provides only the `render_react` method. This method handles proxying to a running `@shopify/react-server`.
519
+
520
+ ```ruby
521
+ class ReactController < ApplicationController
522
+ include Quilt::ReactRenderable
523
+
524
+ def index
525
+ render_react
526
+ end
527
+ end
528
+ ```
529
+
530
+ ### Performance
531
+
532
+ #### Reportable
533
+
534
+ The `Quilt::Performance::Reportable` mixin is intended to be used in Rails controllers, and provides only the `process_report` method. This method handles parsing an incoming report from [@shopify/react-performance's](https://www.npmjs.com/package/@shopify/react-performance) `<PerformanceReport />` component (or a custom report in the same format) and sending it to your application's StatsD endpoint as `distribution`s using [`StatsD-Instrument`](https://rubygems.org/gems/statsd-instrument).
535
+
536
+ > **Note** `Quilt::Performance::Reportable` does not require you to use the `React::Renderable` mixin, React-Server, or even any server-side-rendering solution at all. It should work perfectly fine for applications using something like `sewing_kit_script_tag` based client-side-rendering.
537
+
538
+ ```ruby
539
+ class PerformanceController < ApplicationController
540
+ include Quilt::Performance::Reportable
541
+
542
+ def create
543
+ process_report
544
+ end
545
+ end
546
+ ```
547
+
548
+ The params sent to the controller are expected to be of type `application/json`. Given the following example JSON sent by `@shopify/react-performance`,
549
+
550
+ ```json
551
+ {
552
+ "connection": {
553
+ "rtt": 100,
554
+ "downlink": 2,
555
+ "effectiveType": "3g",
556
+ "type": "4g"
557
+ },
558
+ "navigations": [
559
+ {
560
+ "details": {
561
+ "start": 12312312,
562
+ "duration": 23924,
563
+ "target": "/",
564
+ "events": [
565
+ {
566
+ "type": "script",
567
+ "start": 23123,
568
+ "duration": 124
569
+ },
570
+ {
571
+ "type": "style",
572
+ "start": 23,
573
+ "duration": 14
574
+ }
575
+ ],
576
+ "result": 0
577
+ },
578
+ "metadata": {
579
+ "index": 0,
580
+ "supportsDetailedTime": true,
581
+ "supportsDetailedEvents": true
582
+ }
583
+ }
584
+ ],
585
+ "events": [
586
+ {
587
+ "type": "ttfb",
588
+ "start": 2,
589
+ "duration": 1000
590
+ }
591
+ ]
592
+ }
593
+ ```
594
+
595
+ the above controller would send the following metrics:
596
+
597
+ ```ruby
598
+ StatsD.distribution('time_to_first_byte', 2, ['browser_connection_type:3g'])
599
+ StatsD.distribution('time_to_first_byte', 2, ['browser_connection_type:3g'])
600
+ StatsD.distribution('navigation_complete', 23924, ['browser_connection_type:3g'])
601
+ StatsD.distribution('navigation_usable', 23924, ['browser_connection_type:3g'])
602
+ ```
603
+
604
+ ##### Customizing `process_report` with a block
605
+
606
+ The behaviour of `process_report` can be customized by manipulating the `Quilt::Performance::Client` instance yielded into its implicit block parameter.
607
+
608
+ ```ruby
609
+ process_report do |client|
610
+ # client.on_distribution do ....
611
+ end
612
+ ```
613
+
614
+ #### Client
615
+
616
+ The `Quilt::Performance::Client` class is yielded into the block parameter for `process_report`, and is the primary API for customizing what metrics are sent for a given POST.
617
+
618
+ ##### Client#on_distribution
619
+
620
+ The `on_distribution` method takes a block which is run for each distribution (including custom ones) sent during `process_report`.
621
+
622
+ The provided callback can be used to easily add logging or other side-effects to your measurements.
623
+
624
+ ```ruby
625
+ client.on_distribution do |metric_name, value, tags|
626
+ Rails.logger.debug "#{metric_name}: #{value}, tags: #{tags}"
627
+ end
628
+ ```
629
+
630
+ ##### Client#on_navigation
631
+
632
+ The `on_navigation` method takes a block which is run once per navigation reported to the performance controller _before_ the default distributions for the navigation are sent.
633
+
634
+ The provided callback can be used to add tags to the default `distributions` for a given navigation.
635
+
636
+ ```ruby
637
+ client.on_navigation do |navigation, tags|
638
+ # add tags to be sent with each distribution for this navigation
639
+ tags[:connection_rtt] = navigation.connection.rtt
640
+ tags[:connection_type] = navigation.connection.type
641
+ tags[:navigation_target] = navigation.target
642
+ end
643
+ ```
644
+
645
+ It can also be used to compute and send entirely custom metrics.
646
+
647
+ ```ruby
648
+ client.on_navigation do |navigation, tags|
649
+ # calculate and then send an additional distribution
650
+ weight = navigation.events_with_size.reduce(0) do |total, event|
651
+ total + event.size
652
+ end
653
+ client.distribution('navigation_total_resource_weight', weight, tags)
654
+ end
655
+ ```
656
+
657
+ ##### Client#on_event
658
+
659
+ The `on_event` method takes a block which is run once per event reported to the performance controller _before_ the default distributions for the event are sent.
660
+
661
+ The provided callback can be used to add tags to the default `distributions` for a given event, or perform other side-effects.
662
+
663
+ ```ruby
664
+ client.on_event do |event, tags|
665
+ # add tags to be sent with each distribution for this event
666
+ tags[:connection_rtt] = event.connection.rtt
667
+ tags[:connection_type] = event.connection.type
668
+ end
669
+ ```
670
+
671
+ ### Engine
672
+
673
+ `Quilt::Engine` provides:
674
+
675
+ - a preconfigured `UiController` which consumes `ReactRenderable`
676
+ - a preconfigured `PerformanceReportController` which consumes `Performance::Reportable`
677
+ - a `/performance_report` route mapped to `performance_report#index`
678
+ - a catch-all index route mapped to the `UiController#index`
679
+
680
+ ```ruby
681
+ # config/routes.rb
682
+ Rails.application.routes.draw do
683
+ # ...
684
+ mount Quilt::Engine, at: '/my-front-end'
685
+ end
686
+ ```
687
+
688
+ The above is the equivalent of
689
+
690
+ ```ruby
691
+ post '/my-front-end/performance_report', to: 'performance_report#create'
692
+ get '/my-front-end/*path', to: 'ui#index'
693
+ get '/my-front-end', to: 'ui#index'
694
+ ```
695
+
696
+ ### Configuration
697
+
698
+ The `configure` method allows customization of the address the service will proxy to for UI rendering.
699
+
700
+ ```ruby
701
+ # config/initializers/quilt.rb
702
+ Quilt.configure do |config|
703
+ config.react_server_host = "localhost:3000"
704
+ config.react_server_protocol = 'https'
705
+ end
706
+ ```
707
+
708
+ ### StatsD environment variables
709
+
710
+ The `Performance::Reportable` mixin uses [https://github.com/Shopify/statsd-instrument](StatsD-Instrument) to send distributions. For detailed instructions on configuring where it sends data see [the documentation](https://github.com/Shopify/statsd-instrument#configuration).
711
+
712
+ ### Generators
713
+
714
+ #### `quilt:install`
715
+
716
+ Installs the Node dependencies, provide a basic React app (in TypeScript) and mounts the Quilt engine in `config/routes.rb`.
717
+
718
+ #### `sewing_kit:install`
719
+
720
+ Adds a basic `sewing-kit.config.ts` file.
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ class PerformanceReportController < ActionController::Base
5
+ include Quilt::Performance::Reportable
6
+ protect_from_forgery with: :null_session
7
+
8
+ def create
9
+ process_report
10
+
11
+ render json: { result: 'success' }, status: 200
12
+ rescue ActionController::ParameterMissing => error
13
+ render json: { error: error.message, status: 422 }
14
+ end
15
+ end
16
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Quilt::Engine.routes.draw do
4
+ post '/performance_report', to: 'performance_report#create'
4
5
  get '/*path', to: 'ui#index'
5
6
  root 'ui#index'
6
7
  end
@@ -7,5 +7,6 @@ require "quilt_rails/engine"
7
7
  require "quilt_rails/logger"
8
8
  require "quilt_rails/configuration"
9
9
  require "quilt_rails/react_renderable"
10
+ require "quilt_rails/performance"
10
11
  require "quilt_rails/trusted_ui_server_csrf_strategy"
11
12
  require "quilt_rails/monkey_patches/active_support_reloader" if Rails.env.development?
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module Quilt
3
4
  class Configuration
4
5
  attr_accessor :react_server_host, :react_server_protocol
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "quilt_rails/performance/event_metadata"
4
+ require "quilt_rails/performance/event"
5
+ require "quilt_rails/performance/connection"
6
+ require "quilt_rails/performance/navigation_metadata"
7
+ require "quilt_rails/performance/navigation"
8
+ require "quilt_rails/performance/report"
9
+ require "quilt_rails/performance/client"
10
+ require "quilt_rails/performance/reportable"
11
+
12
+ module Quilt
13
+ module Performance
14
+ LIFECYCLE = {
15
+ time_to_first_byte: 'time_to_first_byte',
16
+ time_to_first_contentful_paint: 'time_to_first_contentful_paint',
17
+ time_to_first_paint: 'time_to_first_paint',
18
+ dom_content_loaded: 'dom_content_loaded',
19
+ first_input_delay: 'first_input_delay',
20
+ load: 'dom_load',
21
+ }
22
+
23
+ NAVIGATION = {
24
+ complete: 'navigation_complete',
25
+ usable: 'navigation_usable',
26
+ download_size: 'navigation_download_size',
27
+ cache_effectiveness: 'navigation_cache_effectiveness',
28
+ }
29
+ end
30
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class Client
6
+ def initialize(report)
7
+ @report = report
8
+ @base_tags = {
9
+ browser_connection_type: report.connection.effective_type,
10
+ }.freeze
11
+
12
+ @distribution_callback = proc { |_name, _value, _tags| nil }
13
+ @event_callback = proc { |_event, _tags| {} }
14
+ @navigation_callback = proc { |_navigation, _tags| {} }
15
+ end
16
+
17
+ def self.send!(report)
18
+ client = Client.new(report)
19
+
20
+ # Allow the user to customize things
21
+ if block_given?
22
+ yield(client)
23
+ end
24
+
25
+ client.send(:process_report!)
26
+ end
27
+
28
+ def distribution(metric_name, value, tag_hash = {})
29
+ tags = tag_hash.map { |key, hash_value| "#{key}:#{hash_value}" }
30
+
31
+ @distribution_callback.call(metric_name, value, tags)
32
+ StatsD.distribution(metric_name, value, tags) unless Rails.env.dev?
33
+ end
34
+
35
+ def on_navigation(&block)
36
+ @navigation_callback = block
37
+ end
38
+
39
+ def on_event(&block)
40
+ @event_callback = block
41
+ end
42
+
43
+ def on_distribution(&block)
44
+ @distribution_callback = block
45
+ end
46
+
47
+ private
48
+
49
+ def process_report!
50
+ report_events
51
+ report_navigations
52
+ end
53
+
54
+ def report_events
55
+ @report.events.each do |event|
56
+ event_tags = @base_tags.dup
57
+ @event_callback.call(event, event_tags)
58
+
59
+ distribution(event.metric_name, event.value, event_tags)
60
+ end
61
+ end
62
+
63
+ def report_navigations
64
+ @report.navigations.each do |navigation|
65
+ tags = @base_tags.dup
66
+ @navigation_callback.call(navigation, tags)
67
+ send_default_navigation_distributions(navigation, tags)
68
+ send_conditional_navigation_distributions(navigation, tags)
69
+ end
70
+ end
71
+
72
+ def send_default_navigation_distributions(navigation, tags)
73
+ distribution(NAVIGATION[:complete], navigation.time_to_complete, tags)
74
+ distribution(NAVIGATION[:usable], navigation.time_to_usable, tags)
75
+ end
76
+
77
+ def send_conditional_navigation_distributions(navigation, tags)
78
+ size = navigation.total_download_size
79
+ distribution(NAVIGATION[:download_size], size, tags) unless size.nil?
80
+
81
+ effectiveness = navigation.cache_effectiveness
82
+ distribution(NAVIGATION[:cache_effectiveness], effectiveness, tags) unless effectiveness.nil?
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class Connection
6
+ attr_accessor :downlink
7
+ attr_accessor :effective_type
8
+ attr_accessor :rtt
9
+ attr_accessor :type
10
+
11
+ def self.from_params(params)
12
+ params.transform_keys! { |key| key.underscore.to_sym }
13
+
14
+ Connection.new(
15
+ downlink: params[:downlink],
16
+ effective_type: params[:effective_type],
17
+ rtt: params[:rtt],
18
+ type: params[:type]
19
+ )
20
+ end
21
+
22
+ def initialize(downlink:, effective_type:, rtt:, type:)
23
+ @downlink = downlink
24
+ @effective_type = effective_type
25
+ @rtt = rtt
26
+ @type = type
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class Event
6
+ TYPE = {
7
+ time_to_first_byte: 'ttfb',
8
+ time_to_first_paint: 'ttfp',
9
+ time_to_first_contentful_paint: 'ttfcp',
10
+ dom_content_loaded: 'dcl',
11
+ first_input_delay: 'fid',
12
+ load: 'load',
13
+ long_task: 'longtask',
14
+ usable: 'usable',
15
+ graphql: 'graphql',
16
+ script_download: 'script',
17
+ style_download: 'style',
18
+ }
19
+
20
+ attr_accessor :type
21
+ attr_accessor :start
22
+ attr_accessor :duration
23
+ attr_accessor :metadata
24
+ attr_accessor :connection
25
+
26
+ def self.from_params(params)
27
+ params.require([:type, :start, :duration])
28
+
29
+ attributes = {
30
+ type: params[:type],
31
+ start: params[:start],
32
+ duration: params[:duration],
33
+ metadata: nil,
34
+ }
35
+
36
+ if params[:metadata]
37
+ attributes[:metadata] = EventMetadata.from_params(params[:metadata])
38
+ end
39
+
40
+ Event.new(**attributes)
41
+ end
42
+
43
+ def initialize(type:, start:, duration:, metadata:)
44
+ @type = type
45
+ @start = start
46
+ @duration = duration
47
+ @metadata = metadata
48
+ end
49
+
50
+ def value
51
+ raw_value = if type == TYPE[:first_input_delay]
52
+ duration
53
+ else
54
+ start
55
+ end
56
+
57
+ raw_value.round
58
+ end
59
+
60
+ def metric_name
61
+ type_name = TYPE.key(type)
62
+ if LIFECYCLE[type_name].nil?
63
+ type
64
+ else
65
+ LIFECYCLE[type_name]
66
+ end
67
+ end
68
+
69
+ def has_metadata?
70
+ !metadata.nil?
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class EventMetadata
6
+ attr_accessor :name
7
+ attr_accessor :size
8
+
9
+ def self.from_params(params)
10
+ EventMetadata.new(
11
+ name: params[:name],
12
+ size: params[:size],
13
+ )
14
+ end
15
+
16
+ def initialize(name:, size:)
17
+ @name = name
18
+ @size = size
19
+ end
20
+
21
+ def has_size?
22
+ !size.nil?
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class Navigation
6
+ attr_accessor :start
7
+ attr_accessor :time_to_complete
8
+ attr_accessor :target
9
+ attr_accessor :events
10
+ attr_accessor :result
11
+ attr_accessor :metadata
12
+ attr_accessor :connection
13
+
14
+ def self.from_params(params)
15
+ params.transform_keys! { |key| key.underscore.to_sym }
16
+ params.require(:details)
17
+
18
+ attributes = {
19
+ start: params[:details][:start],
20
+ time_to_complete: params[:details][:duration],
21
+ target: params[:details][:target],
22
+ result: params[:details][:result],
23
+ events: (params[:details][:events] || []).map do |event|
24
+ Event.from_params(event)
25
+ end,
26
+ metadata: NavigationMetadata.from_params(params[:metadata] || {}),
27
+ }
28
+
29
+ Navigation.new(**attributes)
30
+ end
31
+
32
+ def initialize(
33
+ start:,
34
+ time_to_complete:,
35
+ target:,
36
+ result:,
37
+ events: [],
38
+ metadata: {}
39
+ )
40
+ @start = start
41
+ @time_to_complete = time_to_complete
42
+ @target = target
43
+ @result = result
44
+ @events = events
45
+ @metadata = metadata
46
+ end
47
+
48
+ def events_by_type(target_type)
49
+ events.select do |event|
50
+ event.type == target_type
51
+ end
52
+ end
53
+
54
+ def resource_events
55
+ style_events = events_by_type(Event::TYPE[:style_download])
56
+ script_events = events_by_type(Event::TYPE[:script_download])
57
+ style_events.concat(script_events)
58
+ end
59
+
60
+ def events_with_size
61
+ resource_events.select do |event|
62
+ event.has_metadata? && event.metadata.has_size?
63
+ end
64
+ end
65
+
66
+ def time_to_usable
67
+ usable_event = events_by_type(Event::TYPE[:usable]).first
68
+ return usable_event.start - start unless usable_event.nil?
69
+
70
+ time_to_complete
71
+ end
72
+
73
+ def total_download_size
74
+ events_with_size.reduce(nil) do |total, current|
75
+ current_size = current.metadata.size
76
+ return current_size + total unless total.nil?
77
+ current_size
78
+ end
79
+ end
80
+
81
+ def total_duration_by_event_type(type)
82
+ events = events_by_type(type)
83
+
84
+ events.reduce(0) do |total, current_event|
85
+ total + (current_event.duration || 0)
86
+ end
87
+ end
88
+
89
+ def cache_effectiveness
90
+ events = events_with_size
91
+
92
+ if events.empty? || events.any? { |event| event.metadata.size.nil? }
93
+ return nil
94
+ end
95
+
96
+ cached_events = events.select do |event|
97
+ # this is not actually checking the size of an array,
98
+ # there is no EventMetadata#any? method, so this check is being tripped erroneously.
99
+ # rubocop:disable Style/ZeroLengthPredicate
100
+ event.metadata.size == 0
101
+ # rubocop:enable
102
+ end
103
+ cached_events.length / events.length
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class NavigationMetadata
6
+ attr_accessor :index
7
+ attr_accessor :supports_detailed_time
8
+ attr_accessor :supports_detailed_events
9
+
10
+ def self.from_params(params = {})
11
+ NavigationMetadata.new(
12
+ index: params[:index],
13
+ supports_detailed_time: params[:supports_detailed_time],
14
+ supports_detailed_events: params[:supports_detailed_events],
15
+ )
16
+ end
17
+
18
+ def initialize(index:, supports_detailed_events:, supports_detailed_time:)
19
+ @index = index
20
+ @supports_detailed_time = supports_detailed_time
21
+ @supports_detailed_events = supports_detailed_events
22
+ end
23
+
24
+ def has_size?
25
+ !size.nil?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ class Report
6
+ attr_accessor :events
7
+ attr_accessor :navigations
8
+ attr_accessor :connection
9
+
10
+ def self.from_params(params)
11
+ params.transform_keys! { |key| key.underscore.to_sym }
12
+ params.require(:connection)
13
+
14
+ connection = Connection.from_params(params[:connection])
15
+
16
+ Report.new(
17
+ connection: connection,
18
+ navigations: (params[:navigations] || []).map do |navigation|
19
+ navigation = Navigation.from_params(navigation)
20
+ navigation.connection = connection
21
+ navigation
22
+ end,
23
+ events: (params[:events] || []).map do |event|
24
+ event = Event.from_params(event)
25
+ event.connection = connection
26
+ event
27
+ end,
28
+ )
29
+ end
30
+
31
+ def initialize(events:, navigations:, connection:)
32
+ @events = events
33
+ @navigations = navigations
34
+ @connection = connection
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quilt
4
+ module Performance
5
+ module Reportable
6
+ def process_report(&block)
7
+ params.transform_keys! { |key| key.underscore.to_sym }
8
+ Client.send!(Report.from_params(params), &block)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -47,12 +47,15 @@ module Quilt
47
47
  def initialize(url)
48
48
  # rubocop:disable LineLength
49
49
  super "Errno::ECONNREFUSED: Waiting for React server to boot up. If this error presists verify that @shopify/react-server is configured on #{url}"
50
+ # rubocop:enable LineLength
50
51
  end
51
52
  end
52
53
 
53
54
  class DoNotIntegrationTestError < StandardError
54
55
  def initialize
56
+ # rubocop:disable LineLength
55
57
  super "Do not try to use Rails integration tests on your quilt_rails app. Instead use Jest and @shopify/react-testing to test your React application directly."
58
+ # rubocop:enable LineLength
56
59
  end
57
60
  end
58
61
  end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Quilt
3
- VERSION = "1.8.0"
3
+ VERSION = "1.9.0"
4
4
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quilt_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.0
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mathew Allen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-09-25 00:00:00.000000000 Z
11
+ date: 2019-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: railties
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: 0.9.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: statsd-instrument
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 2.7.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 2.7.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rubocop
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -76,6 +90,7 @@ extra_rdoc_files: []
76
90
  files:
77
91
  - README.md
78
92
  - Rakefile
93
+ - app/controllers/quilt/performance_report_controller.rb
79
94
  - app/controllers/quilt/ui_controller.rb
80
95
  - config/routes.rb
81
96
  - lib/generators/quilt/USAGE
@@ -91,6 +106,15 @@ files:
91
106
  - lib/quilt_rails/engine.rb
92
107
  - lib/quilt_rails/logger.rb
93
108
  - lib/quilt_rails/monkey_patches/active_support_reloader.rb
109
+ - lib/quilt_rails/performance.rb
110
+ - lib/quilt_rails/performance/client.rb
111
+ - lib/quilt_rails/performance/connection.rb
112
+ - lib/quilt_rails/performance/event.rb
113
+ - lib/quilt_rails/performance/event_metadata.rb
114
+ - lib/quilt_rails/performance/navigation.rb
115
+ - lib/quilt_rails/performance/navigation_metadata.rb
116
+ - lib/quilt_rails/performance/report.rb
117
+ - lib/quilt_rails/performance/reportable.rb
94
118
  - lib/quilt_rails/react_renderable.rb
95
119
  - lib/quilt_rails/trusted_ui_server_csrf_strategy.rb
96
120
  - lib/quilt_rails/version.rb