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 +4 -4
- data/lib/sentiero/analytics/error_discovery.rb +40 -3
- data/lib/sentiero/analytics/exporter.rb +1 -1
- data/lib/sentiero/store/error_store.rb +28 -0
- data/lib/sentiero/stores/file.rb +47 -0
- data/lib/sentiero/stores/memory.rb +27 -1
- data/lib/sentiero/stores/redis/keys.rb +4 -0
- data/lib/sentiero/stores/redis.rb +29 -0
- data/lib/sentiero/stores/sqlite/payload_codec.rb +42 -0
- data/lib/sentiero/stores/sqlite/schema.rb +36 -0
- data/lib/sentiero/stores/sqlite.rb +56 -4
- data/lib/sentiero/version.rb +1 -1
- data/lib/sentiero/web/assets/dashboard-R7JYFY62.js +3 -0
- data/lib/sentiero/web/assets/manifest.json +2 -2
- data/lib/sentiero/web/assets/{recorder-W2RCU5VM.js → recorder-DCSAOXX4.js} +3 -3
- data/lib/sentiero/web/monitoring_app.rb +39 -4
- data/lib/sentiero/web/templates/_client_error_status_badge.html.erb +10 -0
- data/lib/sentiero/web/templates/_client_error_status_form.html.erb +12 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +9 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +24 -1
- data/lib/sentiero/web/templates/errors_index.html.erb +13 -4
- data/lib/sentiero/web/views/client_error_show_view.rb +8 -1
- metadata +6 -3
- data/lib/sentiero/web/assets/dashboard-YINQE5ZH.js +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e055280481854b52d5e5d4eba3a51a9f0e377d182b53acb2816aeeac51551ef8
|
|
4
|
+
data.tar.gz: 04dd008f190ecd97d78c75b6b99a23b32f1bac1a6f8b257edbba8ff89a4decda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/sentiero/stores/file.rb
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
|
data/lib/sentiero/version.rb
CHANGED
|
@@ -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-
|
|
3
|
-
"dashboard": "dashboard-
|
|
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",
|