dommy-rack 0.10.0 → 0.11.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: 3cc970f04543db2ddf8d988cceaeacfc644fa3b04773655767e477512ba85deb
4
- data.tar.gz: 5dd3d1215a44fd431b40fc62705c1ed8ef3d438b9321895c90980aa9cec574af
3
+ metadata.gz: 242882a8db41a841906a2292592dcf047941a3578e043cf9dc872d03b6649c77
4
+ data.tar.gz: 62adc178e7d8bffd1e4d923a13f75d130de05d12b953c43377c0d7dc48ee72c2
5
5
  SHA512:
6
- metadata.gz: 7628031ea4b93483bfedb41fb5f716bf7ab80e2155d9505250b618fb20175952035d4c7570d1403895be39c69064213d27507f3d845cd0a2f18486b587b9726d
7
- data.tar.gz: '0852d2605cb0919350c97a93e2d63992e856b3440831249632c66071787b9349def147cf08d3f862664f2fe8d1371807a14249a9637fc522f9ca5307a14e4e01'
6
+ metadata.gz: 41a58a2614a4608d75957cd777bf9e30d657cba682a90509c473627b50c5f88ad929c438afa10a5b54f641f26b7b4a8061280b567b84325ba23445543c895a32
7
+ data.tar.gz: 9e68474ae55c4051d0844da0ffea2c2df9e043a5e31cf86a6d52b805df78dd410110f3aab8bd006bc6690fc483474217154f1be3af037eab87c4e06c7e31dbf9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — 2026-09-11
4
+
5
+ ### Added
6
+ - `Session#dialog_handler=` — answer the page's `alert` / `confirm` / `prompt`. The handler stays installed across navigations, so it also answers dialogs on pages loaded later.
7
+ - Traced requests now carry spans from inside the app (controller, SQL, render, and the jobs and mails a request triggers), flushed with the request they belong to. See dommy-rails for the Rails-side instrumentation.
8
+
9
+ ### Fixed
10
+ - A request whose app raises still closes its trace bracket, so the trace ends with the failure instead of an open request.
11
+ - A subresource fetch made while a request is in flight keeps its own spans rather than attaching them to the outer request.
12
+ - SQL bind values, when enabled, are masked through the trace's own sensitive-key filter — the same one that masks form params.
13
+ - A snapshot's content is stored before its artifact event is written, so a trace read back straight away is complete.
14
+
3
15
  ## 0.10.0 — 2026-07-13
4
16
 
5
17
  ### Added
@@ -17,6 +17,13 @@ module Dommy
17
17
  # it as `last_request` and fires request listeners; an async path routes the
18
18
  # same notification through the scheduler inbox).
19
19
  # * `on_response` — observe the Response (response listeners).
20
+ # * `on_abort` — observe an env whose `@app.call` raised (no Response).
21
+ # * `on_app_start` / `on_app_finish` — bracket the app call ITSELF. Unlike
22
+ # the observation hooks above (which an async path routes through the
23
+ # scheduler inbox, i.e. LATER and on the page thread), these run inline,
24
+ # on whatever thread calls the app, so per-request state a hook installs
25
+ # for the app to find — the Trace's thread-local — is live exactly while
26
+ # the app runs. Anything they need to hand back travels on the env.
20
27
  #
21
28
  # Reload bookkeeping (`last_request_args`) and history/document application are
22
29
  # NOT here: they belong to the page thread and stay in Session / Navigation.
@@ -24,13 +31,17 @@ module Dommy
24
31
  # @param headers [HeaderStore, Hash] anything responding to `merge(overrides)
25
32
  # -> Hash`; the Session passes its live HeaderStore for the page path, a
26
33
  # plain snapshot Hash for a worker path.
27
- def initialize(app:, config:, cookie_jar:, headers:, on_request: nil, on_response: nil)
34
+ def initialize(app:, config:, cookie_jar:, headers:, on_request: nil, on_response: nil, on_abort: nil,
35
+ on_app_start: nil, on_app_finish: nil)
28
36
  @app = app
29
37
  @config = config
30
38
  @cookie_jar = cookie_jar
31
39
  @headers = headers
32
40
  @on_request = on_request
33
41
  @on_response = on_response
42
+ @on_abort = on_abort
43
+ @on_app_start = on_app_start
44
+ @on_app_finish = on_app_finish
34
45
  end
35
46
 
36
47
  # Perform one request and return its Response. `headers` are per-request
@@ -45,7 +56,20 @@ module Dommy
45
56
  cookie_string: @cookie_jar.cookies_for(absolute_url)
