Class: Dommy::Rack::Trace

Inherits:
Object
  • Object
show all
Defined in:
lib/dommy/rack/trace.rb,
lib/dommy/rack/trace/ndjson.rb,
lib/dommy/rack/trace/formatter.rb,
lib/dommy/rack/trace/dom_observer.rb,
lib/dommy/rack/trace/param_filter.rb

Overview

A structured, virtual-time-ordered record of what a Session did: user actions, HTTP exchanges (each redirect hop included), document loads, script boots, console output, JS errors, and — opt-in — DOM mutations. One ordered event stream, not a log: it powers readable failure reports and machine-readable diagnostics (to_text / to_ndjson).

session = Dommy::Rack::Session.new(app, trace: true)
session.visit "/login"
session.click_button "Login"
puts session.trace.to_text

The Trace subscribes to the Session's request/response/document seams and, when JS is enabled, to the SessionRuntime's console/js_error/script seams. User-action grouping comes from the Session's navigation verbs calling __internal_open_action (the shared field verbs are intentionally not instrumented). seq — assigned at emit time — is the authoritative timeline order; t is the virtual clock (nil before a document exists).

Defined Under Namespace

Modules: Formatter Classes: DomObserver, Event, Ndjson, ParamFilter

Constant Summary collapse

LEVELS =

Recording verbosity. :off records nothing; :actions records the action / http / form / document / dom timeline; :verbose adds the JS-realm streams (script / console / js_error).

%i[off actions verbose].freeze
REALM_TYPES =
%i[script console js_error].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(session, level: :verbose, dom: false, filter: ParamFilter::DEFAULT, snapshots: false) ⇒ Trace

Returns a new instance of Trace.



49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/dommy/rack/trace.rb', line 49

def initialize(session, level: :verbose, dom: false, filter: ParamFilter::DEFAULT, snapshots: false)
  @session = session
  @level = LEVELS.include?(level) ? level : :verbose
  @dom = dom
  @param_filter = ParamFilter.new(filter)
  @snapshots = snapshots
  @events = []
  @artifacts = {} # seq => captured content (e.g. DOM HTML), kept out of the event
  @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  @seq = 0
  @action_seq = nil
  @pending_request = nil
  @observers = []
end

Instance Attribute Details

#artifactsObject (readonly)

Returns the value of attribute artifacts.



47
48
49
# File 'lib/dommy/rack/trace.rb', line 47

def artifacts
  @artifacts
end

#eventsObject (readonly)

Returns the value of attribute events.



47
48
49
# File 'lib/dommy/rack/trace.rb', line 47

def events
  @events
end

#levelObject (readonly)

Returns the value of attribute level.



47
48
49
# File 'lib/dommy/rack/trace.rb', line 47

def level
  @level
end

Class Method Details

.attach(session, level: :verbose, dom: false, filter: ParamFilter::DEFAULT, snapshots: false) ⇒ Object

Build a Trace, wire it to the session's (and its runtime's) seams, and return it. A :off trace wires nothing.



41
42
43
44
45
# File 'lib/dommy/rack/trace.rb', line 41

def self.attach(session, level: :verbose, dom: false, filter: ParamFilter::DEFAULT, snapshots: false)
  trace = new(session, level: level, dom: dom, filter: filter, snapshots: snapshots)
  trace.__internal_wire
  trace
end

Instance Method Details

#__internal_open_action(verb, label = nil) ⇒ Object

Open a user-action group. Following events carry this action's seq as their action_seq until the next action opens.



88
89
90
91
92
93
94
95
96
# File 'lib/dommy/rack/trace.rb', line 88

def __internal_open_action(verb, label = nil)
  return if @level == :off

  @seq += 1
  @action_seq = @seq
  @events << Event.new(seq: @seq, t: now_ms, wall_ms: monotonic_ms, type: :action, name: verb,
    action_seq: nil, data: {verb: verb, label: label, source: __internal_caller_source})
  nil
end

#__internal_record_form(method:, url:, params: nil) ⇒ Object

Record a form submission (method, path, filtered params). Form params arrive as ordered [name, value] pairs; present them as a masked hash.



106
107
108
# File 'lib/dommy/rack/trace.rb', line 106

def __internal_record_form(method:, url:, params: nil)
  __internal_emit(:form, {method: method, path: path_of(url) || url, params: @param_filter.form_params(params)})
end

#__internal_wireObject

Subscribe to the session and (when present) runtime seams. Called by .attach; safe to call once.



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/dommy/rack/trace.rb', line 66

