sentiero 1.0.0.beta1 → 1.0.0.beta4

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: c8bc35b365449e9111ed5dcc093ea7fceaf60d739b47e1bf13df8f7cbc83cf55
4
- data.tar.gz: 39c9a76c8acf2f22d44fcc3fa88d450ef1e36a368434ba5ae143c82bd2800b30
3
+ metadata.gz: e055280481854b52d5e5d4eba3a51a9f0e377d182b53acb2816aeeac51551ef8
4
+ data.tar.gz: 04dd008f190ecd97d78c75b6b99a23b32f1bac1a6f8b257edbba8ff89a4decda
5
5
  SHA512:
6
- metadata.gz: 527083a0dc823bb03343f97b41f3f40e3c629ce201dbf234613c9d615068e8e481d612f0aa0ff8a5736e6cc487f1f6d049f0d704647b0fa37e7361d4cafa4003
7
- data.tar.gz: c77ebaff3d74da9cdef241db684ccb6f0b5a141a10932fd49d3af131bd4434ddb8194ee78abab087a16893c2b01a4566f0f75f98f44e17679e5107bfb2a9b0dc
6
+ metadata.gz: abc7b8ac2d64eb8e6df281f589c80b6eeaa4345cd3b47752e5c7a4d5d570ce7cfe482ab56d40424ab43d61cda956b3b02c49ebe9a329a364a1a533ad765a4228
7
+ data.tar.gz: 56a0d486e4189136a2546d4a72fc3d834fc0637c61fa3f4cf81bb9616a0acaf655555f23a9f9ef80e2ea92767b89986f3612f538609b022922bf9b56ca240cb1
@@ -16,21 +16,42 @@ module Sentiero
16
16
 
17
17
  MAX_FACET_VALUES = 50
18
18
 
19
- def grouped_errors(sort_by: "count", since: nil, until_time: nil)
19
+ STATUS_FILTERS = %w[open resolved ignored all].freeze
20
+
21
+ def grouped_errors(sort_by: "count", since: nil, until_time: nil, status: nil)
20
22
  groups = {}
21
23
 
22
24
  _scanned, hit_cap = scan_sessions(since: since, until_time: until_time, types: [META, CUSTOM]) do |summary, window_id, events|
23
25
  collect_window(groups, summary, window_id, events)
24
26
  end
25
27
 
28
+ groups.each_value { |group| group[:status] = effective_status(group) }
29
+
26
30
  {
27
- groups: sort_groups(groups.values, sort_by),
31
+ groups: sort_groups(filter_by_status(groups.values, status), sort_by),
28
32
  was_truncated: hit_cap
29
33
  }
30
34
  end
31
35
 
32
36
  private
33
37
 
38
+ def effective_status(group)
39
+ state = group[:state]
40
+ return "open" if state.nil?
41
+ return "ignored" if state[:status] == "ignored"
42
+ return "regressed" if group[:regressions].positive?
43
+
44
+ "resolved"
45
+ end
46
+
47
+ def filter_by_status(groups, status)
48
+ case status
49
+ when "all" then groups
50
+ when "resolved", "ignored" then groups.select { |g| g[:status] == status }
51
+ else groups.select { |g| %w[open regressed].include?(g[:status]) }
52
+ end
53
+ end
54
+
34
55
  def collect_window(groups, summary, window_id, events)
35
56
  anchor = window_anchor(summary, window_id, events)
36
57
 
@@ -48,8 +69,10 @@ module Sentiero
48
69
 
49
70
  key = ErrorCollector.group_key(message)
50
71
  group = groups[key] ||= new_group(key, message, payload)
72
+ group[:stack] ||= stack_of(payload)
51
73
  group[:count] += 1
52
74
  group[:last_seen_at] = [group[:last_seen_at], timestamp].compact.max
75
+ group[:regressions] += 1 if regression?(group[:state], timestamp)
53
76
  tally_facets(group, summary[:metadata] || {})
54
77
  return if group[:occurrences].size >= MAX_OCCURRENCES_PER_GROUP
55
78
 
@@ -66,14 +89,23 @@ module Sentiero
66
89
  payload.is_a?(Hash) ? payload : {}
67
90
  end
68
91
 
92
+ def regression?(state, timestamp)
93
+ state && state[:status] == "resolved" && timestamp && timestamp.to_f > state[:last_seen_at].to_f
94
+ end
95
+
69
96
  def new_group(key, message, payload)
97
+ id = Digest::SHA1.hexdigest(key)
70
98
  {
71
- id: Digest::SHA1.hexdigest(key),
99
+ id: id,
72
100
  message: message,
73
101
  source: source_of(payload),
74
102
  line: line_of(payload),
103
+ stack: nil,
75
104
  count: 0,
76
105
  last_seen_at: nil,
106
+ state: @store.client_error_states([id])[id],
107
+ status: nil,
108
+ regressions: 0,
77
109
  browsers: Hash.new(0),
78
110
  devices: Hash.new(0),
79
111
  pages: Hash.new(0),
@@ -105,6 +137,11 @@ module Sentiero
105
137
  line.is_a?(Integer) ? line : nil
106
138
  end
107
139
 
140
+ def stack_of(payload)
141
+ stack = payload["stack"]
142
+ (stack.is_a?(String) && !stack.empty?) ? stack : nil
143
+ end
144
+
108
145
  def sort_groups(groups, sort_by)
109
146
  case sort_by
110
147
  when "recency"
@@ -73,7 +73,7 @@ module Sentiero
73
73
  def build_errors
74
74
  headers = %w[message source line count last_seen_at session_id window_id offset_ms]
75
75
 
76
- rows = ErrorDiscovery.new(store).grouped_errors(since: since, until_time: until_time)[:groups].flat_map do |group|
76
+ rows = ErrorDiscovery.new(store).grouped_errors(since: since, until_time: until_time, status: "all")[:groups].flat_map do |group|
77
77
  group[:occurrences].map do |occ|
78
78
  [
79
79
  group[:message],
@@ -68,6 +68,19 @@ module Sentiero
68
68
  raise NoMethodError, "#{self.class}#session_ids_for_problem not implemented"
69
69
  end
70
70
 
71
+ def client_error_states(ids)
72
+ raise NoMethodError, "#{self.class}#client_error_states not implemented"
73
+ end
74
+
75
+ # changed_at is epoch seconds; last_seen_at is the group's epoch-ms watermark.
76
+ def set_client_error_state(id, status:, changed_at:, last_seen_at:)
77
+ raise NoMethodError, "#{self.class}#set_client_error_state not implemented"
78
+ end
79
+
80
+ def purge_client_error_states(cutoff)
81
+ raise NoMethodError, "#{self.class}#purge_client_error_states not implemented"
82
+ end
83
+
71
84
  private
72
85
 
73
86
  # Shared problem bookkeeping: the semantic source of truth for problem
@@ -123,6 +136,21 @@ module Sentiero
123
136
  )
124
137
  end
125
138
 
139
+ def client_error_state_attrs(status, changed_at, last_seen_at)
140
+ {status: status, changed_at: changed_at.to_f, last_seen_at: last_seen_at&.to_f}
141
+ end
142
+
143
+ def client_error_state_from_strings(h)
144
+ {status: h["status"], changed_at: h["changed_at"], last_seen_at: h["last_seen_at"]}
145
+ end
146
+
147
+ def select_client_error_states(states, ids)
148
+ ids.each_with_object({}) do |id, out|
149
+ state = states[id]
150
+ out[id] = state.dup if state
151
+ end
152
+ end
153
+
126
154
  def build_problem_title(occurrence)
127
155
  "#{occurrence["exception_class"]}: #{occurrence["message"]}"[0, PROBLEM_TITLE_MAX]
128
156
  end
@@ -291,6 +291,30 @@ module Sentiero
291
291
  latest_session_ids(occurrences[problem_id] || [], limit: limit)
292
292
  end
293
293
 
294
+ def client_error_states(ids)
295
+ select_client_error_states(read_client_error_states, ids)
296
+ end
297
+
298
+ def set_client_error_state(id, status:, changed_at:, last_seen_at:)
299
+ validate_id!(id)
300
+ validate_status!(status)
301
+ update_client_error_states do |states|
302
+ if status == "open"
303
+ states.delete(id)
304
+ else
305
+ states[id] = client_error_state_attrs(status, changed_at, last_seen_at)
306
+ end
307
+ end
308
+ nil
309
+ end
310
+
311
+ def purge_client_error_states(cutoff)
312
+ update_client_error_states do |states|
313
+ states.delete_if { |_id, state| state[:status] == "resolved" && state[:changed_at] < cutoff.to_f }
314
+ end
315
+ nil
316
+ end
317
+
294
318
  def clear!
295
319
  FileUtils.rm_rf(Dir.glob(::File.join(@root, "*")))
296
320
  nil
@@ -310,6 +334,7 @@ module Sentiero
310
334
  deleted = stale.size
311
335
 
312
336
  purge_error_data_older_than!(cutoff)
337
+ purge_client_error_states(cutoff)
313
338
 
314
339
  deleted
315
340
  end
@@ -552,6 +577,28 @@ module Sentiero
552
577
  }
