hibiki_rails 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83e9656d09f3910405a4db7167b526c173158eb1035d5cb5129e4f916c88c278
4
- data.tar.gz: e22b25605c6522e98cfdc86dcd685e62b3c60ed31ca865eb2daa53f74aaaf946
3
+ metadata.gz: 915a98a11ca62685f60697d27383e8592978e0c2173b0e984c24eca7736cef55
4
+ data.tar.gz: 070cd14175c3027b164ec0b52378b59178499f3f3474fe3296722f6432c580ab
5
5
  SHA512:
6
- metadata.gz: 8c459eb9453de6af88c6e7859849a41dcbf7c0daa5d2e79da12e5ac8f729f4b85093e9d10c176d97cb8cd5141ef47ffeb0bcb2482aa03b223e42b2af4b13d9a6
7
- data.tar.gz: 7ccd090ef5fdd72735d7bb45d356a1f7085fb6c323d42692c91b7226a5fd6ac058f0ea477efaa6d7693dfbfe66254d85c358e8ea5ca7d3fcd49f6fde56366040
6
+ metadata.gz: 5d07fa8e0a897a21cce85e559e42a02dc78090f89986bc37c24202a36fb81322287a1ae86fb4c5166aa615a4ee8924732bb63020643a2e4db8e8ec72a5d870eb
7
+ data.tar.gz: 993b20b7af2d105e5c067823621eb8050ff426a57a9ac0b0aacccbd2a9fd9136a9a2a6f0f635d7932952f7c4c58bef2645492c67fe1b79acbb28d1c2e86f5a37
data/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ The gem and the npm package are released in lockstep and share these version
4
4
  numbers — `app/assets/javascripts/hibiki.js` is a single copy served both ways,
5
5
  so importmap and bundler apps always resolve identical client code.
6
6
 
7
+ ## 0.18.0 — 2026-10-11
8
+
9
+ ### Added
10
+
11
+ **Instrumentation events.** A channel now publishes
12
+ `ActiveSupport::Notifications` events, so an app can time what a graph does.
13
+ Action Cable's own `perform_action.action_cable` covers only the hand-off to
14
+ the graph's thread; the action, its effects and its renders run after it.
15
+
16
+ | Event | Duration covers | Payload |
17
+ |---|---|---|
18
+ | `subscribe.hibiki_rails` | building the graph and its first renders | `channel` |
19
+ | `unsubscribe.hibiki_rails` | disposing the graph | `channel` |
20
+ | `action.hibiki_rails` | the action and every effect it flushed | `channel`, `action`, `wait`, `renders` |
21
+ | `render.hibiki_rails` | one `broadcast_replace`, `broadcast_morph` or `broadcast_refresh` | `channel`, `target`, `method` |
22
+
23
+ - The events are always on and need no configuration. The gem subscribes to
24
+ none of them. With no subscriber an action costs one to two microseconds
25
+ more than in 0.17.2.
26
+ - A subscriber runs on the graph's thread, so keep it to cheap work in
27
+ memory.
28
+ - An action the channel does not have publishes nothing. A raising action
29
+ publishes, with the exception in the payload.
30
+ - `transmit_value` and `transmit_url` have no event of their own; Action
31
+ Cable's `transmit.action_cable` covers them.
32
+
33
+ **`Hibiki::Rails.registry.tally`** returns the live subscriptions by channel
34
+ class name.
35
+
36
+ **Trips: how long a visitor waited.** With `HibikiController.reportTrips =
37
+ true`, each island times its round trips and sends them with its next action.
38
+ The channel publishes one `trip.hibiki_rails` event for each, with `channel`,
39
+ `action`, `ack` (milliseconds until the server answered) and `paint`
40
+ (milliseconds until the first render arrived, or `nil`).
41
+
42
+ - Off by default. Set it once, on the class, for every island.
43
+ - A dropped or stalled trip is not reported, and neither is the last trip on
44
+ a page, since no action follows it.
45
+ - The figures come from the browser. The channel takes at most five per
46
+ action, only for its own actions, and clamps each to 0 to 60 000 ms.
47
+ - `hbt` joins `hbk` as a reserved payload key. The channel removes it before
48
+ the action method sees its data.
49
+
7
50
  ## 0.17.2 — 2026-10-11
8
51
 
9
52
  ### Fixed
