sentiero 1.0.0.alpha1
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 +7 -0
- data/LICENSE.txt +7 -0
- data/README.md +679 -0
- data/lib/sentiero/analytics/analyzer.rb +91 -0
- data/lib/sentiero/analytics/bounded.rb +29 -0
- data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
- data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
- data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
- data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
- data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
- data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
- data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
- data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
- data/lib/sentiero/analytics/entry_attribution.rb +71 -0
- data/lib/sentiero/analytics/error_discovery.rb +118 -0
- data/lib/sentiero/analytics/events.rb +21 -0
- data/lib/sentiero/analytics/exporter.rb +242 -0
- data/lib/sentiero/analytics/form_analyzer.rb +153 -0
- data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
- data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
- data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
- data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
- data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
- data/lib/sentiero/analytics/problem_detail.rb +97 -0
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
- data/lib/sentiero/analytics/segmenter.rb +133 -0
- data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
- data/lib/sentiero/analytics/stats.rb +30 -0
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
- data/lib/sentiero/configuration.rb +184 -0
- data/lib/sentiero/erasure.rb +48 -0
- data/lib/sentiero/fingerprint.rb +34 -0
- data/lib/sentiero/ip_anonymizer.rb +29 -0
- data/lib/sentiero/redaction/config.rb +61 -0
- data/lib/sentiero/redaction.rb +207 -0
- data/lib/sentiero/reporter/configuration.rb +50 -0
- data/lib/sentiero/reporter/context.rb +31 -0
- data/lib/sentiero/reporter/dispatcher.rb +91 -0
- data/lib/sentiero/reporter/http_transport.rb +57 -0
- data/lib/sentiero/reporter/log_transport.rb +26 -0
- data/lib/sentiero/reporter/middleware.rb +62 -0
- data/lib/sentiero/reporter/normalizer.rb +14 -0
- data/lib/sentiero/reporter/null_transport.rb +18 -0
- data/lib/sentiero/reporter/report_context.rb +29 -0
- data/lib/sentiero/reporter/scrubber.rb +47 -0
- data/lib/sentiero/reporter/test_helper.rb +32 -0
- data/lib/sentiero/reporter/test_transport.rb +28 -0
- data/lib/sentiero/reporter.rb +214 -0
- data/lib/sentiero/roda.rb +47 -0
- data/lib/sentiero/store/error_store.rb +220 -0
- data/lib/sentiero/store/limits.rb +31 -0
- data/lib/sentiero/store/session_store.rb +118 -0
- data/lib/sentiero/store.rb +72 -0
- data/lib/sentiero/stores/file.rb +566 -0
- data/lib/sentiero/stores/memory.rb +362 -0
- data/lib/sentiero/stores/redis/keys.rb +59 -0
- data/lib/sentiero/stores/redis/lua.rb +119 -0
- data/lib/sentiero/stores/redis.rb +665 -0
- data/lib/sentiero/stores/sqlite/schema.rb +79 -0
- data/lib/sentiero/stores/sqlite.rb +626 -0
- data/lib/sentiero/user_agent.rb +32 -0
- data/lib/sentiero/version.rb +5 -0
- data/lib/sentiero/web/analytics_app.rb +538 -0
- data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
- data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
- data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
- data/lib/sentiero/web/assets/manifest.json +11 -0
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
- data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
- data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
- data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
- data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
- data/lib/sentiero/web/assets_app.rb +42 -0
- data/lib/sentiero/web/base_app.rb +319 -0
- data/lib/sentiero/web/basic_auth.rb +27 -0
- data/lib/sentiero/web/basic_auth_check.rb +41 -0
- data/lib/sentiero/web/body_reader.rb +44 -0
- data/lib/sentiero/web/csv_writer.rb +45 -0
- data/lib/sentiero/web/dashboard_app.rb +236 -0
- data/lib/sentiero/web/errors_app.rb +97 -0
- data/lib/sentiero/web/escaping.rb +37 -0
- data/lib/sentiero/web/events_app.rb +196 -0
- data/lib/sentiero/web/formatting.rb +43 -0
- data/lib/sentiero/web/ingest_app.rb +92 -0
- data/lib/sentiero/web/manifest.rb +43 -0
- data/lib/sentiero/web/monitoring_app.rb +316 -0
- data/lib/sentiero/web/script_tag.rb +57 -0
- data/lib/sentiero/web/shareable_replay.rb +88 -0
- data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
- data/lib/sentiero/web/templates/_brand.html.erb +18 -0
- data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
- data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
- data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
- data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
- data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
- data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
- data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
- data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
- data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
- data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
- data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
- data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
- data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
- data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
- data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
- data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
- data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
- data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
- data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
- data/lib/sentiero/web/templates/event_show.html.erb +52 -0
- data/lib/sentiero/web/templates/events_index.html.erb +177 -0
- data/lib/sentiero/web/templates/export_index.html.erb +69 -0
- data/lib/sentiero/web/templates/forms.html.erb +105 -0
- data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
- data/lib/sentiero/web/templates/import.html.erb +39 -0
- data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
- data/lib/sentiero/web/templates/segments.html.erb +114 -0
- data/lib/sentiero/web/templates/session_show.html.erb +195 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
- data/lib/sentiero/web/track_app.rb +57 -0
- data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
- data/lib/sentiero/web/views/analyzer_view.rb +27 -0
- data/lib/sentiero/web/views/base_view.rb +76 -0
- data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
- data/lib/sentiero/web/views/conversions_view.rb +41 -0
- data/lib/sentiero/web/views/engagement_view.rb +67 -0
- data/lib/sentiero/web/views/errors_index_view.rb +37 -0
- data/lib/sentiero/web/views/event_show_view.rb +20 -0
- data/lib/sentiero/web/views/events_index_view.rb +56 -0
- data/lib/sentiero/web/views/export_view.rb +23 -0
- data/lib/sentiero/web/views/forms_view.rb +28 -0
- data/lib/sentiero/web/views/frustration_view.rb +15 -0
- data/lib/sentiero/web/views/funnel_view.rb +36 -0
- data/lib/sentiero/web/views/heatmap_view.rb +34 -0
- data/lib/sentiero/web/views/import_view.rb +13 -0
- data/lib/sentiero/web/views/page_report_view.rb +43 -0
- data/lib/sentiero/web/views/problem_show_view.rb +46 -0
- data/lib/sentiero/web/views/scroll_view.rb +23 -0
- data/lib/sentiero/web/views/segments_view.rb +28 -0
- data/lib/sentiero/web/views/session_show_view.rb +105 -0
- data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
- data/lib/sentiero/web/views/vitals_view.rb +45 -0
- data/lib/sentiero/web/views.rb +24 -0
- data/lib/sentiero/window_ref.rb +6 -0
- data/lib/sentiero.rb +69 -0
- metadata +232 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
# User-Agent classifier into coarse buckets — good enough for distribution
|
|
5
|
+
# charts, not for precise version detection.
|
|
6
|
+
module UserAgent
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def device(user_agent)
|
|
10
|
+
return if !user_agent || user_agent.empty?
|
|
11
|
+
if user_agent.match?(/Tablet|iPad/i)
|
|
12
|
+
"Tablet"
|
|
13
|
+
elsif user_agent.match?(/Mobile|Android|iPhone/i)
|
|
14
|
+
"Mobile"
|
|
15
|
+
else
|
|
16
|
+
"Desktop"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def browser(user_agent)
|
|
21
|
+
return if !user_agent || user_agent.empty?
|
|
22
|
+
case user_agent
|
|
23
|
+
when /Edg\//i then "Edge"
|
|
24
|
+
when /OPR\//i, /Opera/i then "Opera"
|
|
25
|
+
when /Chrome\//i then "Chrome"
|
|
26
|
+
when /Safari\//i then "Safari"
|
|
27
|
+
when /Firefox\//i then "Firefox"
|
|
28
|
+
else "Other"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_app"
|
|
4
|
+
require_relative "../analytics/stats_aggregator"
|
|
5
|
+
require_relative "../analytics/segmenter"
|
|
6
|
+
require_relative "../analytics/error_discovery"
|
|
7
|
+
require_relative "../analytics/heatmap_analyzer"
|
|
8
|
+
require_relative "../analytics/scroll_depth_analyzer"
|
|
9
|
+
require_relative "../analytics/form_analyzer"
|
|
10
|
+
require_relative "../analytics/exporter"
|
|
11
|
+
require_relative "csv_writer"
|
|
12
|
+
require_relative "shareable_replay"
|
|
13
|
+
|
|
14
|
+
module Sentiero
|
|
15
|
+
module Web
|
|
16
|
+
# Rack app owning all /analytics/* routes.
|
|
17
|
+
# Mounted at the same point as DashboardApp (which delegates /analytics requests here),
|
|
18
|
+
# so PATH_INFO/SCRIPT_NAME are read from env to preserve base_path.
|
|
19
|
+
class AnalyticsApp < BaseApp
|
|
20
|
+
def initialize
|
|
21
|
+
super
|
|
22
|
+
BaseApp.warn_unauthenticated_once
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(env)
|
|
26
|
+
path = env["PATH_INFO"] || "/"
|
|
27
|
+
|
|
28
|
+
return unauthorized_response unless authorized?(env)
|
|
29
|
+
|
|
30
|
+
case path
|
|
31
|
+
when "/analytics"
|
|
32
|
+
handle_overview(env)
|
|
33
|
+
when "/analytics/heatmap"
|
|
34
|
+
handle_heatmap(env)
|
|
35
|
+
when "/analytics/heatmap.json"
|
|
36
|
+
handle_heatmap_json(env)
|
|
37
|
+
when "/analytics/segments"
|
|
38
|
+
handle_segments(env)
|
|
39
|
+
when "/analytics/scroll"
|
|
40
|
+
handle_scroll(env)
|
|
41
|
+
when "/analytics/vitals"
|
|
42
|
+
handle_vitals(env)
|
|
43
|
+
when "/analytics/frustration"
|
|
44
|
+
handle_frustration(env)
|
|
45
|
+
when "/analytics/funnel"
|
|
46
|
+
handle_funnel(env)
|
|
47
|
+
when "/analytics/engagement"
|
|
48
|
+
handle_engagement(env)
|
|
49
|
+
when "/analytics/conversions"
|
|
50
|
+
handle_conversions(env)
|
|
51
|
+
when "/analytics/forms"
|
|
52
|
+
handle_forms(env)
|
|
53
|
+
when "/analytics/page"
|
|
54
|
+
handle_page(env)
|
|
55
|
+
when "/analytics/export"
|
|
56
|
+
handle_export_index(env)
|
|
57
|
+
when %r{\A/analytics/export/(\w+)\.(csv|json)\z}
|
|
58
|
+
handle_export_download(env, $1, $2)
|
|
59
|
+
when %r{\A/analytics/share/([^/]+)\z}
|
|
60
|
+
id = $1
|
|
61
|
+
return invalid_id unless valid_id?(id)
|
|
62
|
+
return not_found unless shareable_replays?
|
|
63
|
+
handle_share(env, id)
|
|
64
|
+
when "/analytics/import"
|
|
65
|
+
return not_found unless shareable_replays?
|
|
66
|
+
handle_import(env)
|
|
67
|
+
else
|
|
68
|
+
not_found
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ALLOWED_RANGES = [14, 30, 90].freeze
|
|
73
|
+
DEFAULT_RANGE = 30
|
|
74
|
+
|
|
75
|
+
BROWSER_OPTIONS = %w[Chrome Safari Firefox Edge Opera Other].freeze
|
|
76
|
+
DEVICE_OPTIONS = %w[Desktop Mobile Tablet].freeze
|
|
77
|
+
METADATA_MATCH_OPTIONS = %w[exact contains].freeze
|
|
78
|
+
|
|
79
|
+
ENGAGEMENT_SORTS = %w[score duration].freeze
|
|
80
|
+
|
|
81
|
+
# Cap on free-text filter inputs
|
|
82
|
+
MAX_FILTER_LENGTH = 256
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def shareable_replays?
|
|
87
|
+
Sentiero.configuration.shareable_replays
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_segments(env)
|
|
91
|
+
params = query_params(env)
|
|
92
|
+
filters = parse_segment_filters(params)
|
|
93
|
+
|
|
94
|
+
page, per_page, offset = paginate(params, default: 20, max: 100)
|
|
95
|
+
|
|
96
|
+
result = Sentiero::Analytics::Segmenter.new(Sentiero.store, **filters.except(:since_param, :until_param))
|
|
97
|
+
.matching(limit: per_page, offset: offset)
|
|
98
|
+
|
|
99
|
+
audit!(env, action: :list_sessions)
|
|
100
|
+
|
|
101
|
+
render_page(env, Views::SegmentsView.new(
|
|
102
|
+
filters: filters,
|
|
103
|
+
browser_options: BROWSER_OPTIONS,
|
|
104
|
+
device_options: DEVICE_OPTIONS,
|
|
105
|
+
sessions: result[:sessions],
|
|
106
|
+
page: page,
|
|
107
|
+
per_page: per_page,
|
|
108
|
+
has_next: result[:has_next],
|
|
109
|
+
was_truncated: result[:was_truncated],
|
|
110
|
+
filter_query: segment_filter_query(filters)
|
|
111
|
+
))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def render_analyzer_page(env, analyzer:, view_class:)
|
|
115
|
+
params = query_params(env)
|
|
116
|
+
since, until_time = parse_range_params(params)
|
|
117
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
118
|
+
|
|
119
|
+
result = analyzer.new(Sentiero.store).analyze(since: since, until_time: until_time)
|
|
120
|
+
|
|
121
|
+
render_page(env, view_class.new(
|
|
122
|
+
pages: result[:pages],
|
|
123
|
+
was_truncated: result[:was_truncated],
|
|
124
|
+
since: since_param,
|
|
125
|
+
until_str: until_param
|
|
126
|
+
))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_scroll(env)
|
|
130
|
+
render_analyzer_page(env,
|
|
131
|
+
analyzer: Sentiero::Analytics::ScrollDepthAnalyzer,
|
|
132
|
+
view_class: Views::ScrollView)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_forms(env)
|
|
136
|
+
params = query_params(env)
|
|
137
|
+
since, until_time = parse_range_params(params)
|
|
138
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
139
|
+
|
|
140
|
+
result = Sentiero::Analytics::FormAnalyzer.new(Sentiero.store)
|
|
141
|
+
.analyze(since: since, until_time: until_time)
|
|
142
|
+
|
|
143
|
+
render_page(env, Views::FormsView.new(
|
|
144
|
+
sessions_started: result[:sessions_with_form_interaction],
|
|
145
|
+
sessions_completed: result[:sessions_completed],
|
|
146
|
+
completion_rate: result[:completion_rate],
|
|
147
|
+
total_submits: result[:total_submits],
|
|
148
|
+
fields: result[:fields],
|
|
149
|
+
drop_off_fields: result[:drop_off_fields],
|
|
150
|
+
was_truncated: result[:was_truncated],
|
|
151
|
+
since: since_param,
|
|
152
|
+
until_str: until_param
|
|
153
|
+
))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Per-URL drill-down composing every metric for one selected URL. Read-only
|
|
157
|
+
# aggregate (no session listing), so not audited.
|
|
158
|
+
def handle_page(env)
|
|
159
|
+
require_relative "../analytics/page_report_analyzer"
|
|
160
|
+
|
|
161
|
+
params = query_params(env)
|
|
162
|
+
url = clean_text(params["url"])
|
|
163
|
+
since, until_time = parse_range_params(params)
|
|
164
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
165
|
+
|
|
166
|
+
urls = Sentiero::Analytics::HeatmapAnalyzer.new(Sentiero.store).recorded_urls.sort
|
|
167
|
+
|
|
168
|
+
report = if url
|
|
169
|
+
Sentiero::Analytics::PageReportAnalyzer.new(Sentiero.store)
|
|
170
|
+
.analyze(url, since: since, until_time: until_time)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
render_page(env, Views::PageReportView.new(
|
|
174
|
+
report: report,
|
|
175
|
+
urls: urls,
|
|
176
|
+
selected_url: url,
|
|
177
|
+
was_truncated: report ? report[:was_truncated] : false,
|
|
178
|
+
since: since_param,
|
|
179
|
+
until_str: until_param
|
|
180
|
+
))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def handle_heatmap(env)
|
|
184
|
+
params = query_params(env)
|
|
185
|
+
selected_url = clean_text(params["url"])
|
|
186
|
+
since, until_time = parse_range_params(params)
|
|
187
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
188
|
+
|
|
189
|
+
analyzer = Sentiero::Analytics::HeatmapAnalyzer.new(Sentiero.store)
|
|
190
|
+
# The picker lists every recorded page (not just in-range ones) so a
|
|
191
|
+
# range tweak never empties the dropdown.
|
|
192
|
+
urls = analyzer.recorded_urls.sort
|
|
193
|
+
selected_url ||= urls.first
|
|
194
|
+
|
|
195
|
+
# Only run the (expensive) per-event scan when a URL is selected. This
|
|
196
|
+
# scan exists only to render the truncation banner; heatmap.json scans
|
|
197
|
+
# again for the grid data (deduplicating is a deferred optimization).
|
|
198
|
+
was_truncated = selected_url ? analyzer.analyze(selected_url, since: since, until_time: until_time)[:was_truncated] : false
|
|
199
|
+
|
|
200
|
+
base_path = base_path(env)
|
|
201
|
+
config = JSON.generate({
|
|
202
|
+
jsonUrl: heatmap_json_url(base_path, since_param, until_param),
|
|
203
|
+
eventsUrlTemplate: "#{base_path}/api/sessions/{session}/windows/{window}/events",
|
|
204
|
+
selectedUrl: selected_url
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
render_page(env, Views::HeatmapView.new(
|
|
208
|
+
urls: urls,
|
|
209
|
+
selected_url: selected_url,
|
|
210
|
+
was_truncated: was_truncated,
|
|
211
|
+
config_json: config,
|
|
212
|
+
since: since_param,
|
|
213
|
+
until_str: until_param
|
|
214
|
+
))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def heatmap_json_url(base_path, since_param, until_param)
|
|
218
|
+
url = "#{base_path}/analytics/heatmap.json"
|
|
219
|
+
range = {"since" => since_param, "until" => until_param}.reject { |_key, value| value.empty? }
|
|
220
|
+
range.empty? ? url : "#{url}?#{Rack::Utils.build_query(range)}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Read-only JSON API for the heatmap canvas. The bucket grid is keyed by
|
|
224
|
+
# [col, row] tuples server-side; it is emitted as a flat list of
|
|
225
|
+
# {x, y, count} so it round-trips through JSON.
|
|
226
|
+
def handle_heatmap_json(env)
|
|
227
|
+
params = query_params(env)
|
|
228
|
+
url = clean_text(params["url"])
|
|
229
|
+
since, until_time = parse_range_params(params)
|
|
230
|
+
|
|
231
|
+
result = if url
|
|
232
|
+
Sentiero::Analytics::HeatmapAnalyzer.new(Sentiero.store).analyze(url, since: since, until_time: until_time)
|
|
233
|
+
else
|
|
234
|
+
{clicks_by_bucket: {}, top_elements: [], total_clicks: 0, representative_window: nil, was_truncated: false}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
payload = {
|
|
238
|
+
grid_size: Sentiero::Analytics::HeatmapAnalyzer::GRID_SIZE,
|
|
239
|
+
total_clicks: result[:total_clicks],
|
|
240
|
+
clicks_by_bucket: result[:clicks_by_bucket].map { |(x, y), count| {x: x, y: y, count: count} },
|
|
241
|
+
top_elements: result[:top_elements],
|
|
242
|
+
representative_window: result[:representative_window],
|
|
243
|
+
was_truncated: result[:was_truncated]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
[200, json_headers, [JSON.generate(payload)]]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def handle_export_index(env)
|
|
250
|
+
params = query_params(env)
|
|
251
|
+
since, until_time = parse_range_params(params)
|
|
252
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
253
|
+
|
|
254
|
+
render_page(env, Views::ExportView.new(
|
|
255
|
+
shareable_replays: shareable_replays?,
|
|
256
|
+
since: since_param,
|
|
257
|
+
until_str: until_param,
|
|
258
|
+
datasets: Sentiero::Analytics::Exporter::DATASETS
|
|
259
|
+
))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Downloads are POST + CSRF-guarded.
|
|
263
|
+
def handle_export_download(env, dataset, format)
|
|
264
|
+
return [405, {"content-type" => "text/plain"}, ["Method Not Allowed"]] unless env["REQUEST_METHOD"] == "POST"
|
|
265
|
+
|
|
266
|
+
post_params = Rack::Request.new(env).POST
|
|
267
|
+
return forbidden_csrf unless valid_csrf_token?(env, post_params["csrf_token"])
|
|
268
|
+
|
|
269
|
+
exporter = Sentiero::Analytics::Exporter.new(Sentiero.store)
|
|
270
|
+
return not_found unless exporter.dataset?(dataset)
|
|
271
|
+
|
|
272
|
+
audit!(env, action: :export, dataset: dataset)
|
|
273
|
+
|
|
274
|
+
since, until_time = parse_range_params(post_params)
|
|
275
|
+
table = exporter.table(dataset, since: since, until_time: until_time)
|
|
276
|
+
body, content_type = render_export(table, format)
|
|
277
|
+
filename = "#{dataset}#{range_filename_suffix(since, until_time)}.#{format}"
|
|
278
|
+
[200, download_headers(content_type, filename), [body]]
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def range_filename_suffix(since, until_time)
|
|
282
|
+
return "" unless since || until_time
|
|
283
|
+
|
|
284
|
+
from = since ? Time.at(since).utc.strftime("%Y-%m-%d") : "start"
|
|
285
|
+
to = until_time ? Time.at(until_time).utc.strftime("%Y-%m-%d") : "now"
|
|
286
|
+
"_#{from}_to_#{to}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def render_export(table, format)
|
|
290
|
+
if format == "csv"
|
|
291
|
+
[CsvWriter.generate(table[:headers], table[:rows]), "text/csv"]
|
|
292
|
+
else
|
|
293
|
+
[JSON.generate(table), "application/json"]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def handle_share(env, id)
|
|
298
|
+
html = ShareableReplay.new(Sentiero.store, id).html
|
|
299
|
+
return not_found if html.nil?
|
|
300
|
+
|
|
301
|
+
audit!(env, action: :share, session_id: id)
|
|
302
|
+
|
|
303
|
+
[200, download_headers("text/html", "session-#{sanitize_filename(id)}.html"), [html]]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def sanitize_filename(id)
|
|
307
|
+
id.gsub(/[^a-zA-Z0-9_-]/, "")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def download_headers(content_type, filename)
|
|
311
|
+
{
|
|
312
|
+
"content-type" => content_type,
|
|
313
|
+
"content-disposition" => "attachment; filename=\"#{filename}\"",
|
|
314
|
+
"x-content-type-options" => "nosniff"
|
|
315
|
+
}
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def parse_segment_filters(params)
|
|
319
|
+
since = parse_since_param(params["since"])
|
|
320
|
+
until_time = parse_until_param(params["until"])
|
|
321
|
+
|
|
322
|
+
{
|
|
323
|
+
browser: allowed(params["browser"], BROWSER_OPTIONS),
|
|
324
|
+
device: allowed(params["device"], DEVICE_OPTIONS),
|
|
325
|
+
url_pattern: clean_text(params["url_pattern"]),
|
|
326
|
+
metadata_key: clean_text(params["metadata_key"]),
|
|
327
|
+
metadata_value: clean_text(params["metadata_value"]),
|
|
328
|
+
metadata_match: allowed(params["metadata_match"], METADATA_MATCH_OPTIONS) || "exact",
|
|
329
|
+
has_errors: params["has_errors"] == "true",
|
|
330
|
+
min_duration_ms: parse_duration_seconds(params["min_duration"]),
|
|
331
|
+
max_duration_ms: parse_duration_seconds(params["max_duration"]),
|
|
332
|
+
since: since,
|
|
333
|
+
until_time: until_time,
|
|
334
|
+
# Raw strings, echoed back to the form/pagination only when they
|
|
335
|
+
# parsed successfully (so garbage input is dropped, not round-tripped).
|
|
336
|
+
since_param: since ? clean_text(params["since"]) : nil,
|
|
337
|
+
until_param: until_time ? clean_text(params["until"]) : nil
|
|
338
|
+
}
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def segment_filter_query(filters)
|
|
342
|
+
query = {
|
|
343
|
+
"browser" => filters[:browser],
|
|
344
|
+
"device" => filters[:device],
|
|
345
|
+
"url_pattern" => filters[:url_pattern],
|
|
346
|
+
"metadata_key" => filters[:metadata_key],
|
|
347
|
+
"metadata_value" => filters[:metadata_value],
|
|
348
|
+
"metadata_match" => filters[:metadata_match],
|
|
349
|
+
"min_duration" => filters[:min_duration_ms]&.then { |ms| ms / 1000 },
|
|
350
|
+
"max_duration" => filters[:max_duration_ms]&.then { |ms| ms / 1000 },
|
|
351
|
+
"since" => filters[:since_param],
|
|
352
|
+
"until" => filters[:until_param]
|
|
353
|
+
}.compact
|
|
354
|
+
query["has_errors"] = "true" if filters[:has_errors]
|
|
355
|
+
Rack::Utils.build_query(query)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def allowed(value, options)
|
|
359
|
+
options.include?(value) ? value : nil
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def clean_text(value)
|
|
363
|
+
return nil unless value.is_a?(String)
|
|
364
|
+
stripped = value.strip
|
|
365
|
+
return nil if stripped.empty?
|
|
366
|
+
stripped[0, MAX_FILTER_LENGTH]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# The form takes durations in whole seconds; the Segmenter works in ms.
|
|
370
|
+
def parse_duration_seconds(value)
|
|
371
|
+
return nil unless value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
372
|
+
value.to_i * 1000
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def handle_overview(env)
|
|
376
|
+
params = query_params(env)
|
|
377
|
+
range_days = parse_range(params["range"])
|
|
378
|
+
# Custom since/until bounds take precedence over the ?range=N preset
|
|
379
|
+
# (which stays supported as an alias / quick-fill).
|
|
380
|
+
since, until_time = parse_range_params(params)
|
|
381
|
+
|
|
382
|
+
aggregator = Sentiero::Analytics::StatsAggregator.new(Sentiero.store)
|
|
383
|
+
# One widened scan yields both the current window (carrying the bounded
|
|
384
|
+
# server-exception overlay) and the equal-length prior window for deltas.
|
|
385
|
+
combined = aggregator.aggregate_with_prior(range_days: range_days, since: since, until_time: until_time)
|
|
386
|
+
stats = combined[:current]
|
|
387
|
+
|
|
388
|
+
render_page(env, Views::AnalyticsIndexView.new(
|
|
389
|
+
range_days: range_days,
|
|
390
|
+
allowed_ranges: ALLOWED_RANGES,
|
|
391
|
+
custom_range: !(since.nil? && until_time.nil?),
|
|
392
|
+
since: since ? params["since"].to_s : "",
|
|
393
|
+
until_str: until_time ? params["until"].to_s : "",
|
|
394
|
+
deltas: overview_deltas(stats, combined[:prior]),
|
|
395
|
+
stats: stats
|
|
396
|
+
))
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def parse_range(value)
|
|
400
|
+
range = value.to_i
|
|
401
|
+
ALLOWED_RANGES.include?(range) ? range : DEFAULT_RANGE
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Deltas against the prior (equal-length, immediately preceding) window.
|
|
405
|
+
# Skipped when the prior aggregate is absent (zero-length window or a
|
|
406
|
+
# truncated scan) or when either window's scan was truncated.
|
|
407
|
+
def overview_deltas(stats, prior)
|
|
408
|
+
return nil if stats[:was_truncated]
|
|
409
|
+
return nil if prior.nil? || prior[:was_truncated]
|
|
410
|
+
|
|
411
|
+
{
|
|
412
|
+
sessions: percent_delta(stats[:total_sessions], prior[:total_sessions]),
|
|
413
|
+
events: percent_delta(stats[:total_events], prior[:total_events]),
|
|
414
|
+
error_free_rate: error_free_rate_delta(stats, prior)
|
|
415
|
+
}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def percent_delta(current, prior)
|
|
419
|
+
return nil if prior.nil? || prior.zero?
|
|
420
|
+
((current - prior).to_f / prior * 100).round(1)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def error_free_rate_delta(stats, prior)
|
|
424
|
+
current_rate = error_free_rate(stats)
|
|
425
|
+
prior_rate = error_free_rate(prior)
|
|
426
|
+
return nil unless current_rate && prior_rate
|
|
427
|
+
(current_rate - prior_rate).round(1)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def error_free_rate(stats)
|
|
431
|
+
total = stats[:total_sessions]
|
|
432
|
+
return nil if total.zero?
|
|
433
|
+
(1 - stats[:sessions_with_errors].to_f / total) * 100
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def handle_vitals(env)
|
|
437
|
+
require_relative "../analytics/web_vitals_analyzer"
|
|
438
|
+
|
|
439
|
+
render_analyzer_page(env,
|
|
440
|
+
analyzer: Sentiero::Analytics::WebVitalsAnalyzer,
|
|
441
|
+
view_class: Views::VitalsView)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def handle_frustration(env)
|
|
445
|
+
require_relative "../analytics/frustration_analyzer"
|
|
446
|
+
|
|
447
|
+
render_analyzer_page(env,
|
|
448
|
+
analyzer: Sentiero::Analytics::FrustrationAnalyzer,
|
|
449
|
+
view_class: Views::FrustrationView)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
FUNNEL_STEP_PARAMS = %w[step1 step2 step3].freeze
|
|
453
|
+
|
|
454
|
+
def handle_funnel(env)
|
|
455
|
+
require_relative "../analytics/funnel_analyzer"
|
|
456
|
+
|
|
457
|
+
params = query_params(env)
|
|
458
|
+
since, until_time = parse_range_params(params)
|
|
459
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
460
|
+
|
|
461
|
+
requested = FUNNEL_STEP_PARAMS.filter_map { |key| clean_text(params[key]) }
|
|
462
|
+
steps = Sentiero::Analytics::FunnelAnalyzer.usable_steps(requested)
|
|
463
|
+
|
|
464
|
+
result = Sentiero::Analytics::FunnelAnalyzer.new(Sentiero.store)
|
|
465
|
+
.analyze(steps, since: since, until_time: until_time)
|
|
466
|
+
|
|
467
|
+
render_page(env, Views::FunnelView.new(
|
|
468
|
+
# Selected steps stay choosable even when out of the scanned range.
|
|
469
|
+
tags: (result[:tags] + steps).uniq.sort,
|
|
470
|
+
selected_steps: steps,
|
|
471
|
+
steps: result[:steps],
|
|
472
|
+
was_truncated: result[:was_truncated],
|
|
473
|
+
since: since_param,
|
|
474
|
+
until_str: until_param
|
|
475
|
+
))
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def handle_conversions(env)
|
|
479
|
+
require_relative "../analytics/conversion_analyzer"
|
|
480
|
+
|
|
481
|
+
params = query_params(env)
|
|
482
|
+
since, until_time = parse_range_params(params)
|
|
483
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
484
|
+
|
|
485
|
+
requested = clean_text(params["tag"])
|
|
486
|
+
tag = Sentiero::Analytics::FunnelAnalyzer.usable_steps([requested].compact).first
|
|
487
|
+
|
|
488
|
+
result = Sentiero::Analytics::ConversionAnalyzer.new(Sentiero.store)
|
|
489
|
+
.analyze(tag, since: since, until_time: until_time)
|
|
490
|
+
|
|
491
|
+
render_page(env, Views::ConversionsView.new(
|
|
492
|
+
# The selected tag stays choosable even when out of the scanned range.
|
|
493
|
+
tags: (result[:tags] + [tag].compact).uniq.sort,
|
|
494
|
+
selected_tag: tag,
|
|
495
|
+
entry_pages: result[:entry_pages],
|
|
496
|
+
referrers: result[:referrers],
|
|
497
|
+
utm: result[:utm],
|
|
498
|
+
was_truncated: result[:was_truncated],
|
|
499
|
+
since: since_param,
|
|
500
|
+
until_str: until_param
|
|
501
|
+
))
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Per-session struggle score ranking. Surfaces individual sessions, so audited.
|
|
505
|
+
def handle_engagement(env)
|
|
506
|
+
require_relative "../analytics/engagement_analyzer"
|
|
507
|
+
|
|
508
|
+
params = query_params(env)
|
|
509
|
+
since, until_time = parse_range_params(params)
|
|
510
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
511
|
+
|
|
512
|
+
# Closed allow-list: anything off it (including bogus values) becomes
|
|
513
|
+
# "score" and is never echoed back into the page.
|
|
514
|
+
sort = ENGAGEMENT_SORTS.include?(params["sort"]) ? params["sort"] : "score"
|
|
515
|
+
|
|
516
|
+
result = Sentiero::Analytics::EngagementAnalyzer.new(Sentiero.store)
|
|
517
|
+
.analyze(since: since, until_time: until_time)
|
|
518
|
+
|
|
519
|
+
audit!(env, action: :list_sessions)
|
|
520
|
+
|
|
521
|
+
render_page(env, Views::EngagementView.new(
|
|
522
|
+
sessions: result[:sessions],
|
|
523
|
+
distribution: result[:distribution],
|
|
524
|
+
scanned: result[:scanned],
|
|
525
|
+
was_truncated: result[:was_truncated],
|
|
526
|
+
sort: sort,
|
|
527
|
+
since: since_param,
|
|
528
|
+
until_str: until_param
|
|
529
|
+
))
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Client-side replay page; the replay runs in the browser, so this only renders.
|
|
533
|
+
def handle_import(env)
|
|
534
|
+
render_page(env, Views::ImportView.new)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var n=document.querySelector("[data-analytics-range]");if(n){let t=n.form,a=document.querySelector("[data-analytics-apply]"),o=t?Array.from(t.querySelectorAll('input[type="date"]')):[];n.addEventListener("change",()=>{for(let e of o)e.value="";t&&t.submit()});for(let e of o)e.addEventListener("change",()=>{t&&t.submit()});a&&(a.style.display="none")}})();
|
|
@@ -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"}},it=1e3,S=5e3,lt=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>S&&(e+=n-r,r=o),n=o}return e+=n-r,e}function k(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 st(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 Bt(t){return!!(t&&t.type===3&&t.data&&t.data.source===5&&typeof t.data.id=="number")}function Gt(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(!Bt(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,Gt(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 U(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 Ht(t){return t<1e3?`${Math.round(t/100)*100}ms`:`${(Math.round(t/100)/10).toFixed(1)}s`}function dt(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<=S&&a.push(`Time to next field: ${Ht(n.nextFieldOffset)}`),a}function F(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}if(r=r.concat(o),o.length<it){e(null,r);return}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 Vt(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 Yt(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 l=1;l<t.length;l++){let c=t[l].timestamp-t[l-1].timestamp;c>S&&(o.push({start:i,end:t[l-1].timestamp}),a.push({start:t[l-1].timestamp,end:t[l].timestamp,duration:c}),i=t[l].timestamp)}if(o.push({start:i,end:r}),a.length===0)return{map:l=>(l-e)/n,gaps:[]};let s=0;for(let l=0;l<o.length;l++)s+=o[l].end-o[l].start;let f=a.length*lt;f>40&&(f=40);let u=f/a.length,m=100-f,d=[],p=0;for(let l=0;l<o.length;l++){let c=o[l].end-o[l].start,h=s>0?c/s*m:0;d.push({type:"active",start:o[l].start,end:o[l].end,posStart:p,posEnd:p+h}),p+=h,l<a.length&&(d.push({type:"gap",start:a[l].start,end:a[l].end,duration:a[l].duration,posStart:p,posEnd:p+u}),p+=u)}function g(l){for(let c=0;c<d.length;c++){let h=d[c];if(h.type==="active"&&l>=h.start&&l<=h.end){let v=h.end-h.start,V=v>0?(l-h.start)/v:0;return(h.posStart+V*(h.posEnd-h.posStart))/100}}for(let c=0;c<d.length;c++)if(d[c].type==="gap"&&l>=d[c].start&&l<=d[c].end)return d[c].posStart/100;return l<=e?0:1}let y=d.filter(l=>l.type==="gap");return{map:g,gaps:y}}function mt(t){if(!t||t.length===0)return[];let e=t[0].timestamp;j=Yt(t);let r=U(t),n=0,a=[];for(let o=0;o<t.length;o++){let i=Vt(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 yt(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 ht(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}`),dt(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,gt=!1,X=null,Et=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 xt(t){if(!X)return;let e=w();if(!e)return;let r=e.map(Et+t)*100;X.style.left=`${Math.max(0,Math.min(100,r))}%`}function Ct(t,e){let r=document.getElementById("event-markers");if(!r)return;if(t.length===0){r.style.display="none";return}e&&e.length>0&&(Et=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(c=>c.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 l=document.createElement("span");l.className="marker-group-count",l.textContent=m.events.length,g.appendChild(y),g.appendChild(l),g.addEventListener("click",c=>{c.stopPropagation(),J();let h=document.createElement("div");h.className="marker-dropdown",m.events.forEach(v=>{let V=C[v.category]||{},O=document.createElement("div");O.className=`marker-dropdown-item${v.category==="error"?" error-entry":""}`;let Y=document.createElement("span");Y.className="activity-dot",Y.style.backgroundColor=V.color||"#6c757d";let ot=document.createElement("span");ot.textContent=`${M(v.offset)} ${R(v)}`,O.appendChild(Y),O.appendChild(ot),O.addEventListener("click",Ft=>{Ft.stopPropagation(),J(),P(v.offset)}),h.appendChild(O)}),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: ${k(m.duration)}`,a.appendChild(d)}),gt||(gt=!0,document.addEventListener("click",J)),r.appendChild(a),r.style.display=""}var B=null,z=[];function vt(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,l=a.offset-y;if(l>S){let c=document.createElement("div");c.className="activity-gap",c.textContent=`${k(l)} inactive`,r.appendChild(c)}}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=ht(a);if(g.length>0){let y=document.createElement("div");y.className="activity-detail",g.forEach(l=>{let c=document.createElement("div");if(c.className="activity-detail-line",l.indexOf(`
|
|
3
|
+
`)!==-1){let h=document.createElement("pre");h.className="activity-detail-pre",h.textContent=l,c.appendChild(h)}else c.textContent=l;y.appendChild(c)}),s.appendChild(y)}r.appendChild(s)}),qt()}}function Z(){B&&(clearInterval(B),B=null)}function qt(){Z(),B=setInterval(()=>{let t=A();if(t)try{let r=t.getReplayer().getCurrentTime();Kt(r),xt(r)}catch{}},250)}function Kt(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"),e[n].scrollIntoView({block:"nearest",behavior:"smooth"})):a.classList.remove("active"))}}var Jt=["LCP","CLS","INP"];function Xt(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 zt(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 Tt(t,e){if(!e)return;let r=Xt(t);e.innerHTML="";let n=!1;for(let a of Jt){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} ${zt(a,o.value)}`,o.rating&&(i.title=o.rating),e.appendChild(i)}e.style.display=n?"inline-flex":"none"}function te(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 ee(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 Q(t){let e=te(t);if(e<=0)return null;let r=ee(t),n=r&&r>0?Math.round((e+r)/r*10)/10:null;return{y:e,viewports:n}}function bt(t,e){if(!e)return;let r=Q(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 St=6,kt=1e3,b=!1,I=null;function re(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 ne(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 ae(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 oe(){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 ie(){tt();let t=oe();if(!t)return;let e=D(),r=ne(e),n=re(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}=ae(i,r,a),u=document.createElement("div");u.className="click-dot",u.style.left=`${s-St}px`,u.style.top=`${f-St}px`,o.appendChild(u)}),getComputedStyle(t).position==="static"&&(t.style.position="relative"),t.appendChild(o),I=o}function Mt(){b=!1,tt();let t=document.getElementById("toggle-clicks-btn");t&&(t.setAttribute("aria-pressed","false"),t.classList.remove("btn-active"))}function G(){if(!A())return b;b=!b,b?ie():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 _t=500,At=10,It=3,Ot=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 ce(t){return!t||!t.data?!1:t.type===4?!0:t.type===3?t.data.source===0||t.data.source===5:!1}function se(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<It)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<=_t&&i.timestamp-s.timestamp<=_t&&Math.abs(i.data.x-s.data.x)<=At&&Math.abs(i.data.y-s.data.y)<=At)){let u=a-n;if(u>=It){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 de(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+Ot,o=!1;for(let i=r+1;i<t.length;i++){let s=t[i].timestamp;if(s>a)break;if(s>n&&ce(t[i])){o=!0;break}}o||e.push({subtype:"dead_click",timestamp:n,x:t[r].data.x,y:t[r].data.y,elapsed:Ot,event:t[r]})}return e}function H(t){if(!Array.isArray(t)||t.length===0)return[];let e=t[0].timestamp,r=se(t),n=de(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 x=null,Pt=[],Lt=[1,2,4,8,16],et=0,$t=!1;function A(){return x}function D(){return Pt}function pe(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: ${k(r)} / ${a}%)`,e.style.display="inline"}}function fe(t){let e=document.getElementById("viewport-info");if(!e)return;let r=st(t);r&&(e.textContent=`Viewport: ${r}`,e.style.display="inline")}function ue(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(Lt.length-1,et+t));try{let e=x.getReplayer();e&&e.setConfig&&e.setConfig({speed:Lt[et]})}catch(e){console.warn("Sentiero: speed change failed",e)}}function me(){$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}F(t,(n,a)=>{if(n){console.error("Sentiero: failed to load events",n),e.innerHTML="";let c=document.createElement("div");c.className="alert alert-danger",c.textContent=`Failed to load session events: ${n.message}`,e.appendChild(c);return}if(a.length===0){e.innerHTML='<p class="text-muted">No events recorded for this window.</p>';return}Pt=a,pe(a),fe(a),bt(a,document.getElementById("scroll-depth-info")),Tt(a,document.getElementById("web-vitals-badges"));let o=mt(a),i=w(),s=a[0].timestamp,f=H(a);f.forEach(c=>{c.position=i?i.map(c.timestamp):0});let u=Array.isArray(r)?r:[];u.forEach(c=>{c.position=i?i.map(s+c.offset):0});let m=o.concat(f).concat(u).sort((c,h)=>c.offset-h.offset);Ct(m,a),vt(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 l=ue(a);if(l)try{let c=x.getReplayer();c&&setTimeout(()=>{c.play(l-a[0].timestamp),c.pause()},100)}catch(c){console.warn("Sentiero: could not seek to timestamp",c)}Mt(),me()})}function nt(){let t=A(),e=D();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=D();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 Dt(){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")G();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 ye(t,e,r){return!(e!=="all"&&t.dataset.activityKind!==e||r&&t.dataset.activityLevel!==r)}function Ut(){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=ye(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:F,copyLink:nt,downloadJSON:at,toggleClickOverlay:G,computeScrollDepth:Q,detectFrustrationEvents:H,analyzeFormInteractions:W,buildFormContext:U};function he(){let t=document.getElementById("server-activity-markers");if(!t)return[];try{return yt(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=he();rt(e.eventsUrl,r,n)}}catch(e){console.error("Sentiero: failed to parse player config",e)}Dt(),Ut(),window.addEventListener("pagehide",Z)});})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var i=[{stop:0,color:[37,99,235]},{stop:.5,color:[219,39,119]},{stop:1,color:[234,88,12]}];function p(n,c,t){return n+(c-n)*t}function C(n){let c=Math.max(0,Math.min(1,n));for(let t=1;t<i.length;t++){let r=i[t-1],o=i[t];if(c<=o.stop){let e=(c-r.stop)/(o.stop-r.stop||1);return[Math.round(p(r.color[0],o.color[0],e)),Math.round(p(r.color[1],o.color[1],e)),Math.round(p(r.color[2],o.color[2],e))]}}return i[i.length-1].color}async function w(n,c){let t=n.includes("?")?"&":"?",r=await fetch(`${n}${t}url=${encodeURIComponent(c)}`,{headers:{Accept:"application/json"}});if(!r.ok)throw new Error(`heatmap request failed: ${r.status}`);return r.json()}async function E(n,c){if(!c)return null;let t=n.replace("{session}",encodeURIComponent(c.session_id)).replace("{window}",encodeURIComponent(c.window_id));try{let r=await fetch(t,{headers:{Accept:"application/json"}});if(!r.ok)return null;let o=await r.json();for(let e of o)if(e&&e.type===4&&e.data&&e.data.width&&e.data.height)return{width:e.data.width,height:e.data.height}}catch{return null}return null}function _(n,c,t){let r=c.grid_size||20,o=t&&t.width>0?t.height/t.width:1,e=720,s=Math.round(e*o);n.width=e,n.height=s;let a=n.getContext("2d");if(!a)return;a.clearRect(0,0,e,s);let d=c.clicks_by_bucket||[],h=d.reduce((l,u)=>Math.max(l,u.count),0);if(h===0)return;let m=e/r,f=s/r;for(let l of d){let u=l.count/h,[y,g,x]=C(u);a.fillStyle=`rgba(${y}, ${g}, ${x}, ${.25+u*.55})`,a.fillRect(l.x*m,l.y*f,m,f)}}function b(n,c){let t=c.top_elements||[],r=c.total_clicks||0;if(n.replaceChildren(),t.length===0){let o=document.createElement("tr"),e=document.createElement("td");e.className="py-2 text-gray-400 text-center",e.textContent="No element data.",o.appendChild(e),n.appendChild(o);return}for(let o of t){let e=document.createElement("tr");e.className="border-b border-gray-100 last:border-0";let s=document.createElement("td");s.className="py-1 font-mono text-gray-700 truncate max-w-0 w-full",s.textContent=o.selector;let a=document.createElement("td");a.className="py-1 pl-2 text-right text-gray-500 tabular-nums";let d=r>0?Math.round(o.count/r*100):0;a.textContent=`${o.count} (${d}%)`,e.append(s,a),n.appendChild(e)}}async function k(n){let c=document.getElementById("heatmap-status"),t=document.getElementById("heatmap-canvas"),r=document.getElementById("heatmap-top-elements");if(!(!n.selectedUrl||!t||!r))try{let o=await w(n.jsonUrl,n.selectedUrl),e=await E(n.eventsUrlTemplate,o.representative_window);_(t,o,e),b(r,o),c&&(c.textContent=o.total_clicks===0?"No clicks recorded for this page.":`${o.total_clicks} clicks aggregated.`)}catch{c&&(c.textContent="Could not load heatmap data.")}}function $(){let n=document.getElementById("heatmap-config");if(!n)return;let c;try{c=JSON.parse(n.textContent)}catch{return}let t=document.querySelector("[data-heatmap-url]");if(t&&t.form){t.addEventListener("change",()=>t.form.submit());for(let o of t.form.querySelectorAll('input[type="date"]'))o.addEventListener("change",()=>t.form.submit());let r=document.querySelector("[data-heatmap-apply]");r&&(r.style.display="none")}k(c)}$();})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var f="import-player",c="import-status",p="import-textarea",y="import-file",m="import-dropzone",v="import-replay";function E(t){let r=(t||"").trim();if(!r)return{ok:!1,error:"Paste some JSON or choose a file first."};let e;try{e=JSON.parse(r)}catch{return{ok:!1,error:"That doesn't look like valid JSON."}}return Array.isArray(e)?{ok:!0,events:e}:{ok:!1,error:"Expected a JSON array of events."}}function g(t){if(!Array.isArray(t))return{ok:!1,error:"Expected a JSON array of events."};if(t.length===0)return{ok:!1,error:"The file contains no events."};if(t.length<2)return{ok:!1,error:"At least two events are needed to replay."};for(let r of t){if(!r||typeof r!="object")return{ok:!1,error:"Every event must be an object."};if(typeof r.type!="number")return{ok:!1,error:"Every event needs a numeric type."};if(typeof r.timestamp!="number")return{ok:!1,error:"Every event needs a numeric timestamp."}}return{ok:!0}}function h(t,r){if(typeof rrwebPlayer>"u")throw new Error("rrweb-player global not loaded");let e=rrwebPlayer.default||rrwebPlayer;return t.replaceChildren(),new e({target:t,props:{events:r,width:t.clientWidth||900,autoPlay:!0,showController:!0}})}function a(t,r){let e=document.getElementById(c);e&&(e.textContent=t,e.classList.toggle("text-red-600",!!r))}function l(t,r){let e=E(t);if(!e.ok)return a(e.error,!0),!1;let n=g(e.events);if(!n.ok)return a(n.error,!0),!1;try{h(r,e.events)}catch{return a("Could not start the player.",!0),!1}return a(`Replaying ${e.events.length} events.`,!1),!0}function d(t,r){if(t.size>26214400){a("That file is too large to import.",!0);return}let e=new FileReader;e.onload=()=>r(String(e.result||"")),e.onerror=()=>a("Could not read that file.",!0),e.readAsText(t)}function I(){let t=document.getElementById(f),r=document.getElementById(p),e=document.getElementById(y),n=document.getElementById(m),i=document.getElementById(v);t&&(i&&r&&i.addEventListener("click",()=>l(r.value,t)),e&&r&&e.addEventListener("change",()=>{let o=e.files&&e.files[0];o&&d(o,s=>{r.value=s,l(s,t)})}),n&&r&&(n.addEventListener("dragover",o=>{o.preventDefault(),n.classList.add("border-blue-400")}),n.addEventListener("dragleave",()=>n.classList.remove("border-blue-400")),n.addEventListener("drop",o=>{o.preventDefault(),n.classList.remove("border-blue-400");let s=o.dataTransfer&&o.dataTransfer.files[0];s&&d(s,u=>{r.value=u,l(u,t)})})))}typeof document<"u"&&I();})();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"recorder": "recorder-SLLXSUUX.js",
|
|
3
|
+
"dashboard": "dashboard-JFYNHZZV.js",
|
|
4
|
+
"sessions_index": "sessions_index-2RAGTEZM.js",
|
|
5
|
+
"analytics": "analytics-RH24EOLD.js",
|
|
6
|
+
"heatmap": "heatmap-EBKFWSKN.js",
|
|
7
|
+
"import": "import-HIMBJJ4S.js",
|
|
8
|
+
"style": "style-d71e72fd.css",
|
|
9
|
+
"rrweb-player": "rrweb-player-cd435a95.js",
|
|
10
|
+
"rrweb-player-css": "rrweb-player-css-ce5e9629.css"
|
|
11
|
+
}
|