553
578
  end
554
579
 
580
+ def read_client_error_states
581
+ raw = read_error_json("client_error_states.json") || {}
582
+ raw.transform_values { |s| client_error_state_from_strings(s) }
583
+ end
584
+
585
+ def update_client_error_states
586
+ ::File.open(error_lock_path, ::File::RDWR | ::File::CREAT, 0o600) do |lock|
587
+ lock.flock(::File::LOCK_EX)
588
+ states = read_client_error_states
589
+ yield states
590
+ raw = states.transform_values { |s| stringify_client_error_state(s) }
591
+ path = error_data_path("client_error_states.json")
592
+ tmp = "#{path}.tmp.#{Process.pid}"
593
+ ::File.write(tmp, JSON.generate(raw))
594
+ ::File.rename(tmp, path)
595
+ end
596
+ end
597
+
598
+ def stringify_client_error_state(s)
599
+ {"status" => s[:status], "changed_at" => s[:changed_at], "last_seen_at" => s[:last_seen_at]}
600
+ end
601
+
555
602
  def purge_error_data_older_than!(cutoff)
556
603
  update_error_data do |problems, occurrences, server_events|
557
604
  purge_error_collections!(problems, occurrences, server_events, cutoff)
@@ -15,6 +15,7 @@ module Sentiero
15
15
  @problems = Concurrent::Map.new # fingerprint => problem Hash (symbol-keyed)
16
16
  @occurrences = Concurrent::Map.new # fingerprint => Concurrent::Array of occurrence Hashes
17
17
  @server_events = Concurrent::Array.new
18
+ @client_error_states = Concurrent::Map.new
18
19
  end
19
20
 
20
21
  def save_events(ref, events)
@@ -260,9 +261,33 @@ module Sentiero
260
261
  latest_session_ids(list.to_a, limit: limit)
261
262
  end
262
263
 
264
+ def client_error_states(ids)
265
+ select_client_error_states(@client_error_states, ids)
266
+ end
267
+
268
+ def set_client_error_state(id, status:, changed_at:, last_seen_at:)
269
+ validate_id!(id)
270
+ validate_status!(status)
271
+ if status == "open"
272
+ @client_error_states.delete(id)
273
+ else
274
+ @client_error_states[id] = client_error_state_attrs(status, changed_at, last_seen_at)
275
+ end
276
+ nil
277
+ end
278
+
279
+ def purge_client_error_states(cutoff)
280
+ @client_error_states.each_pair do |id, state|
281
+ @client_error_states.delete(id) if state[:status] == "resolved" && state[:changed_at] < cutoff.to_f
282
+ end
283
+ nil
284
+ end
285
+
263
286
  def purge_older_than(seconds)
264
287
  deleted = super
265
- purge_error_collections!(@problems, @occurrences, @server_events, Time.now.to_f - seconds)
288
+ cutoff = Time.now.to_f - seconds
289
+ purge_error_collections!(@problems, @occurrences, @server_events, cutoff)
290
+ purge_client_error_states(cutoff)
266
291
  deleted
267
292
  end
268
293
 
@@ -271,6 +296,7 @@ module Sentiero
271
296
  @problems.clear
272
297
  @occurrences.clear
273
298
  @server_events.clear
299
+ @client_error_states.clear
274
300
  nil
275
301
  end
276
302
 
@@ -50,6 +50,10 @@ module Sentiero
50
50
  @server_events ||= "#{@prefix}server_events"
51
51
  end
52
52
 
53
+ def client_error_states
54
+ @client_error_states ||= "#{@prefix}client_error_states"
55
+ end
56
+
53
57
  def server_events_project(project)
54
58
  "#{@prefix}server_events:project:#{project}"
55
59
  end
@@ -406,6 +406,34 @@ module Sentiero
406
406
  latest_session_ids(raw.map { |json| JSON.parse(json) }, limit: limit)
407
407
  end
408
408
 
409
+ def client_error_states(ids)
410
+ return {} if ids.empty?
411
+
412
+ values = @redis.hmget(@keys.client_error_states, *ids)
413
+ ids.zip(values).each_with_object({}) do |(id, json), out|
414
+ out[id] = client_error_state_from_strings(JSON.parse(json)) if json
415
+ end
416
+ end
417
+
418
+ def set_client_error_state(id, status:, changed_at:, last_seen_at:)
419
+ validate_id!(id)
420
+ validate_status!(status)
421
+ if status == "open"
422
+ @redis.hdel(@keys.client_error_states, id)
423
+ else
424
+ @redis.hset(@keys.client_error_states, id, JSON.generate(client_error_state_attrs(status, changed_at, last_seen_at)))
425
+ end
426
+ nil
427
+ end
428
+
429
+ def purge_client_error_states(cutoff)
430
+ @redis.hscan_each(@keys.client_error_states) do |id, json|
431
+ state = JSON.parse(json)
432
+ @redis.hdel(@keys.client_error_states, id) if state["status"] == "resolved" && state["changed_at"].to_f < cutoff.to_f
433
+ end
434
+ nil
435
+ end
436
+
409
437
  def clear!
410
438
  # SCAN, not KEYS: KEYS is O(N) over the whole keyspace and blocks the server.
411
439
  @redis.scan_each(match: "#{@prefix}*") { |key| @redis.del(key) }
@@ -430,6 +458,7 @@ module Sentiero
430
458
  end
431
459
 