46
57
  )
47
58
  @on_request&.call(env)
48
- status, response_headers, response_body = @app.call(env)
59
+ # on_app_start opened a request bracket (the Trace exposes itself
60
+ # thread-locally inside it); an app exception must still close it, or
61
+ # per-request state leaks past the failed request. Close it BEFORE
62
+ # on_abort, which reads what the bracket left on the env. The exception
63
+ # itself propagates unchanged.
64
+ @on_app_start&.call(env)
65
+ begin
66
+ status, response_headers, response_body = @app.call(env)
67
+ rescue ::Exception # rubocop:disable Lint/RescueException -- close the bracket for ANY abort
68
+ @on_app_finish&.call(env)
69
+ @on_abort&.call(env)
70
+ raise
71
+ end
72
+ @on_app_finish&.call(env)
49
73
  response = Response.new(status, response_headers, response_body, url: absolute_url)
50
74
  response.set_cookie_strings.each do |sc|
51
75
  @cookie_jar.store_from_header(sc, absolute_url)
@@ -49,7 +49,7 @@ module Dommy
49
49
  keyword_init: true
50
50
  )
51
51
 
52
- attr_reader :last_request, :last_response, :history, :trace
52
+ attr_reader :last_request, :last_response, :history, :trace, :dialog_handler
53
53
 
54
54
  # A factory `->(session) { js_runtime_host }` that binds a JS runtime to a
55
55
  # session for `javascript: true`. dommy-js-quickjs installs one when its
@@ -129,6 +129,9 @@ module Dommy
129
129
  @scope_stack = []
130
130
  @request_listeners = []
131
131
  @response_listeners = []
132
+ @abort_listeners = []
133
+ @app_start_listeners = []
134
+ @app_finish_listeners = []
132
135
  @document_loaded_listeners = []
133
136
  @subresource_allowlist = [] # hosts allowed for cross-origin <script>/fetch/XHR
134
137
  @blocked_subresource_hosts = [] # cross-origin hosts declined since the last reset (awaiting a decision)
@@ -145,6 +148,14 @@ module Dommy
145
148
  # verbs drive JS handlers.
146
149
  def javascript? = !@js_runtime.nil?
147
150
 
151
+ # Supply native dialog answers to the current page and to every page
152
+ # subsequently installed while the handler is active. The Capybara driver
153
+ # uses this around accept_confirm / dismiss_confirm (and alert/prompt).
154
+ def dialog_handler=(handler)
155
+ @dialog_handler = handler
156
+ @current_window.dialog_handler = handler if @current_window
157
+ end
158
+
148
159
  # The bound JS runtime (a SessionRuntime), or nil when JS is disabled.
149
160
  # Exposed for the Trace to subscribe to the runtime's console / js_error /
150
161
  # script seams; not part of the everyday browsing API.
@@ -511,6 +522,35 @@ module Dommy
511
522
  self
512
523
  end
513
524
 
525
+ # Register a callback invoked with the Rack env when a request ABORTS
526
+ # (the app raised, so no Response exists). Every on_request has a
527
+ # matching on_response OR on_abort — per-request state can bracket on
528
+ # the pair.
529
+ def on_abort(&block)
530
+ @abort_listeners << block
531
+ self
532
+ end
533
+
534
+ # Register callbacks that BRACKET the Rack app call itself, with the env.
535
+ # Unlike on_request / on_response (which the async-network path routes
536
+ # through the scheduler inbox, so they run after the fact on the page
537
+ # thread), these fire inline on whatever thread calls the app — which is
538
+ # the only place a hook can install per-request state the app itself will
539
+ # find, such as the Trace's thread-local. State they need to hand to the
540
+ # later on_response travels on the env, not on the listener's owner.
541
+ def on_app_start(&block)
542
+ @app_start_listeners << block
543
+ self
544
+ end
545
+
546
+ # The matching close. Runs for a successful AND a raising app call, and
547
+ # before on_abort so the aborted request's observer sees what the bracket
548
+ # left on the env.
549
+ def on_app_finish(&block)
550
+ @app_finish_listeners << block
551
+ self
552
+ end
553
+
514
554
  # Register a callback invoked with the new Window each time a navigation
515
555
  # installs an HTML document (visit, redirects, link clicks, form submits,
516
556
  # back/forward, reload, meta refresh). This is the page-load lifecycle
@@ -680,6 +720,15 @@ module Dommy
680
720
  },
