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.
Files changed (155) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +7 -0
  3. data/README.md +679 -0
  4. data/lib/sentiero/analytics/analyzer.rb +91 -0
  5. data/lib/sentiero/analytics/bounded.rb +29 -0
  6. data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
  7. data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
  8. data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
  9. data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
  10. data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
  11. data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
  12. data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
  13. data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
  14. data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
  15. data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
  16. data/lib/sentiero/analytics/entry_attribution.rb +71 -0
  17. data/lib/sentiero/analytics/error_discovery.rb +118 -0
  18. data/lib/sentiero/analytics/events.rb +21 -0
  19. data/lib/sentiero/analytics/exporter.rb +242 -0
  20. data/lib/sentiero/analytics/form_analyzer.rb +153 -0
  21. data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
  22. data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
  23. data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
  24. data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
  25. data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
  26. data/lib/sentiero/analytics/problem_detail.rb +97 -0
  27. data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
  28. data/lib/sentiero/analytics/segmenter.rb +133 -0
  29. data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
  30. data/lib/sentiero/analytics/stats.rb +30 -0
  31. data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
  32. data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
  33. data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
  34. data/lib/sentiero/configuration.rb +184 -0
  35. data/lib/sentiero/erasure.rb +48 -0
  36. data/lib/sentiero/fingerprint.rb +34 -0
  37. data/lib/sentiero/ip_anonymizer.rb +29 -0
  38. data/lib/sentiero/redaction/config.rb +61 -0
  39. data/lib/sentiero/redaction.rb +207 -0
  40. data/lib/sentiero/reporter/configuration.rb +50 -0
  41. data/lib/sentiero/reporter/context.rb +31 -0
  42. data/lib/sentiero/reporter/dispatcher.rb +91 -0
  43. data/lib/sentiero/reporter/http_transport.rb +57 -0
  44. data/lib/sentiero/reporter/log_transport.rb +26 -0
  45. data/lib/sentiero/reporter/middleware.rb +62 -0
  46. data/lib/sentiero/reporter/normalizer.rb +14 -0
  47. data/lib/sentiero/reporter/null_transport.rb +18 -0
  48. data/lib/sentiero/reporter/report_context.rb +29 -0
  49. data/lib/sentiero/reporter/scrubber.rb +47 -0
  50. data/lib/sentiero/reporter/test_helper.rb +32 -0
  51. data/lib/sentiero/reporter/test_transport.rb +28 -0
  52. data/lib/sentiero/reporter.rb +214 -0
  53. data/lib/sentiero/roda.rb +47 -0
  54. data/lib/sentiero/store/error_store.rb +220 -0
  55. data/lib/sentiero/store/limits.rb +31 -0
  56. data/lib/sentiero/store/session_store.rb +118 -0
  57. data/lib/sentiero/store.rb +72 -0
  58. data/lib/sentiero/stores/file.rb +566 -0
  59. data/lib/sentiero/stores/memory.rb +362 -0
  60. data/lib/sentiero/stores/redis/keys.rb +59 -0
  61. data/lib/sentiero/stores/redis/lua.rb +119 -0
  62. data/lib/sentiero/stores/redis.rb +665 -0
  63. data/lib/sentiero/stores/sqlite/schema.rb +79 -0
  64. data/lib/sentiero/stores/sqlite.rb +626 -0
  65. data/lib/sentiero/user_agent.rb +32 -0
  66. data/lib/sentiero/version.rb +5 -0
  67. data/lib/sentiero/web/analytics_app.rb +538 -0
  68. data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
  69. data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
  70. data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
  71. data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
  72. data/lib/sentiero/web/assets/manifest.json +11 -0
  73. data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
  74. data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
  75. data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
  76. data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
  77. data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
  78. data/lib/sentiero/web/assets_app.rb +42 -0
  79. data/lib/sentiero/web/base_app.rb +319 -0
  80. data/lib/sentiero/web/basic_auth.rb +27 -0
  81. data/lib/sentiero/web/basic_auth_check.rb +41 -0
  82. data/lib/sentiero/web/body_reader.rb +44 -0
  83. data/lib/sentiero/web/csv_writer.rb +45 -0
  84. data/lib/sentiero/web/dashboard_app.rb +236 -0
  85. data/lib/sentiero/web/errors_app.rb +97 -0
  86. data/lib/sentiero/web/escaping.rb +37 -0
  87. data/lib/sentiero/web/events_app.rb +196 -0
  88. data/lib/sentiero/web/formatting.rb +43 -0
  89. data/lib/sentiero/web/ingest_app.rb +92 -0
  90. data/lib/sentiero/web/manifest.rb +43 -0
  91. data/lib/sentiero/web/monitoring_app.rb +316 -0
  92. data/lib/sentiero/web/script_tag.rb +57 -0
  93. data/lib/sentiero/web/shareable_replay.rb +88 -0
  94. data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
  95. data/lib/sentiero/web/templates/_brand.html.erb +18 -0
  96. data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
  97. data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
  98. data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
  99. data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
  100. data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
  101. data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
  102. data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
  103. data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
  104. data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
  105. data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
  106. data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
  107. data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
  108. data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
  109. data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
  110. data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
  111. data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
  112. data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
  113. data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
  114. data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
  115. data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
  116. data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
  117. data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
  118. data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
  119. data/lib/sentiero/web/templates/event_show.html.erb +52 -0
  120. data/lib/sentiero/web/templates/events_index.html.erb +177 -0
  121. data/lib/sentiero/web/templates/export_index.html.erb +69 -0
  122. data/lib/sentiero/web/templates/forms.html.erb +105 -0
  123. data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
  124. data/lib/sentiero/web/templates/import.html.erb +39 -0
  125. data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
  126. data/lib/sentiero/web/templates/segments.html.erb +114 -0
  127. data/lib/sentiero/web/templates/session_show.html.erb +195 -0
  128. data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
  129. data/lib/sentiero/web/track_app.rb +57 -0
  130. data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
  131. data/lib/sentiero/web/views/analyzer_view.rb +27 -0
  132. data/lib/sentiero/web/views/base_view.rb +76 -0
  133. data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
  134. data/lib/sentiero/web/views/conversions_view.rb +41 -0
  135. data/lib/sentiero/web/views/engagement_view.rb +67 -0
  136. data/lib/sentiero/web/views/errors_index_view.rb +37 -0
  137. data/lib/sentiero/web/views/event_show_view.rb +20 -0
  138. data/lib/sentiero/web/views/events_index_view.rb +56 -0
  139. data/lib/sentiero/web/views/export_view.rb +23 -0
  140. data/lib/sentiero/web/views/forms_view.rb +28 -0
  141. data/lib/sentiero/web/views/frustration_view.rb +15 -0
  142. data/lib/sentiero/web/views/funnel_view.rb +36 -0
  143. data/lib/sentiero/web/views/heatmap_view.rb +34 -0
  144. data/lib/sentiero/web/views/import_view.rb +13 -0
  145. data/lib/sentiero/web/views/page_report_view.rb +43 -0
  146. data/lib/sentiero/web/views/problem_show_view.rb +46 -0
  147. data/lib/sentiero/web/views/scroll_view.rb +23 -0
  148. data/lib/sentiero/web/views/segments_view.rb +28 -0
  149. data/lib/sentiero/web/views/session_show_view.rb +105 -0
  150. data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
  151. data/lib/sentiero/web/views/vitals_view.rb +45 -0
  152. data/lib/sentiero/web/views.rb +24 -0
  153. data/lib/sentiero/window_ref.rb +6 -0
  154. data/lib/sentiero.rb +69 -0
  155. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ VERSION = "1.0.0.alpha1"
5
+ 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
+ }