432
460
  purge_error_data_older_than!(cutoff)
461
+ purge_client_error_states(cutoff)
433
462
 
434
463
  deleted
435
464
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Sentiero
6
+ module Stores
7
+ class SQLite
8
+ # zlib compression for events.data payloads. FullSnapshots dominate the
9
+ # table's bytes (~93% in production samples) and deflate ~6-7x, so
10
+ # payloads at or above the threshold are stored as compressed BLOBs;
11
+ # smaller rows stay plain JSON TEXT (near-zero win, and skipping them
12
+ # keeps ~85% of rows out of the deflate path). Reads sniff the storage
13
+ # format from the payload itself: a zlib stream starts with 0x78 and a
14
+ # valid header checksum, which no JSON text can.
15
+ module PayloadCodec
16
+ COMPRESSION_THRESHOLD = 1024
17
+ # CMF byte for deflate with a 32K window — the only one zlib emits.
18
+ ZLIB_FIRST_BYTE = 0x78
19
+
20
+ module_function
21
+
22
+ def encode(json)
23
+ return json if json.bytesize < COMPRESSION_THRESHOLD
24
+
25
+ ::SQLite3::Blob.new(Zlib::Deflate.deflate(json))
26
+ end
27
+
28
+ def decode(data)
29
+ return data unless compressed?(data)
30
+
31
+ Zlib::Inflate.inflate(data).force_encoding(Encoding::UTF_8)
32
+ end
33
+
34
+ def compressed?(data)
35
+ data.getbyte(0) == ZLIB_FIRST_BYTE &&
36
+ (flg = data.getbyte(1)) &&
37
+ (((ZLIB_FIRST_BYTE << 8) | flg) % 31).zero?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -80,9 +80,17 @@ module Sentiero
80
80
  CREATE INDEX IF NOT EXISTS idx_server_events_event_id ON server_events(event_id);
81
81
  CREATE INDEX IF NOT EXISTS idx_server_events_project ON server_events(project);
82
82
  CREATE INDEX IF NOT EXISTS idx_server_events_session ON server_events(session_id);
83
+
84
+ CREATE TABLE IF NOT EXISTS client_error_states (
85
+ id TEXT PRIMARY KEY,
86
+ status TEXT NOT NULL,
87
+ changed_at REAL NOT NULL,
88
+ last_seen_at REAL
89
+ );
83
90
  SQL
84
91
 
85
92
  migrate_events_type!(db)
93
+ migrate_compress_payloads!(db)
86
94
  end
87
95
 
88
96
  # Databases created before the events.type column existed get a
@@ -110,6 +118,34 @@ module Sentiero
110
118
  SQL
111
119
  end
112
120
  end
121
+
122
+ # user_version 1 = every large payload is zlib-compressed (PayloadCodec).
123
+ SCHEMA_VERSION = 1
124
+
125
+ # One-time pass compressing pre-existing plain-TEXT payloads. Batched
126
+ # in short transactions so a live recorder's ingest writes interleave
127
+ # instead of timing out behind one giant lock. Guarded by
128
+ # PRAGMA user_version, so subsequent opens skip the table scan.
129
+ # Frees pages for reuse but does not shrink the file — VACUUM manually
130
+ # to reclaim disk.
131
+ def self.migrate_compress_payloads!(db)
132
+ return if db.get_first_value("PRAGMA user_version").to_i >= SCHEMA_VERSION
133
+
134
+ loop do
135
+ rows = db.execute(
136
+ "SELECT id, data FROM events WHERE typeof(data) = 'text' AND LENGTH(data) >= ? LIMIT 500",
137
+ [PayloadCodec::COMPRESSION_THRESHOLD]
138
+ )
139
+ break if rows.empty?
140
+
141
+ db.transaction(:immediate) do
142
+ rows.each do |row|
143
+ db.execute("UPDATE events SET data = ? WHERE id = ?", [PayloadCodec.encode(row["data"]), row["id"]])
144
+ end
145
+ end
146
+ end
147
+ db.execute("PRAGMA user_version = #{SCHEMA_VERSION}")
148
+ end
113
149
  end
114
150
  end
115
151
  end
@@ -16,6 +16,7 @@ module Sentiero
16
16
  class SQLite < Store
17
17
  # Loaded after the class line above establishes SQLite < Store, so
18
18
  # schema.rb's own `class SQLite` reopen doesn't hit a superclass mismatch.
19
+ require_relative "sqlite/payload_codec"
19
20
  require_relative "sqlite/schema"
20
21
 
21
22
  # Single-file SQLite store for single-process production and dev.
@@ -57,10 +58,25 @@ module Sentiero
57
58
  # a slow dashboard scan can't stall recorder ingest (and vice versa).
58
59
  @in_memory = @path == ":memory:" || @path.start_with?("file::memory:")
59
60
  @tls_key = :"sentiero_sqlite_#{object_id}"
61
+ # Every connection this store opened, across all threads, so #close can
62
+ # release them. Thread-locals alone outlive a discarded store on
63
+ # long-lived threads (puma workers, the test runner) and leak fds.
64
+ @connections = []
65
+ @connections_lock = Mutex.new
60
66
  @shared_db = create_connection if @in_memory
61
67
  Schema.create(db)
62
68
  end
63
69
 
70
+ # Closes every connection this store opened. A file-backed store reopens a
71
+ # connection on next use; an in-memory store's data is gone for good.
72
+ def close
73
+ @connections_lock.synchronize do
74
+ @connections.each { |conn| conn.close unless conn.closed? }
75
+ @connections.clear
76
+ end
77
+ nil
78
+ end
79
+
64
80
  # event_type_counts / event_counts_by_day group on indexed columns and
65
81
  # never read payloads (see Schema for the column-order guarantee).
66
82
  def supports_event_aggregates?
@@ -101,7 +117,7 @@ module Sentiero
101
117
  events.each do |event|
102
118
  type = event["type"]
103
119
  stmt.execute(session_id, window_id, event["timestamp"]&.to_f,
104
- type.is_a?(Integer) ? type : nil, JSON.generate(event))
120
+ type.is_a?(Integer) ? type : nil, PayloadCodec.encode(JSON.generate(event)))
105
121
  end
106
122
  ensure
107
123
  stmt.close
@@ -300,7 +316,7 @@ module Sentiero
300
316
  params << limit.to_i
301
317
  end
302
318
 
303
- db.execute(sql, params).map { |event_row| JSON.parse(event_row["data"]) }
319
+ db.execute(sql, params).map { |event_row| JSON.parse(PayloadCodec.decode(event_row["data"])) }
304
320
  end
305
321
 
306
322
  def save_metadata(session_id, metadata)
@@ -504,6 +520,36 @@ module Sentiero
504
520
  db.execute(sql, params).map { |row| row["session_id"] }
505
521
  end
506
522
 