data/README.md CHANGED
@@ -125,6 +125,52 @@ The generated components are just Rails partials (or Phlex components, if you us
125
125
 
126
126
  Congratulations! Now you have your first reactive component!
127
127
 
128
+ ## Instrumentation
129
+
130
+ A channel publishes `ActiveSupport::Notifications` events. They are always
131
+ on, and the gem subscribes to none of them.
132
+
133
+ | Event | Duration covers | Payload |
134
+ |---|---|---|
135
+ | `subscribe.hibiki_rails` | building the graph and its first renders | `channel` |
136
+ | `unsubscribe.hibiki_rails` | disposing the graph | `channel` |
137
+ | `action.hibiki_rails` | the action and every effect it flushed | `channel`, `action`, `wait`, `renders` |
138
+ | `render.hibiki_rails` | rendering and handing the stream to Action Cable | `channel`, `target`, `method` |
139
+ | `trip.hibiki_rails` | nothing | `channel`, `action`, `ack`, `paint` |
140
+
141
+ ```ruby
142
+ ActiveSupport::Notifications.subscribe("action.hibiki_rails") do |event|
143
+ Rails.logger.info "#{event.payload[:channel]}##{event.payload[:action]} #{event.duration.round(1)}ms"
144
+ end
145
+ ```
146
+
147
+ - `channel` is the class name. `wait` is the milliseconds the action queued
148
+ for the graph's thread. `renders` is how many renders it caused.
149
+ - `method` is `:replace`, `:morph` or `:refresh`. A refresh has no `target`.
150
+ - An action the channel does not have publishes nothing. A raising action
151
+ publishes, with the exception in the payload.
152
+ - Action Cable's `broadcast.action_cable` fires inside the render event, on
153
+ the same thread, and carries the message if you want its size.
154
+ - `Hibiki::Rails.registry.tally` returns the live subscriptions by channel
155
+ class, and `registry.size` the total.
156
+
157
+ **A subscriber runs on the graph's thread.** Keep it to cheap work in
158
+ memory; anything slow holds up that visitor's actions.
159
+
160
+ A `trip` is the browser's own timing of one action: `ack` is the
161
+ milliseconds until the server answered and `paint` the milliseconds until
162
+ its first render arrived, or `nil` when nothing rendered. Reporting is off
163
+ until the app turns it on:
164
+
165
+ ```js
166
+ import HibikiController from "hibiki-rails"
167
+ HibikiController.reportTrips = true
168
+ ```
169
+
170
+ Each island then sends its last trips with its next action. The figures come
171
+ from visitors' browsers, so treat them as reported: the gem bounds them (five
172
+ per action, known action names, 0 to 60 000 ms) and cannot verify them.
173
+
128
174
  ## Documentation
129
175
 
130
176
  Documentation site: <https://planetaska.github.io/hibiki/rails-introduction/>