def __internal_wire
  return if @level == :off

  @session.on_request { |env| __internal_on_request(env) }
  @session.on_response { |response| __internal_on_response(response) }

  runtime = @session.respond_to?(:__internal_js_runtime) ? @session.__internal_js_runtime : nil
  if runtime
    runtime.on_document { |window| __internal_on_document(window) }
    runtime.on_script { |element, error| __internal_emit(:script, script_data(element, error), window: current_window) }
    runtime.on_console { |log| __internal_emit(:console, console_data(log), window: current_window) }
    runtime.on_js_error { |error| __internal_emit(:js_error, {message: error_message(error)}, window: current_window) }
  else
    @session.on_document_loaded { |window| __internal_on_document(window) }
  end
  nil
end

#actionsObject



128
# File 'lib/dommy/rack/trace.rb', line 128

def actions = events_of(:action)

#consoleObject



125
# File 'lib/dommy/rack/trace.rb', line 125

def console = events_of(:console)

#current_pageObject

The page currently loaded (for failure context).



134
# File 'lib/dommy/rack/trace.rb', line 134

def current_page = {url: @session.current_url, title: @session.document&.title}

#documentsObject



123
# File 'lib/dommy/rack/trace.rb', line 123

def documents = events_of(:document)

#dom_mutationsObject



129
# File 'lib/dommy/rack/trace.rb', line 129

def dom_mutations = events_of(:dom)

#flush_domObject

Drain the current window's microtasks so queued MutationObserver records are delivered (JS mode does this via after_interaction; non-JS / tests call this explicitly).



113
114
115
116
117
118
# File 'lib/dommy/rack/trace.rb', line 113

def flush_dom
  return unless @dom

  current_window&.scheduler&.drain_microtasks
  nil
end

#formsObject



127
# File 'lib/dommy/rack/trace.rb', line 127

def forms = events_of(:form)

#httpObject

--- Queries (read-only views over the event stream) ---



122
# File 'lib/dommy/rack/trace.rb', line 122

def http = events_of(:http)

#js_errorsObject



126
# File 'lib/dommy/rack/trace.rb', line 126

def js_errors = events_of(:js_error)

#last_actionObject



130
# File 'lib/dommy/rack/trace.rb', line 130

def last_action = actions.last

#recent(count = 10) ⇒ Object



131
# File 'lib/dommy/rack/trace.rb', line 131

def recent(count = 10) = @events.last(count)

#record_error(message:, exception_class: nil, source: nil) ⇒ Object



98
99
100
101
102
# File 'lib/dommy/rack/trace.rb', line 98

def record_error(message:, exception_class: nil, source: nil)
  data = {exception_class: exception_class, message: presence(message)}.compact
  __internal_emit(:error, {label: "Expectation failed", source: source, data: data}.compact,
    window: current_window)
end

#save(dir, status: "ok", metadata: nil) ⇒ Object

Write a self-contained trace bundle to dir: trace.ndjson plus an artifacts/ directory with each captured snapshot as its own file, the NDJSON referencing them by relative path. Returns dir. This is what the standalone viewer opens (and v/o resolve paths against).



155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/dommy/rack/trace.rb', line 155

def save(dir, status: "ok", metadata: nil)
  require "fileutils"
  ::FileUtils.mkdir_p(::File.join(dir, "artifacts"))
  paths = @artifacts.each_with_object({}) do |(seq, content), out|
    rel = ::File.join("artifacts", "art_#{seq}.html")
    ::File.write(::File.join(dir, rel), content)
    out[seq] = {path: rel}
  end
  ndjson = Ndjson.new(@events, level: @level, metadata: ,
    artifacts: paths, end_wall_ms: monotonic_ms).document(status: status)
  ::File.write(::File.join(dir, "trace.ndjson"), ndjson)
  dir
end

#scriptsObject



124
# File 'lib/dommy/rack/trace.rb', line 124

def scripts = events_of(:script)

#to_ndjson(status: "ok", wall_time: nil, metadata: nil) ⇒ Object



141
142
143
144
145
# File 'lib/dommy/rack/trace.rb', line 141

def to_ndjson(status: "ok", wall_time: nil, metadata: nil)
  inline = @artifacts.transform_values { |content| {content: content, encoding: "utf-8"} }
  Ndjson.new(@events, level: @level, wall_time: wall_time, metadata: ,
    artifacts: inline, end_wall_ms: monotonic_ms).document(status: status)
end

#to_sObject



139
# File 'lib/dommy/rack/trace.rb', line 139

def to_s = to_text

#to_text(limit: nil) ⇒ Object

--- Formatting ---



138
# File 'lib/dommy/rack/trace.rb', line 138

def to_text(limit: nil) = Formatter::Text.new(limit ? @events.last(limit) : @events).render

#write_ndjson(path, **opts) ⇒ Object



147
148
149
# File 'lib/dommy/rack/trace.rb', line 147

def write_ndjson(path, **opts)
  ::File.write(path, to_ndjson(**opts))
end