681
721
  on_response: lambda { |response|
682
722
  @response_listeners.each { |cb| cb.call(response) }
723
+ },
724
+ on_abort: lambda { |env|
725
+ @abort_listeners.each { |cb| cb.call(env) }
726
+ },
727
+ on_app_start: lambda { |env|
728
+ @app_start_listeners.each { |cb| cb.call(env) }
729
+ },
730
+ on_app_finish: lambda { |env|
731
+ @app_finish_listeners.each { |cb| cb.call(env) }
683
732
  }
684
733
  )
685
734
  end
@@ -728,6 +777,7 @@ module Dommy
728
777
  @current_url = final_url
729
778
  if response.html?
730
779
  @current_window = response.window
780
+ @current_window.dialog_handler = @dialog_handler
731
781
  # Set the geometry mode before scripts boot so the very first
732
782
  # getBoundingClientRect a framework calls already sees it.
733
783
  @current_window.approximate_layout = @approximate_layout if @approximate_layout
@@ -848,6 +898,19 @@ module Dommy
848
898
  },
849
899
  on_response: sched && lambda { |response|
850
900
  sched.post_external { @response_listeners.each { |cb| cb.call(response) } }
901
+ },
902
+ on_abort: sched && lambda { |env|
903
+ sched.post_external { @abort_listeners.each { |cb| cb.call(env) } }
904
+ },
905
+ # The app-call bracket is NOT posted to the inbox: it has to be open
906
+ # while the worker thread is inside the app, which a page-thread
907
+ # callback scheduled for later can never be. Its listeners touch only
908
+ # thread-locals and the env, so running them off-thread is safe.
909
+ on_app_start: lambda { |env|
910
+ @app_start_listeners.each { |cb| cb.call(env) }
911
+ },
912
+ on_app_finish: lambda { |env|
913
+ @app_finish_listeners.each { |cb| cb.call(env) }
851
914
  }
852
915
  )
853
916
  end
@@ -35,6 +35,7 @@ module Dommy
35
35
  when :console then "CONSOLE [#{data[:level]}] #{data[:text]}"
36
36
  when :js_error then "JS_ERROR #{data[:message]}"
37
37
  when :dom then dom(data)
38
+ when :span then "SPAN [#{data[:kind]}] #{data[:label]} (#{data[:duration_ms]}ms)"
38
39
  else "#{event.type.to_s.upcase} #{compact(data)}"
39
40
  end
40
41
  end
@@ -36,6 +36,16 @@ module Dommy
36
36
  LEVELS = %i[off actions verbose].freeze
37
37
  REALM_TYPES = %i[script console js_error].freeze
38
38
 
39
+ # The app-call bracket's channels. The thread-locals are how in-app
40
+ # instrumentation (dommy-rails' TraceInstrumentation) finds the trace
41
+ # whose request is running on THIS thread; the env keys carry that
42
+ # request's spans — and the bracket's saved outer values — back to the
43
+ # response listener, which may run later and on another thread.
44
+ TRACE_THREAD_KEY = :__dommy_active_trace__
45
+ SPANS_THREAD_KEY = :__dommy_trace_spans__
46
+ SPANS_ENV_KEY = "dommy.trace.spans"
47
+ BRACKET_ENV_KEY = "dommy.trace.outer_bracket"
48
+
39
49
  # Build a Trace, wire it to the session's (and its runtime's) seams, and
40
50
  # return it. A `:off` trace wires nothing.
41
51
  def self.attach(session, level: :verbose, dom: false, filter: ParamFilter::DEFAULT, snapshots: false)
@@ -68,6 +78,11 @@ module Dommy
68
78
 
69
79
  @session.on_request { |env| __internal_on_request(env) }
70
80
  @session.on_response { |response| __internal_on_response(response) }
81
+ @session.on_abort { |env| __internal_on_abort(env) } if @session.respond_to?(:on_abort)
82
+ if @session.respond_to?(:on_app_start)
83
+ @session.on_app_start { |env| __internal_open_app_bracket(env) }
84
+ @session.on_app_finish { |env| __internal_close_app_bracket(env) }
85
+ end
71
86
 
72
87
  runtime = @session.respond_to?(:__internal_js_runtime) ? @session.__internal_js_runtime : nil
73
88
  if runtime
@@ -90,8 +105,9 @@ module Dommy
90
105
 
91
106
  @seq += 1
92
107
  @action_seq = @seq