523
+ def client_error_states(ids)
524
+ return {} if ids.empty?
525
+
526
+ placeholders = (["?"] * ids.size).join(", ")
527
+ rows = db.execute("SELECT id, status, changed_at, last_seen_at FROM client_error_states WHERE id IN (#{placeholders})", ids)
528
+ rows.each_with_object({}) do |row, out|
529
+ out[row["id"]] = {status: row["status"], changed_at: row["changed_at"], last_seen_at: row["last_seen_at"]}
530
+ end
531
+ end
532
+
533
+ def set_client_error_state(id, status:, changed_at:, last_seen_at:)
534
+ validate_id!(id)
535
+ validate_status!(status)
536
+ if status == "open"
537
+ db.execute("DELETE FROM client_error_states WHERE id = ?", [id])
538
+ else
539
+ db.execute(
540
+ "INSERT INTO client_error_states (id, status, changed_at, last_seen_at) VALUES (?, ?, ?, ?) " \
541
+ "ON CONFLICT(id) DO UPDATE SET status = excluded.status, changed_at = excluded.changed_at, last_seen_at = excluded.last_seen_at",
542
+ [id, status, changed_at.to_f, last_seen_at&.to_f]
543
+ )
544
+ end
545
+ nil
546
+ end
547
+
548
+ def purge_client_error_states(cutoff)
549
+ db.execute("DELETE FROM client_error_states WHERE status = 'resolved' AND changed_at < ?", [cutoff.to_f])
550
+ nil
551
+ end
552
+
507
553
  def clear!
508
554
  db.transaction(:immediate) do
509
555
  db.execute("DELETE FROM events")
@@ -511,6 +557,7 @@ module Sentiero
511
557
  db.execute("DELETE FROM problems")
512
558
  db.execute("DELETE FROM occurrences")
513
559
  db.execute("DELETE FROM server_events")
560
+ db.execute("DELETE FROM client_error_states")
514
561
  end
515
562
  nil
516
563
  end
@@ -534,6 +581,7 @@ module Sentiero
534
581
  [cutoff]
535
582
  )
536
583
  db.execute("DELETE FROM problems WHERE last_seen < ?", [cutoff])
584
+ db.execute("DELETE FROM client_error_states WHERE status = 'resolved' AND changed_at < ?", [cutoff])
537
585
  end
538
586
 
539
587
  session_count
@@ -570,7 +618,7 @@ module Sentiero
570
618
  "SELECT session_id, window_id, data FROM events WHERE session_id IN (#{placeholders})#{type_clause} ORDER BY timestamp ASC",
571
619
  chunk + type_params
572
620
  ).each do |row|
573
- (grouped[row["session_id"]][row["window_id"]] ||= []) << JSON.parse(row["data"])
621
+ (grouped[row["session_id"]][row["window_id"]] ||= []) << JSON.parse(PayloadCodec.decode(row["data"]))
574
622
  end
575
623
  end
576
624
  grouped
@@ -608,7 +656,10 @@ module Sentiero
608
656
  def db
609
657
  return @shared_db if @in_memory
610
658
 
611
- Thread.current[@tls_key] ||= create_connection
659
+ conn = Thread.current[@tls_key]
660
+ return conn if conn && !conn.closed?
661
+
662
+ Thread.current[@tls_key] = create_connection
612
663
  end
613
664
 
614
665
  def create_connection
@@ -620,6 +671,7 @@ module Sentiero
620
671
  conn.busy_handler_timeout = 5_000
621
672
  conn.execute("PRAGMA journal_mode=WAL")
622
673
  conn.execute("PRAGMA foreign_keys=ON")
674
+ @connections_lock.synchronize { @connections << conn }
623
675
  conn
624
676
  end
625
677
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sentiero
4
- VERSION = "1.0.0.beta1"
4
+ VERSION = "1.0.0.beta4"
5
5
  end