@@ -284,6 +284,10 @@ export class ChannelController extends Controller {
284
284
  static busyCeiling = 10000
285
285
  static focusGrace = 500
286
286
 
287
+ // Whether to tell the server how long each trip took (see "Trips"
288
+ // below). Off unless an app sets it, once, for every island.
289
+ static reportTrips = false
290
+
287
291
  async connect() {
288
292
  this.prepareTransport()
289
293
  await this.openSubscription()
@@ -302,6 +306,7 @@ export class ChannelController extends Controller {
302
306
  this.seq = 0
303
307
  this.renders = 0
304
308
  this.busy = new Map()
309
+ this.trips = []
305
310
  this.settledAt = -Infinity
306
311
  this.lastControl = null
307
312
  this.queued = []
@@ -345,7 +350,8 @@ export class ChannelController extends Controller {
345
350
  // a sequence number under the reserved `hbk` key and opens a busy record
346
351
  // that the matching ack closes; the seq is stamped LAST so a form field
347
352
  // can never overwrite it. (`hbk` is the second reserved payload key —
348
- // ActionCable's own Subscription#perform already writes `action`.)
353
+ // ActionCable's own Subscription#perform already writes `action`. `hbt`
354
+ // is the third: the trips timed since the last perform.)
349
355
  //
350
356
  // Public API on the island controller (the header's "App JS reaching
351
357
  // the graph"). Returns the seq when the action was accepted, so a
@@ -356,8 +362,10 @@ export class ChannelController extends Controller {
356
362
  perform(action, payload = {}) {
357
363
  const seq = ++this.seq
358
364
  payload.hbk = seq
365
+ delete payload.hbt
359
366
  if (this.subscribed) {
360
- this.beginBusy(seq)
367
+ this.sendTrips(payload)
368
+ this.beginBusy(seq, action)
361
369
  // Subscription#perform returns false when the socket is not open —
362
370
  // the gap between the socket dying and the connection monitor
363
371
  // noticing, during which `subscribed` still says live. The frame
@@ -383,7 +391,8 @@ export class ChannelController extends Controller {
383
391
  // whole gap — the signal the connect window cannot give, where the
384
392
  // page is painted and looks live.
385
393
  if (!this.connectedOnce) {
386
- this.beginBusy(seq)
394
+ this.sendTrips(payload)
395
+ this.beginBusy(seq, action)
387
396
  this.queued.push([action, payload])
388
397
  return seq
389
398
  }
@@ -430,9 +439,11 @@ export class ChannelController extends Controller {
430
439
  // means the server has not answered, and a hold is motion after the
431
440
  // answer.
432
441
 
433
- beginBusy(seq) {
442
+ beginBusy(seq, action) {
434
443
  const record = {
435
444
  control: null,
445
+ action,
446
+ at: performance.now(),
436
447
  // Which render the island was on when this started, so the ack can
437
448
  // ask "has anything painted since?" without consulting a clock.
438
449
  renders: this.renders,
@@ -486,6 +497,7 @@ export class ChannelController extends Controller {
486
497
  if (this.state === "stalled") this.setState("ready")
487
498
  const record = this.busy.get(ack)
488
499
  if (!record) return
500
+ if (!dropped) record.ack = Math.round(performance.now() - record.at)
489
501
  if (dropped || this.renders > record.renders) return this.settle(ack)
490
502
  record.grace = setTimeout(() => this.settle(ack), this.constructor.busyGrace)
491
503
  }
@@ -499,6 +511,7 @@ export class ChannelController extends Controller {
499
511
  this.settledAt = performance.now()
500
512
  record.control?.removeAttribute("data-hibiki-busy")
501
513
  if (this.busy.size === 0) this.hideBusy()
514
+ if (record.ack !== undefined && this.constructor.reportTrips) this.keepTrip(record)
502
515
  }
503
516
 
504
517
  settleAll() {
@@ -528,13 +541,49 @@ export class ChannelController extends Controller {
528
541
  this.setState("stalled")
529
542
  }
530
543
 
544
+ // ── Trips ─────────────────────────────────────────────────────────────
545
+ //
546
+ // Only the browser sees both halves of a trip land: the ack on the
547
+ // island's socket and the render after its pubsub hop. With reportTrips
548
+ // on, each acked trip is kept as its action, the ms to the ack and the ms
549
+ // to the first render of its own (null when nothing rendered), and the
550
+ // next perform carries the last five under `hbt`. A dropped or stalled
551
+ // trip is not reported, and the last trip on a page never is.
552
+
553
+ keepTrip({ action, at, ack, paint }) {
554
+ this.trips.push({ action, at, ack, paint, settled: this.settledAt })
555
+ if (this.trips.length > 5) this.trips.shift()
556
+ }
557
+
558
+ sendTrips(payload) {
559
+ if (!this.trips.length) return
560
+ payload.hbt = this.trips.map(({ action, ack, paint }) => [action, ack, paint ?? null])
561
+ this.trips = []
562
+ }
563
+
564
+ // A render of this island's own has arrived. Timed now, not when it is
565
+ // applied: the order and motion holds come after the answer. A trip that
566
+ // settled a moment ago still takes it, by the rule own() uses.
567
+ arrived() {
568
+ const now = performance.now()
569
+ for (const record of this.busy.values()) record.paint ??= Math.round(now - record.at)
570
+ for (const trip of this.trips) {
571
+ if (trip.paint === undefined && now - trip.settled <= this.constructor.focusGrace) {
572
+ trip.paint = Math.round(now - trip.at)
573
+ }
574
+ }
575
+ }
576
+
531
577
  // Every inbound frame lands here first. Acks are transport bookkeeping,
532
578
  // handled by the base and deliberately NOT routed through received() — so
533
579
  // a subclass that overrides received without calling super still clears
534
580
  // its pending state.
535
581
  handleMessage(data) {
536
582
  if (data && "ack" in data) return this.acknowledge(data)
537
- if (data && data.html) this.renders++
583
+ if (data && data.html) {
584
+ this.renders++
585
+ this.arrived()
586
+ }
538
587
  this.received(data)
539
588
  }
540
589
 
@@ -855,6 +904,9 @@ export default class HibikiController extends ChannelController {
855
904
  this.streamRender = (event) => {
856
905
  const render = event.detail.render
857
906
  const stream = event.detail.newStream
907
+ if (stream?.getAttribute?.("data-hibiki-from") === `${this.channelValue}/${this.cidValue}`) {
908
+ this.arrived()
909
+ }
858
910
  // Taken now, not when the render's turn comes: see arrival().
859
911
  const arrival = this.arrival()
860
912
  event.detail.render = (streamElement) =>
@@ -13,14 +13,19 @@ module Hibiki
13
13
  # All private — a public helper would become a client-invocable action
14
14
  # (see the NOTE on Hibiki::Rails::Channel).
15
15
  module Broadcasts
16
+ include Instrumentation
17
+
16
18
  private
17
19
 
18
20
  # Replace the element with DOM id `target` (accepts partial:/locals:,
19
21
  # html:, or anything Turbo's renderer takes).
20
22
  def broadcast_replace(target:, attributes: {}, **rendering)
21
- Turbo::StreamsChannel.broadcast_replace_to(
22
- *stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
23
- )
23
+ method = attributes[:method].to_s == "morph" ? :morph : :replace
24
+ instrument_render(target:, method:) do
25
+ Turbo::StreamsChannel.broadcast_replace_to(
26
+ *stream_name, target:, attributes: attributes.merge(render_stamp), **rendering
27
+ )
28
+ end
24
29
  end
25
30
 
26
31
  # Replace via Turbo 8 morphing (<turbo-stream action="replace"
@@ -32,7 +37,7 @@ module Hibiki
32
37
  # Tell the page to refresh itself (Turbo 8 morph-everything style).
33
38
  # Pair with Debounce when many writes should mean one refresh.
34
39
  def broadcast_refresh
35
- Turbo::StreamsChannel.broadcast_refresh_to(*stream_name)
40
+ instrument_render(method: :refresh) { Turbo::StreamsChannel.broadcast_refresh_to(*stream_name) }
36
41
  end
37
42
 
38
43
  # The Morph-everything style as one call: an effect that tracks
@@ -77,13 +77,20 @@ module Hibiki
77
77
  # produces zero bytes, and "clear on the next render" would hang
78
78
  # forever. `delete`, not `[]`: the key must not reach the action
79
79
  # method's data. No `hbk`, no ack, so a 0.3.0 page keeps working.
80
+ #
81
+ # `hbt` is the third reserved key: the trips the browser timed since
82
+ # its last action (see Instrumentation#trips_from). Deleted whether or
83
+ # not anyone listens, for the same reason as `hbk`.
80
84
  def perform_action(data)
81
85
  seq = data.delete("hbk")
86
+ trips = trips_from(data.delete("hbt"))
87
+ asked = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
88
 
83
89
  # A nil actor means the subscription was rejected or already torn
84
90
  # down; drop the action rather than blow up the cable thread.
85
91
  posted = @__hibiki_actor&.post do
86
- Hibiki.batch { super(data) }
92
+ trips.each { instrument("trip", it) }
93
+ instrument_action(data, asked) { Hibiki.batch { super(data) } }
87
94
  ensure
88
95
  # Hibiki.batch flushes in its own ensure, so effects queued
89
96
  # before a raise have run by the time this does — the ack is
@@ -111,7 +118,9 @@ module Hibiki
111
118
  # The block runs owned + untracked (Hibiki.root); the effects it
112
119
  # creates do their dependency-collecting first run right here, on
113
120
  # the graph thread.
114
- @__hibiki_actor.post { @__hibiki_root = Hibiki.root { build_graph } }
121
+ @__hibiki_actor.post do
122
+ instrument("subscribe") { @__hibiki_root = Hibiki.root { build_graph } }
123
+ end
115
124
  Hibiki::Rails.registry.register(self)
116
125
  end
117
126
 
@@ -123,7 +132,7 @@ module Hibiki
123
132
  return unless @__hibiki_actor
124
133
 
125
134
  Hibiki::Rails.registry.unregister(self)
126
- @__hibiki_actor.post { @__hibiki_root.dispose }
135
+ @__hibiki_actor.post { instrument("unsubscribe") { @__hibiki_root.dispose } }
127
136
  @__hibiki_actor.stop
128
137
  end
129
138
 
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hibiki
4
+ module Rails
5
+ # The channel's ActiveSupport::Notifications events (`*.hibiki_rails`).
6
+ # Always on: with no subscriber `instrument` only yields. The gem
7
+ # subscribes to none of them. A subscriber runs on the graph thread, so
8
+ # it holds up that graph for as long as it takes.
9
+ #
10
+ # All private — a public helper would become a client-invocable action
11
+ # (see the NOTE on Hibiki::Rails::Channel).
12
+ module Instrumentation
13
+ # The most trips one action may report, and the longest time one may
14
+ # claim, in ms. The figures come from a browser, so they are bounded
15
+ # rather than believed.
16
+ TRIP_LIMIT = 5
17
+ TRIP_RANGE = (0..60_000)
18
+
19
+ EVENTS = %w[subscribe unsubscribe action render trip].to_h { [it, "#{it}.hibiki_rails"] }.freeze
20
+
21
+ private
22
+
23
+ # With nobody listening this is one lookup and a yield; no payload is
24
+ # built.
25
+ def instrument(event, payload = nil, &)
26
+ return (yield({}) if block_given?) unless listening?(event)
27
+
28
+ ActiveSupport::Notifications.instrument(EVENTS.fetch(event), { channel: self.class.name, **payload }, &)
29
+ end
30
+
31
+ # `asked` is the clock as the cable thread posted the action; `wait` is
32
+ # how long the graph thread took to reach it, in ms. An action the
33
+ # channel does not have gets no event: the name is the client's, and a
34
+ # subscriber that stores it must not be handed arbitrary strings.
35
+ def instrument_action(data, asked)
36
+ action = data["action"].to_s
37
+ return yield unless listening?("action") && self.class.action_methods.include?(action)
38
+
39
+ before = @__hibiki_render_count.to_i
40
+ instrument("action", { action:, wait: ms_since(asked) }) do |payload|
41
+ yield
42
+ ensure
43
+ payload[:renders] = @__hibiki_render_count.to_i - before
44
+ end
45
+ end
46
+
47
+ def listening?(event) = ActiveSupport::Notifications.notifier.listening?(EVENTS.fetch(event))
48
+
49
+ def ms_since(clock) = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000
50
+
51
+ def instrument_render(method:, target: nil, &)
52
+ @__hibiki_render_count = @__hibiki_render_count.to_i + 1
53
+ instrument("render", { target:, method: }.compact, &)
54
+ end
55
+
56
+ # The trips a browser reported under `hbt`, as event payloads. Each is
57
+ # `[action, ack, paint]`: ms to the ack, and ms to the first render, or
58
+ # nil when nothing rendered.
59
+ def trips_from(reported)
60
+ reported.is_a?(Array) ? reported.first(TRIP_LIMIT).filter_map { trip(*it) } : []
61
+ end
62
+
63
+ def trip(action = nil, ack = nil, paint = nil, *)
64
+ return unless action.is_a?(String) && self.class.action_methods.include?(action)
65
+ return unless ack.is_a?(Numeric) && (paint.nil? || paint.is_a?(Numeric))
66
+
67
+ { action:, ack: ack.clamp(TRIP_RANGE), paint: paint&.clamp(TRIP_RANGE) }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -28,6 +28,13 @@ module Hibiki
28
28
  @lock.synchronize { @channels.size }
29
29
  end
30
30
 
31
+ # The live subscriptions by channel class name. Count from this rather
32
+ # than from subscribe and unsubscribe events: a development reload
33
+ # drops sockets without running `unsubscribed`.
34
+ def tally
35
+ @lock.synchronize { @channels.map { it.class.name }.tally }
36
+ end
37
+
31
38
  # Close each distinct cable connection, which routes every one of
32
39
  # its channels through the normal unsubscribed path — dispose root,
33
40
  # stop actor, unregister — so teardown has exactly ONE code path and
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Hibiki
4
4
  module Rails
5
- VERSION = "0.17.2"
5
+ VERSION = "0.18.0"
6
6
  end
7
7
  end
data/lib/hibiki/rails.rb CHANGED
@@ -68,6 +68,7 @@ require_relative "rails/version"
68
68
  require_relative "rails/registry"
69
69
  require_relative "rails/graph_actor"
70
70
  require_relative "rails/debounce"
71
+ require_relative "rails/instrumentation"
71
72
  require_relative "rails/broadcasts"
72
73
  require_relative "rails/channel"
73
74
  require_relative "rails/nested_actions"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hibiki_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.2
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - planetaska
@@ -187,6 +187,7 @@ files:
187
187
  - lib/hibiki/rails/engine.rb
188
188
  - lib/hibiki/rails/graph_actor.rb
189
189
  - lib/hibiki/rails/helpers.rb
190
+ - lib/hibiki/rails/instrumentation.rb
190
191
  - lib/hibiki/rails/keys.rb
191
192
  - lib/hibiki/rails/nested_actions.rb
192
193
  - lib/hibiki/rails/reactive_form.rb