93
- @events << Event.new(seq: @seq, t: now_ms, wall_ms: monotonic_ms, type: :action, name: verb,
108
+ event = Event.new(seq: @seq, t: now_ms, wall_ms: monotonic_ms, type: :action, name: verb,
94
109
  action_seq: nil, data: {verb: verb, label: label, source: __internal_caller_source})
110
+ @events << event
95
111
  nil
96
112
  end
97
113
 
@@ -117,6 +133,10 @@ module Dommy
117
133
  nil
118
134
  end
119
135
 
136
+ # The configured sensitive-key filter — instrumentation layers mask
137
+ # values they record (SQL binds) with the SAME rules as form params.
138
+ def __internal_param_filter__ = @param_filter
139
+
120
140
  # --- Queries (read-only views over the event stream) ---
121
141
 
122
142
  def http = events_of(:http)
@@ -139,7 +159,7 @@ module Dommy
139
159
  def to_s = to_text
140
160
 
141
161
  def to_ndjson(status: "ok", wall_time: nil, metadata: nil)
142
- inline = @artifacts.transform_values { |content| {content: content, encoding: "utf-8"} }
162
+ inline = InlineArtifacts.new(@artifacts)
143
163
  Ndjson.new(@events, level: @level, wall_time: wall_time, metadata: metadata,
144
164
  artifacts: inline, end_wall_ms: monotonic_ms).document(status: status)
145
165
  end
@@ -166,38 +186,109 @@ module Dommy
166
186
  dir
167
187
  end
168
188
 
189
+ # Lazily shapes an artifact's inline emission fields ({content:,
190
+ # encoding:}) by seq — the single owner of the inline wire shape that
191
+ # to_ndjson hands the serializer.
192
+ class InlineArtifacts
193
+ def initialize(artifacts)
194
+ @artifacts = artifacts
195
+ end
196
+
197
+ def [](seq)
198
+ content = @artifacts[seq]
199
+ content ? {content: content, encoding: "utf-8"} : nil
200
+ end
201
+ end
202
+
203
+ # Instrumentation entry point (reached via the thread-local): buffer an
204
+ # inside-the-request span (controller/db/render — anything an
205
+ # instrumentation layer measured). Emitted as completed span events,
206
+ # parented to the enclosing :http event, when the response arrives —
207
+ # spans finish before the response exists, so they can't reference it
208
+ # any earlier. Public; the surrounding request seams stay private.
209
+ #
210
+ # The buffer is the one the app-call bracket published thread-locally,
211
+ # not an ivar: a subresource fetch (JS `fetch`/XHR) runs the app on a
212
+ # network worker thread, so a per-Trace buffer would be a race AND would
213
+ # mis-attribute those spans to whatever the page thread was doing.
214
+ def __internal_record_span__(kind:, label:, duration_ms:, data: nil)
215
+ buffer = Thread.current[SPANS_THREAD_KEY]
216
+ return if @level == :off || buffer.nil?
217
+
218
+ buffer << {kind: kind.to_s, label: label.to_s,
219
+ duration_ms: duration_ms.to_f.round(2), data: data}
220
+ nil
221
+ end
222
+
169
223
  private
170
224
 
171
225
  # Emit one event, gated by the recording level, and return it (or nil if
172
226
  # gated out). `seq` is the canonical order; `t` is the virtual clock if a
173
227
  # window exists.
174
- def __internal_emit(type, data, name: nil, window: nil)
228
+ # `artifact:` carries the event's captured content (a DOM snapshot),
229
+ # stored under the event's seq alongside it.
230
+ def __internal_emit(type, data, name: nil, window: nil, artifact: nil)
175
231
  return if @level == :off
176
232
  return if REALM_TYPES.include?(type) && @level != :verbose
177
233
 
178
234
  @seq += 1
179
235
  event = Event.new(seq: @seq, t: window&.scheduler&.now_ms, wall_ms: monotonic_ms, type: type,
180
236
  name: name, action_seq: @action_seq, data: data)
237
+ @artifacts[event.seq] = artifact if artifact
181
238
  @events << event
182
239
  event
183
240
  end
184
241
 
185
- # on_request fires before its on_response (single-threaded, per redirect
186
- # hop), so stash the method/path here and emit the `:http` event when the
187
- # response arrives with its status.
242
+ # on_request fires before its on_response (per redirect hop, in order),
243
+ # so stash the method/path here and emit the `:http` event when the
244
+ # response arrives with its status. The env is stashed too: the app-call
245
+ # bracket leaves this request's spans on it, and on the async-network
246
+ # path this listener runs after the app already finished.
188
247
  def __internal_on_request(env)
189
248
  @pending_request = {
190
249
  method: env["REQUEST_METHOD"],
191
250
  path: env["PATH_INFO"],
192
- query: presence(env["QUERY_STRING"])
251
+ query: presence(env["QUERY_STRING"]),
252
+ env: env
193
253
  }
194
254
  nil
195
255
  end
196
256
 
257
+ # --- the app-call bracket (runs on the thread that calls the app) ---
258
+
259
+ # Expose this trace to in-app instrumentation (dommy-rails subscribes to
260
+ # ActiveSupport::Notifications and records spans through the thread-local)
261
+ # for exactly the duration of the app call, and give it a buffer to fill.
262
+ # The buffer rides on the env, so the response listener finds this
263
+ # request's spans even when the app ran on another thread.
264
+ #
265
+ # The previous values are saved rather than assumed nil: a nested request
266
+ # (an app that drives a Dommy session of its own) must restore its
267
+ # caller's bracket instead of tearing it down.
268
+ def __internal_open_app_bracket(env)
269
+ return if @level == :off
270
+
271
+ spans = []
272
+ env[SPANS_ENV_KEY] = spans
273
+ env[BRACKET_ENV_KEY] = [Thread.current[TRACE_THREAD_KEY], Thread.current[SPANS_THREAD_KEY]]
274
+ Thread.current[TRACE_THREAD_KEY] = self
275
+ Thread.current[SPANS_THREAD_KEY] = spans
276
+ nil
277
+ end
278
+
279
+ def __internal_close_app_bracket(env)
280
+ saved = env.delete(BRACKET_ENV_KEY)
281
+ return if saved.nil?
282
+
283
+ Thread.current[TRACE_THREAD_KEY] = saved[0]
284
+ Thread.current[SPANS_THREAD_KEY] = saved[1]
285
+ nil
286
+ end
287
+
197
288
  def __internal_on_response(response)
198
289
  request = @pending_request || {}
199
290
  @pending_request = nil
200
- __internal_emit(:http, {
291
+ http = __internal_emit(:http, {
201
292
  method: request[:method],
202
293
  path: request[:path] || path_of(response.url),
203
294
  query: request[:query],
@@ -206,6 +297,36 @@ module Dommy
206
297
  location: response.location_header,
207
298
  set_cookie: response.set_cookie_strings.map { |raw| cookie_name(raw) }
208
299
  })
300
+ __internal_flush_spans(http, request[:env])
301
+ end
302
+
303
+ # The request never produced a Response (the app raised): record the
304
+ # aborted request itself, with the spans the closed bracket left on the
305
+ # env, so the trace shows what ran before the exception.
306
+ def __internal_on_abort(env)
307
+ request = @pending_request || {}
308
+ @pending_request = nil
309
+ http = __internal_emit(:http, {
310
+ method: request[:method] || env["REQUEST_METHOD"],
311
+ path: request[:path] || env["PATH_INFO"],
312
+ query: request[:query],
313
+ status: nil,
314
+ aborted: true
315
+ })
316
+ __internal_flush_spans(http, request[:env] || env)
317
+ end
318
+
319
+ def __internal_flush_spans(http_event, env)
320
+ spans = env && env.delete(SPANS_ENV_KEY)
321
+ return if spans.nil? || spans.empty? || http_event.nil?
322
+
323
+ spans.each do |span|
324
+ payload = {kind: span[:kind], label: span[:label],
325
+ duration_ms: span[:duration_ms], parent: http_event.seq}
326
+ payload.merge!(span[:data]) if span[:data]
327
+ __internal_emit(:span, payload)
328
+ end
329
+ nil
209
330
  end
210
331
 
211
332
  def __internal_on_document(window)
@@ -219,10 +340,9 @@ module Dommy
219
340
  html = @session.document&.to_html
220
341
  return unless html
221
342
 
222
- event = __internal_emit(:artifact,
343
+ __internal_emit(:artifact,
223
344
  {kind: "dom_snapshot", label: "DOM #{@session.current_url}", content_type: "text/html"},
224
- window: window)
225
- @artifacts[event.seq] = html if event
345
+ window: window, artifact: html)
226
346
  end
227
347
 
228
348
  # --- DOM observation ---
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Dommy
4
4
  module Rack
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dommy-rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - takahashim
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.10.0
18
+ version: 0.11.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.10.0
25
+ version: 0.11.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rack
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -101,7 +101,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
101
101
  - !ruby/object:Gem::Version
102
102
  version: '0'
103
103
  requirements: []
104
- rubygems_version: 4.0.10
104
+ rubygems_version: 3.6.9
105
105
  specification_version: 4
106
106
  summary: Rack-backed browser session layer for Dommy
107
107
  test_files: []