@@ -0,0 +1,3 @@
1
+ (()=>{var C={navigation:{label:"Navigation",color:"#198754"},click:{label:"Click",color:"#dc3545"},input:{label:"Input",color:"#0d6efd"},error:{label:"Error",color:"#ff0000"},custom:{label:"Custom",color:"#6f42c1"},frustration:{label:"Frustration",color:"#ff6b6b"},server_exception:{label:"Server Exception",color:"#b91c1c"},server_event:{label:"Server Event",color:"#7c3aed"}},k=5e3,it=3;function ct(t){if(t.length<2)return 0;let e=0,r=t[0].timestamp,n=t[0].timestamp;for(let a=1;a<t.length;a++){let o=t[a].timestamp;o-n>k&&(e+=n-r,r=o),n=o}return e+=n-r,e}function S(t){let e=Math.round(t/1e3);if(e<60)return`${e}s`;if(e<3600){let a=Math.floor(e/60),o=e%60;return o>0?`${a}m ${o}s`:`${a}m`}let r=Math.floor(e/3600),n=Math.floor(e%3600/60);return n>0?`${r}h ${n}m`:`${r}h`}function M(t){let e=Math.floor(t/1e3),r=Math.floor(e/3600),n=Math.floor(e%3600/60),a=e%60,o=i=>(i<10?"0":"")+i;return r>0?`${r}:${o(n)}:${o(a)}`:`${n}:${o(a)}`}function _(t,e){return t.length<=e?t:t.substring(0,e-3)+"..."}function lt(t){for(let e=0;e<t.length;e++)if(t[e].type===4&&t[e].data){let r=t[e].data.width,n=t[e].data.height;if(r&&n)return`${r}x${n}`}return null}function Ft(t){return!!(t&&t.type===3&&t.data&&t.data.source===5&&typeof t.data.id=="number")}function Ht(t){return!!t&&typeof t.isChecked=="boolean"}function W(t){if(!Array.isArray(t))return[];let e=new Map;for(let n=0;n<t.length;n++){let a=t[n];if(!Ft(a))continue;let o=a.data.id,i=e.get(o);i||(i={nodeId:o,fillCount:0,isToggle:!1,firstTimestamp:a.timestamp,lastTimestamp:a.timestamp},e.set(o,i)),i.fillCount+=1,i.lastTimestamp=a.timestamp,Ht(a.data)&&(i.isToggle=!0)}let r=Array.from(e.values()).sort((n,a)=>n.firstTimestamp-a.firstTimestamp);return r.forEach((n,a)=>{n.order=a+1,n.label=`Field ${a+1}`}),r}function D(t){let e=W(t),r={},n=[];for(let a=0;a<e.length;a++){let o=e[a],i=e[a+1];o.nextFieldOffset=i?i.firstTimestamp-o.firstTimestamp:null,r[o.nodeId]=o,n.push(o.label)}return{totalFields:e.length,sequence:n,byNodeId:r}}function Gt(t){return t<1e3?`${Math.round(t/100)*100}ms`:`${(Math.round(t/100)/10).toFixed(1)}s`}function st(t,e){if(!t||t.category!=="input")return[];if(!e||!e.byNodeId)return[];let r=t.event&&t.event.data;if(!r||typeof r.id!="number")return[];let n=e.byNodeId[r.id];if(!n)return[];let a=[`Field ${n.order} of ${e.totalFields}`];return n.isToggle?n.fillCount>1&&a.push(`Toggled: ${n.fillCount} times`):n.fillCount>1&&a.push(`Re-filled: ${n.fillCount} times`),n.nextFieldOffset!=null&&n.nextFieldOffset>0&&n.nextFieldOffset<=k&&a.push(`Time to next field: ${Gt(n.nextFieldOffset)}`),a}function B(t,e){let r=[],n=t;function a(){fetch(n).then(o=>{if(!o.ok)throw new Error(`Failed to fetch events: ${o.status}`);return o.json()}).then(o=>{if(!Array.isArray(o)||o.length===0){e(null,r);return}r=r.concat(o);let i=o[o.length-1];if(i&&i.timestamp){let s=t.indexOf("?")===-1?"?":"&";n=`${t}${s}after=${i.timestamp}`,a()}else e(null,r)}).catch(o=>{r.length>0?e(null,r):e(o,[])})}a()}var j=null;function w(){return j}function Yt(t){if(t.type===4&&t.data&&t.data.href)return"navigation";if(t.type===3&&t.data){if(t.data.source===2){let e=t.data.type;return e===2||e===4||e===3||e===7?"click":null}if(t.data.source===5)return"input"}return t.type===5?t.data&&t.data.tag==="error"?"error":t.data&&t.data.tag==="navigation"?"navigation":"custom":null}function Vt(t){if(!t||t.length<2)return{map:()=>0,gaps:[]};let e=t[0].timestamp,r=t[t.length-1].timestamp,n=r-e;if(n<=0)return{map:()=>0,gaps:[]};let a=[],o=[],i=e;for(let c=1;c<t.length;c++){let l=t[c].timestamp-t[c-1].timestamp;l>k&&(o.push({start:i,end:t[c-1].timestamp}),a.push({start:t[c-1].timestamp,end:t[c].timestamp,duration:l}),i=t[c].timestamp)}if(o.push({start:i,end:r}),a.length===0)return{map:c=>(c-e)/n,gaps:[]};let s=0;for(let c=0;c<o.length;c++)s+=o[c].end-o[c].start;let f=a.length*it;f>40&&(f=40);let u=f/a.length,m=100-f,d=[],p=0;for(let c=0;c<o.length;c++){let l=o[c].end-o[c].start,h=s>0?l/s*m:0;d.push({type:"active",start:o[c].start,end:o[c].end,posStart:p,posEnd:p+h}),p+=h,c<a.length&&(d.push({type:"gap",start:a[c].start,end:a[c].end,duration:a[c].duration,posStart:p,posEnd:p+u}),p+=u)}function g(c){for(let l=0;l<d.length;l++){let h=d[l];if(h.type==="active"&&c>=h.start&&c<=h.end){let v=h.end-h.start,Y=v>0?(c-h.start)/v:0;return(h.posStart+Y*(h.posEnd-h.posStart))/100}}for(let l=0;l<d.length;l++)if(d[l].type==="gap"&&c>=d[l].start&&c<=d[l].end)return d[l].posStart/100;return c<=e?0:1}let y=d.filter(c=>c.type==="gap");return{map:g,gaps:y}}function ut(t){if(!t||t.length===0)return[];let e=t[0].timestamp;j=Vt(t);let r=D(t),n=0,a=[];for(let o=0;o<t.length;o++){let i=Yt(t[o]);if(!i)continue;let s={index:o,timestamp:t[o].timestamp,offset:t[o].timestamp-e,position:j.map(t[o].timestamp),category:i,event:t[o]};i==="navigation"&&t[o].type===4&&(n++,s.metaIndex=n),i==="input"&&(s.formContext=r),a.push(s)}return a}function mt(t){return Array.isArray(t)?t.map(e=>({offset:e.offset_ms||0,category:e.kind==="exception"?"server_exception":"server_event",kind:e.kind,label:e.label||"",level:e.level||"",href:e.href||null,isServerMarker:!0})):[]}function q(t){return typeof t.isChecked=="boolean"}function R(t){if(t.isServerMarker)return t.label||(t.category==="server_exception"?"Server exception":"Server event");if(t.category==="navigation"&&t.event.data&&t.event.data.href){let r=t.event.data.href;try{let n=_(new URL(r).pathname,40);return t.metaIndex===1?`Page loaded: ${n}`:`Navigated to: ${n}`}catch{return t.metaIndex===1?"Page loaded":"Page navigation"}}if(t.category==="navigation"&&t.event.data&&t.event.data.payload){let r=t.event.data.payload,n=r.url||"",a=r.external?"Leaving to: ":"Navigating to: ";try{let o=new URL(n),i=r.external?o.hostname+o.pathname:o.pathname;return a+_(i,40)}catch{return a+_(n,40)}}if(t.category==="click"&&t.event.data){let r=t.event.data.type,n="Click";return r===4?n="Double Click":r===3?n="Right Click":r===7&&(n="Touch"),t.event.data.x!=null&&t.event.data.y!=null&&(n+=` (${Math.round(t.event.data.x)}, ${Math.round(t.event.data.y)})`),n}if(t.category==="input"&&t.event.data){let r=t.event.data;return typeof r.text=="string"&&r.text.length>0&&!q(r)?`Input: ${_(r.text,20)}`:q(r)?r.isChecked?"Checkbox checked":"Checkbox unchecked":"Input cleared"}if(t.category==="frustration"){let r=t.x!=null&&t.y!=null?` (${Math.round(t.x)}, ${Math.round(t.y)})`:"";return t.subtype==="rage_click"?`Rage click${r}`:`Dead click${r}`}if(t.category==="error"&&t.event.data&&t.event.data.payload){let r=t.event.data.payload.message||"Error";return _(r,50)}if(t.category==="custom"&&t.event.data&&t.event.data.tag)return t.event.data.tag;let e=C[t.category];return e?e.label:"Event"}function yt(t){if(t.isServerMarker){let n=[];return t.level&&n.push(`Level: ${t.level}`),t.href&&n.push(`Details: ${t.href}`),n}let e=[],r=t.event.data||{};if(t.category==="frustration")return t.x!=null&&t.y!=null&&e.push(`Position: (${Math.round(t.x)}, ${Math.round(t.y)})`),t.subtype==="rage_click"?e.push(`Clicks: ${t.count}`):e.push(`No response within ${t.elapsed||500}ms`),e;if(t.category==="navigation")return r.href?(e.push(`URL: ${r.href}`),r.width&&r.height&&e.push(`Viewport: ${r.width}x${r.height}`)):r.payload&&(r.payload.url&&e.push(`URL: ${r.payload.url}`),r.payload.text&&e.push(`Link text: ${r.payload.text}`),r.payload.external&&e.push("External link")),e;if(t.category==="click")r.x!=null&&r.y!=null&&e.push(`Position: (${Math.round(r.x)}, ${Math.round(r.y)})`);else if(t.category==="input")typeof r.text=="string"&&r.text.length>0&&e.push(`Value: ${r.text}`),q(r)&&e.push(`Checked: ${r.isChecked}`),st(t,t.formContext).forEach(n=>e.push(n));else if(t.category==="error"&&r.payload){let n=r.payload;if(n.message&&e.push(`Message: ${n.message}`),n.source){let a=n.source;n.lineno&&(a+=`:${n.lineno}`),n.colno&&(a+=`:${n.colno}`),e.push(`Source: ${a}`)}n.type&&e.push(`Type: ${n.type}`),n.stack&&e.push(`Stack:
2
+ ${n.stack}`)}else if(t.category==="custom"&&r.payload)try{e.push(`Payload: ${JSON.stringify(r.payload,null,2)}`)}catch{e.push("Payload: [object]")}return e}var Wt=1.5,ht=!1,X=null,gt=0;function jt(t){if(t.length===0)return[];let e=[],r={events:[t[0]],position:t[0].position*100};for(let n=1;n<t.length;n++){let a=t[n].position*100;Math.abs(a-r.position)<=Wt?r.events.push(t[n]):(e.push(r),r={events:[t[n]],position:a})}return e.push(r),e}function J(){let t=document.querySelectorAll(".marker-dropdown");for(let e=0;e<t.length;e++)t[e].parentNode.removeChild(t[e])}function Et(t){if(!X)return;let e=w();if(!e)return;let r=e.map(gt+t)*100;X.style.left=`${Math.max(0,Math.min(100,r))}%`}function xt(t,e){let r=document.getElementById("event-markers");if(!r)return;if(t.length===0){r.style.display="none";return}e&&e.length>0&&(gt=e[0].timestamp),r.innerHTML="";let n=document.createElement("div");n.className="marker-legend",Object.keys(C).forEach(m=>{let d=C[m],p=document.createElement("span");p.className="marker-legend-item";let g=document.createElement("span");g.className="marker-legend-dot",g.style.backgroundColor=d.color,p.appendChild(g),p.appendChild(document.createTextNode(d.label)),n.appendChild(p)}),r.appendChild(n);let a=document.createElement("div");a.className="marker-bar";let o=document.createElement("div");o.className="marker-track",a.appendChild(o);let i=document.createElement("div");i.className="marker-playhead",i.style.left="0%",a.appendChild(i),X=i;let s=t;s.length>500&&(s=t.filter(m=>m.category!=="input")),jt(s).forEach(m=>{let d=`${Math.max(1,Math.min(99,m.position))}%`;if(m.events.length===1){let p=m.events[0],g=C[p.category];if(!g)return;let y=document.createElement("div");y.className=`event-marker${p.category==="error"?" error-marker":""}`,y.style.left=d,y.style.backgroundColor=g.color,y.title=`${R(p)} at ${M(p.offset)}`,y.addEventListener("click",()=>P(p.offset)),a.appendChild(y)}else{let p=m.events.some(l=>l.category==="error"),g=document.createElement("div");g.className="marker-group",g.style.left=d;let y=document.createElement("div");y.className=`event-marker marker-group-dot${p?" error-marker":""}`,y.style.backgroundColor=p?"#ff0000":"#6c757d",y.title=`${m.events.length} events at ${M(m.events[0].offset)}`;let c=document.createElement("span");c.className="marker-group-count",c.textContent=m.events.length,g.appendChild(y),g.appendChild(c),g.addEventListener("click",l=>{l.stopPropagation(),J();let h=document.createElement("div");h.className="marker-dropdown",m.events.forEach(v=>{let Y=C[v.category]||{},N=document.createElement("div");N.className=`marker-dropdown-item${v.category==="error"?" error-entry":""}`;let V=document.createElement("span");V.className="activity-dot",V.style.backgroundColor=Y.color||"#6c757d";let ot=document.createElement("span");ot.textContent=`${M(v.offset)} ${R(v)}`,N.appendChild(V),N.appendChild(ot),N.addEventListener("click",Bt=>{Bt.stopPropagation(),J(),P(v.offset)}),h.appendChild(N)}),g.appendChild(h)}),a.appendChild(g)}});let u=w();u&&u.gaps.length>0&&u.gaps.forEach(m=>{let d=document.createElement("div");d.className="marker-gap",d.style.left=`${m.posStart}%`,d.style.width=`${m.posEnd-m.posStart}%`,d.title=`Inactive: ${S(m.duration)}`,a.appendChild(d)}),ht||(ht=!0,document.addEventListener("click",J)),r.appendChild(a),r.style.display=""}var F=null,z=[];function Ct(t){let e=document.getElementById("activity-sidebar"),r=document.getElementById("activity-list"),n=document.getElementById("activity-count");if(!(!e||!r)){if(t.length===0){e.style.display="none";return}z=t,e.style.display="",n&&(n.textContent=t.length),r.innerHTML="",t.forEach((a,o)=>{let i=C[a.category];if(!i)return;if(o>0){let y=t[o-1].offset,c=a.offset-y;if(c>k){let l=document.createElement("div");l.className="activity-gap",l.textContent=`${S(c)} inactive`,r.appendChild(l)}}let s=document.createElement("div");s.className="activity-wrapper";let f=document.createElement("div"),u="activity-entry";a.category==="error"?u+=" error-entry":a.category==="navigation"?u+=" navigation-entry":a.category==="frustration"?u+=" frustration-entry":a.category==="server_exception"?u+=" server-exception-entry":a.category==="server_event"&&(u+=" server-event-entry"),f.className=u,f.setAttribute("data-offset",a.offset),f.setAttribute("data-index",o);let m=document.createElement("span");m.className="activity-time",m.textContent=M(a.offset);let d=document.createElement("span");d.className="activity-dot",d.style.backgroundColor=i.color;let p;a.isServerMarker&&a.href?(p=document.createElement("a"),p.href=a.href,p.className="activity-label activity-label-link",p.addEventListener("click",y=>y.stopPropagation())):(p=document.createElement("span"),p.className="activity-label"),p.textContent=R(a),f.appendChild(m),f.appendChild(d),f.appendChild(p),f.addEventListener("click",()=>P(a.offset)),s.appendChild(f);let g=yt(a);if(g.length>0){let y=document.createElement("div");y.className="activity-detail",g.forEach(c=>{let l=document.createElement("div");if(l.className="activity-detail-line",c.indexOf(`
3
+ `)!==-1){let h=document.createElement("pre");h.className="activity-detail-pre",h.textContent=c,l.appendChild(h)}else l.textContent=c;y.appendChild(l)}),s.appendChild(y)}r.appendChild(s)}),qt()}}function Q(){F&&(clearInterval(F),F=null)}function qt(){Q(),F=setInterval(()=>{let t=A();if(t)try{let r=t.getReplayer().getCurrentTime();Jt(r),Et(r)}catch{}},250)}function Kt(t,e){if(!t)return;let r=e.getBoundingClientRect(),n=r.top-t.getBoundingClientRect().top-t.clientTop+t.scrollTop,a=n+r.height;n<t.scrollTop?t.scrollTo({top:n,behavior:"smooth"}):a>t.scrollTop+t.clientHeight&&t.scrollTo({top:a-t.clientHeight,behavior:"smooth"})}function Jt(t){let e=document.querySelectorAll(".activity-wrapper");if(e.length===0)return;let r=-1;for(let n=z.length-1;n>=0;n--)if(z[n].offset<=t){r=n;break}for(let n=0;n<e.length;n++){let a=e[n].querySelector(".activity-entry");a&&(n===r?a.classList.contains("active")||(a.classList.add("active"),Kt(document.getElementById("activity-sidebar"),e[n])):a.classList.remove("active"))}}var Xt=["LCP","CLS","INP"];function zt(t){let e=Object.create(null);if(!Array.isArray(t))return e;for(let r of t)if(r&&r.type===5&&r.data&&r.data.tag==="__perf"&&r.data.payload){let n=r.data.payload;typeof n.metric=="string"&&typeof n.value=="number"&&(e[n.metric]={value:n.value,rating:n.rating})}return e}function Qt(t,e){return t==="CLS"?e.toFixed(3):`${Math.round(e)} ms`}function Zt(t){return t==="good"?"badge-success":t==="needs-improvement"?"badge-warning":t==="poor"?"badge-danger":"badge-neutral"}function vt(t,e){if(!e)return;let r=zt(t);e.innerHTML="";let n=!1;for(let a of Xt){let o=r[a];if(!o)continue;n=!0;let i=document.createElement("span");i.className=`badge ${Zt(o.rating)} shrink-0`,i.textContent=`${a} ${Qt(a,o.value)}`,o.rating&&(i.title=o.rating),e.appendChild(i)}e.style.display=n?"inline-flex":"none"}function ee(t){let e=0;if(!Array.isArray(t))return e;for(let r=0;r<t.length;r++){let n=t[r];n&&n.type===3&&n.data&&n.data.source===3&&typeof n.data.y=="number"&&n.data.y>e&&(e=n.data.y)}return e}function re(t){if(!Array.isArray(t))return null;for(let e=0;e<t.length;e++){let r=t[e];if(r&&r.type===4&&r.data&&r.data.height)return r.data.height}return null}function Z(t){let e=Math.round(ee(t));if(e<=0)return null;let r=re(t),n=r&&r>0?Math.round((e+r)/r*10)/10:null;return{y:e,viewports:n}}function Tt(t,e){if(!e)return;let r=Z(t);if(!r){e.style.display="none",e.textContent="";return}let n=r.viewports!=null?` (~${r.viewports}\xD7 viewport)`:"";e.textContent=`Max Scroll: ${r.y}px${n}`,e.style.display="inline"}var bt=6,kt=1e3,b=!1,I=null;function ne(t){let e=[];if(!Array.isArray(t))return e;for(let r=0;r<t.length;r++){let n=t[r];n&&n.type===3&&n.data&&n.data.source===2&&n.data.type===2&&typeof n.data.x=="number"&&typeof n.data.y=="number"&&e.push({x:n.data.x,y:n.data.y})}return e}function ae(t){if(!Array.isArray(t))return null;for(let e=0;e<t.length;e++){let r=t[e];if(r&&r.type===4&&r.data&&r.data.width&&r.data.height)return{width:r.data.width,height:r.data.height}}return null}function oe(t,e,r){let n=e&&e.width>0?r.width/e.width:1,a=e&&e.height>0?r.height/e.height:1,o=Math.max(0,Math.min(r.width,t.x*n)),i=Math.max(0,Math.min(r.height,t.y*a));return{x:o,y:i}}function ie(){let t=document.getElementById("replayer");return t?t.querySelector(".replayer-wrapper")||t.querySelector(".rr-player__frame")||t:null}function tt(){I&&I.parentNode&&I.parentNode.removeChild(I),I=null}function ce(){tt();let t=ie();if(!t)return;let e=U(),r=ae(e),n=ne(e);if(n.length>kt){let i=Math.ceil(n.length/kt);n=n.filter((s,f)=>f%i===0)}let a={width:t.clientWidth||(r?r.width:0),height:t.clientHeight||(r?r.height:0)},o=document.createElement("div");o.className="click-overlay-container",n.forEach(i=>{let{x:s,y:f}=oe(i,r,a),u=document.createElement("div");u.className="click-dot",u.style.left=`${s-bt}px`,u.style.top=`${f-bt}px`,o.appendChild(u)}),getComputedStyle(t).position==="static"&&(t.style.position="relative"),t.appendChild(o),I=o}function St(){b=!1,tt();let t=document.getElementById("toggle-clicks-btn");t&&(t.setAttribute("aria-pressed","false"),t.classList.remove("btn-active"))}function H(){if(!A())return b;b=!b,b?ce():tt();let t=document.getElementById("toggle-clicks-btn");return t&&(t.setAttribute("aria-pressed",b?"true":"false"),t.classList.toggle("btn-active",b)),b}var Mt=500,_t=10,At=3,It=500;function Nt(t){return!!(t&&t.type===3&&t.data&&t.data.source===2&&t.data.type===2&&typeof t.data.x=="number"&&typeof t.data.y=="number")}function se(t){return!t||!t.data?!1:t.type===4?!0:t.type===3?t.data.source===0||t.data.source===5:!1}function de(t){if(!Array.isArray(t))return[];let e=[];for(let a=0;a<t.length;a++)Nt(t[a])&&e.push(t[a]);if(e.length<At)return[];let r=[],n=0;for(let a=1;a<=e.length;a++){let o=e[a-1],i=e[a],s=e[n];if(!(i&&i.timestamp-o.timestamp<=Mt&&i.timestamp-s.timestamp<=Mt&&Math.abs(i.data.x-s.data.x)<=_t&&Math.abs(i.data.y-s.data.y)<=_t)){let u=a-n;if(u>=At){let m=[];for(let d=n;d<a;d++)m.push(e[d].timestamp);r.push({subtype:"rage_click",timestamp:s.timestamp,count:u,x:s.data.x,y:s.data.y,memberTimestamps:m,event:s})}n=a}}return r}function pe(t){if(!Array.isArray(t))return[];let e=[];for(let r=0;r<t.length;r++){if(!Nt(t[r]))continue;let n=t[r].timestamp,a=n+It,o=!1;for(let i=r+1;i<t.length;i++){let s=t[i].timestamp;if(s>a)break;if(s>n&&se(t[i])){o=!0;break}}o||e.push({subtype:"dead_click",timestamp:n,x:t[r].data.x,y:t[r].data.y,elapsed:It,event:t[r]})}return e}function G(t){if(!Array.isArray(t)||t.length===0)return[];let e=t[0].timestamp,r=de(t),n=pe(t),a=new Set;r.forEach(i=>(i.memberTimestamps||[i.timestamp]).forEach(s=>a.add(s)));let o=[];return r.forEach(i=>o.push(i)),n.forEach(i=>{a.has(i.timestamp)||o.push(i)}),o.map(i=>({category:"frustration",subtype:i.subtype,timestamp:i.timestamp,offset:i.timestamp-e,count:i.count,elapsed:i.elapsed,x:i.x,y:i.y,event:i.event})).sort((i,s)=>i.offset-s.offset)}var fe=1e4,ue=1e3;function me(t){return t.type===3&&t.data&&t.data.source>=1&&t.data.source<=5}function ye(t,e=fe){if(!Array.isArray(t)||t.length===0)return[];let r=t[0].timestamp,n=[0],a=!1;for(let i of t)me(i)&&(n.push(i.timestamp-r),a=!0);if(!a)return[];n.push(t[t.length-1].timestamp-r);let o=[];for(let i=1;i<n.length;i++)n[i]-n[i-1]>e&&o.push({start:n[i-1],end:n[i]});return o}function he(t,e,r=ue){for(let n of t){let a=n.end-r;if(e>=n.start&&e<a)return a}return null}function Lt(t,e){let r=ye(e);if(r.length===0)return;let n=!1;t.addEventListener("ui-update-player-state",a=>{n=a.payload==="playing"}),t.addEventListener("ui-update-current-time",a=>{if(!n)return;let o;try{o=t.getReplayer().config.skipInactive!==!1}catch{return}if(!o)return;let i=he(r,a.payload);i!==null&&t.goto(i,!0)})}var x=null,Pt=[],Ot=[1,2,4,8,16],et=0,$t=!1;function A(){return x}function U(){return Pt}function ge(t){let e=document.getElementById("active-time");if(!e)return;let r=ct(t);if(r>0){let n=t[t.length-1].timestamp-t[0].timestamp,a=n>0?Math.round(r/n*100):100;e.textContent=`(active: ${S(r)} / ${a}%)`,e.style.display="inline"}}function Ee(t){let e=document.getElementById("viewport-info");if(!e)return;let r=lt(t);r&&(e.textContent=`Viewport: ${r}`,e.style.display="inline")}function xe(t){if(t.length===0)return null;let r=new URLSearchParams(window.location.search).get("t");if(r!==null&&r!==""){let n=parseInt(r,10);if(!isNaN(n)){let a=n-t[0].timestamp;return a>=0?t[0].timestamp+a:null}}return null}function P(t){if(x)try{let e=x.getReplayer();e&&(e.play(t),e.pause())}catch(e){console.warn("Sentiero: seek failed",e)}}function wt(t,e){try{let r=t.getMetaData(),n=t.getCurrentTime(),a=Math.max(0,Math.min(n+e,r.totalTime));t.play(a),t.pause()}catch(r){console.warn("Sentiero: seek failed",r)}}function Rt(t){et=Math.max(0,Math.min(Ot.length-1,et+t));try{let e=x.getReplayer();e&&e.setConfig&&e.setConfig({speed:Ot[et]})}catch(e){console.warn("Sentiero: speed change failed",e)}}function Ce(){$t||($t=!0,document.addEventListener("keydown",t=>{if(!x||t.target.tagName==="INPUT"||t.target.tagName==="TEXTAREA")return;let e;try{e=x.getReplayer()}catch{return}if(e)switch(t.key){case" ":t.preventDefault();try{x.$$?.ctx?.[0]?x.toggle():e.play()}catch{try{e.play()}catch{}}break;case"ArrowLeft":t.preventDefault(),wt(e,-5e3);break;case"ArrowRight":t.preventDefault(),wt(e,5e3);break;case"ArrowUp":t.preventDefault(),Rt(1);break;case"ArrowDown":t.preventDefault(),Rt(-1);break}}))}function rt(t,e,r=[]){if(!e){console.error("Sentiero: player container not found");return}B(t,(n,a)=>{if(n){console.error("Sentiero: failed to load events",n),e.innerHTML="";let l=document.createElement("div");l.className="alert alert-danger",l.textContent=`Failed to load session events: ${n.message}`,e.appendChild(l);return}if(a.length===0){e.innerHTML='<p class="text-muted">No events recorded for this window.</p>';return}Pt=a,ge(a),Ee(a),Tt(a,document.getElementById("scroll-depth-info")),vt(a,document.getElementById("web-vitals-badges"));let o=ut(a),i=w(),s=a[0].timestamp,f=G(a);f.forEach(l=>{l.position=i?i.map(l.timestamp):0});let u=Array.isArray(r)?r:[];u.forEach(l=>{l.position=i?i.map(s+l.offset):0});let m=o.concat(f).concat(u).sort((l,h)=>l.offset-h.offset);xt(m,a),Ct(m);let d=e.closest(".player-frame")||e.parentElement,p=d?d.clientWidth:1024,g=Math.min(2048,p),y=rrwebPlayer.default||rrwebPlayer;x=new y({target:e,props:{events:a,showController:!0,autoPlay:!1,skipInactive:!0,width:g}});let c=xe(a);if(c)try{let l=x.getReplayer();l&&setTimeout(()=>{l.play(c-a[0].timestamp),l.pause()},100)}catch(l){console.warn("Sentiero: could not seek to timestamp",l)}Lt(x,a),St(),Ce()})}function nt(){let t=A(),e=U();if(!(!t||e.length===0))try{let n=t.getReplayer().getCurrentTime(),a=new URL(window.location.href);a.searchParams.set("t",Math.round(n)),navigator.clipboard.writeText(a.toString()).then(()=>{let o=document.getElementById("copy-link-btn");if(o){let i=o.textContent;o.textContent="Copied!",setTimeout(()=>{o.textContent=i},2e3)}})}catch(r){console.warn("Sentiero: copy link failed",r)}}function at(){let t=U();if(t.length!==0)try{let e=JSON.stringify(t,null,2),r=new Blob([e],{type:"application/json"}),n=URL.createObjectURL(r),a=document.createElement("a");a.href=n,a.download="sentiero-session-events.json",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(n)}catch(e){console.warn("Sentiero: download failed",e)}}function Ut(){document.addEventListener("click",t=>{let e=t.target.closest("[data-action]");if(!e)return;let r=e.getAttribute("data-action");if(r==="copy-link")nt();else if(r==="toggle-clicks")H();else if(r==="download-json")at();else if(r==="confirm-delete"){let n=e.getAttribute("data-confirm")||"Are you sure?";confirm(n)||t.preventDefault()}})}function ve(t,e,r){return!(e!=="all"&&t.dataset.activityKind!==e||r&&t.dataset.activityLevel!==r)}function Dt(){let t=document.getElementById("server-activity");if(!t)return;let e=Array.from(t.querySelectorAll("[data-activity-row]"));if(e.length===0)return;let r=t.querySelector("[data-activity-filter-type]"),n=t.querySelector("[data-activity-filter-level]");if(!r&&!n)return;let a="all",o="";function i(){e.forEach(s=>{let f=ve(s,a,o);s.style.display=f?"":"none"})}r&&r.addEventListener("click",s=>{let f=s.target.closest("[data-filter-type]");f&&(a=f.dataset.filterType,Array.from(r.querySelectorAll("[data-filter-type]")).forEach(u=>{u.classList.toggle("btn-active",u===f),u.classList.toggle("btn-secondary",u!==f)}),i())}),n&&n.addEventListener("change",()=>{o=n.value,i()})}window.SentieroDashboard={initPlayer:rt,fetchAllEvents:B,copyLink:nt,downloadJSON:at,toggleClickOverlay:H,computeScrollDepth:Z,detectFrustrationEvents:G,analyzeFormInteractions:W,buildFormContext:D};function Te(){let t=document.getElementById("server-activity-markers");if(!t)return[];try{return mt(JSON.parse(t.textContent))}catch(e){return console.warn("Sentiero: failed to parse server-activity markers",e),[]}}document.addEventListener("DOMContentLoaded",()=>{let t=document.getElementById("sentiero-player-config");if(t)try{let e=JSON.parse(t.textContent),r=document.getElementById("replayer");if(e.eventsUrl&&r){let n=Te();rt(e.eventsUrl,r,n)}}catch(e){console.error("Sentiero: failed to parse player config",e)}Ut(),Dt(),window.addEventListener("pagehide",Q)});})();
@@ -1,6 +1,6 @@
1
1
  {
2
- "recorder": "recorder-W2RCU5VM.js",
3
- "dashboard": "dashboard-YINQE5ZH.js",
2
+ "recorder": "recorder-DCSAOXX4.js",
3
+ "dashboard": "dashboard-R7JYFY62.js",
4
4
  "sessions_index": "sessions_index-2RAGTEZM.js",
5
5
  "analytics": "analytics-RH24EOLD.js",
6
6
  "heatmap": "heatmap-EBKFWSKN.js",