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,149 @@
|
|
|
1
|
+
<h1 class="page-title mb-1">Errors</h1>
|
|
2
|
+
<%= view.render_partial("_tabs.html.erb", tabs: [
|
|
3
|
+
{label: "Server exceptions", href: "#{view.base_path}/issues", active: view.source == "server"},
|
|
4
|
+
{label: "Client JS errors", href: "#{view.base_path}/issues?source=client", active: view.source == "client"}
|
|
5
|
+
]) %>
|
|
6
|
+
|
|
7
|
+
<% if view.source == "client" -%>
|
|
8
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "entries") %>
|
|
9
|
+
|
|
10
|
+
<%= view.render_partial("_errors_client_filter.html.erb", search: view.search, sort_by: view.sort_by, per_page: view.per_page,
|
|
11
|
+
since_param: view.since_param, until_param: view.until_param) %>
|
|
12
|
+
|
|
13
|
+
<% if view.groups.empty? -%>
|
|
14
|
+
<div class="card"><div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
15
|
+
<% if !view.search.empty? -%>
|
|
16
|
+
No results match your filters.
|
|
17
|
+
<a href="<%= view.h(view.base_path) %>/issues?source=client" class="text-blue-600 hover:text-blue-800">Clear filters</a>
|
|
18
|
+
<% else -%>
|
|
19
|
+
No client JS errors captured yet.
|
|
20
|
+
<%= view.render_partial("_sibling_tab_hint.html.erb",
|
|
21
|
+
sibling: view.sibling, noun: "server exception", href: "#{view.base_path}/issues") %>
|
|
22
|
+
<% end -%>
|
|
23
|
+
</div></div>
|
|
24
|
+
<% else -%>
|
|
25
|
+
<div class="table-wrap">
|
|
26
|
+
<table class="data-table">
|
|
27
|
+
<thead><tr><th>Error</th><th class="w-24">Browser</th><th class="w-20">Count</th><th class="w-32">Last seen</th></tr></thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
<% view.groups.each do |group| -%>
|
|
30
|
+
<tr>
|
|
31
|
+
<td>
|
|
32
|
+
<a href="<%= view.h(view.base_path) %>/issues/client/<%= view.h(group[:id]) %>" class="block font-medium text-blue-600 hover:text-blue-800 break-words"><%= view.h(group[:message]) %></a>
|
|
33
|
+
<span class="block text-[11px] text-gray-400 mt-0.5">
|
|
34
|
+
<% if group[:source] -%>
|
|
35
|
+
<span class="font-mono"><%= view.h(group[:source]) %><%= group[:line] ? ":#{group[:line]}" : "" %></span>
|
|
36
|
+
<% else -%>Unknown source<% end -%>
|
|
37
|
+
· <%= group[:count] %> occurrence<%= group[:count] == 1 ? "" : "s" %>
|
|
38
|
+
</span>
|
|
39
|
+
</td>
|
|
40
|
+
<td class="text-xs text-gray-600">
|
|
41
|
+
<% browsers = group[:browsers] || {} -%>
|
|
42
|
+
<% dominant = browsers.max_by { |_browser, count| count } -%>
|
|
43
|
+
<% if dominant -%>
|
|
44
|
+
<span data-dominant-browser="<%= view.h(dominant.first) %>"><%= view.h(dominant.first) %></span>
|
|
45
|
+
<% if browsers.size > 1 -%>
|
|
46
|
+
<span class="text-gray-400" data-browser-others="<%= browsers.size - 1 %>">+<%= browsers.size - 1 %></span>
|
|
47
|
+
<% end -%>
|
|
48
|
+
<% else -%>
|
|
49
|
+
<span class="text-gray-400">—</span>
|
|
50
|
+
<% end -%>
|
|
51
|
+
</td>
|
|
52
|
+
<td><span class="badge badge-danger tabular-nums"><%= group[:count] %>×</span></td>
|
|
53
|
+
<td class="text-gray-400 text-[10px] tabular-nums">
|
|
54
|
+
<%= group[:last_seen_at] ? Time.at(group[:last_seen_at].to_f / 1000).utc.strftime("%b %d, %H:%M") : "—" %>
|
|
55
|
+
</td>
|
|
56
|
+
</tr>
|
|
57
|
+
<% end -%>
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<%= view.render_partial("_pagination.html.erb",
|
|
63
|
+
base_href: "#{view.base_path}/issues", page: view.page, has_next: view.has_next,
|
|
64
|
+
params: {"source" => "client", "search" => view.search, "sort_by" => view.sort_by,
|
|
65
|
+
"since" => view.since_param, "until" => view.until_param, "per_page" => view.per_page.to_s}) %>
|
|
66
|
+
<% end -%>
|
|
67
|
+
<% else -%>
|
|
68
|
+
<%= view.render_partial("_errors_server_filter.html.erb", search: view.search, status: view.status, sort_by: view.sort_by, per_page: view.per_page,
|
|
69
|
+
since_param: view.since_param, until_param: view.until_param) %>
|
|
70
|
+
|
|
71
|
+
<% if view.problems.empty? -%>
|
|
72
|
+
<div class="card"><div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
73
|
+
<% if !view.search.empty? || !view.status.empty? || !view.since_param.empty? || !view.until_param.empty? -%>
|
|
74
|
+
No results match your filters.
|
|
75
|
+
<a href="<%= view.h(view.base_path) %>/issues" class="text-blue-600 hover:text-blue-800">Clear filters</a>
|
|
76
|
+
<% else -%>
|
|
77
|
+
No problems recorded yet.
|
|
78
|
+
<%= view.render_partial("_sibling_tab_hint.html.erb",
|
|
79
|
+
sibling: view.sibling, noun: "client JS error", href: "#{view.base_path}/issues?source=client") %>
|
|
80
|
+
<% end -%>
|
|
81
|
+
</div></div>
|
|
82
|
+
<% else -%>
|
|
83
|
+
<div class="table-wrap">
|
|
84
|
+
<table class="data-table">
|
|
85
|
+
<thead>
|
|
86
|
+
<tr>
|
|
87
|
+
<th>Title</th>
|
|
88
|
+
<th>Project</th>
|
|
89
|
+
<th>Count</th>
|
|
90
|
+
<th>Status</th>
|
|
91
|
+
<th>First Seen</th>
|
|
92
|
+
<th>Last Seen</th>
|
|
93
|
+
<th></th>
|
|
94
|
+
</tr>
|
|
95
|
+
</thead>
|
|
96
|
+
<tbody>
|
|
97
|
+
<% view.problems.each do |p| -%>
|
|
98
|
+
<tr>
|
|
99
|
+
<td>
|
|
100
|
+
<a href="<%= view.h(view.base_path) %>/issues/<%= view.h(p[:id]) %>" class="font-medium text-blue-600 hover:text-blue-800">
|
|
101
|
+
<span class="text-gray-500 text-xs mr-1"><%= view.h(p[:exception_class]) %></span>
|
|
102
|
+
<%= view.h(p[:message].to_s[0, 120]) %>
|
|
103
|
+
</a>
|
|
104
|
+
<% if view.new_since && p[:first_seen] && p[:first_seen] >= view.new_since -%>
|
|
105
|
+
<span class="badge badge-info">new</span>
|
|
106
|
+
<% end -%>
|
|
107
|
+
</td>
|
|
108
|
+
<td class="text-gray-500 text-xs"><%= view.h(p[:project].to_s) %></td>
|
|
109
|
+
<td>
|
|
110
|
+
<span class="badge badge-neutral tabular-nums"><%= p[:count] %></span>
|
|
111
|
+
</td>
|
|
112
|
+
<td>
|
|
113
|
+
<% case p[:status]
|
|
114
|
+
when "resolved" -%>
|
|
115
|
+
<span class="badge badge-success">resolved</span>
|
|
116
|
+
<% when "ignored" -%>
|
|
117
|
+
<span class="badge badge-neutral">ignored</span>
|
|
118
|
+
<% else -%>
|
|
119
|
+
<span class="badge badge-danger">open</span>
|
|
120
|
+
<% end -%>
|
|
121
|
+
</td>
|
|
122
|
+
<td class="text-gray-400 text-[10px]"><%= p[:first_seen] ? Time.at(p[:first_seen]).utc.strftime("%b %d, %H:%M") : "N/A" %></td>
|
|
123
|
+
<td class="text-gray-400 text-[10px]"><%= p[:last_seen] ? Time.at(p[:last_seen]).utc.strftime("%b %d, %H:%M") : "N/A" %></td>
|
|
124
|
+
<td class="text-right">
|
|
125
|
+
<form method="post" action="<%= view.h(view.base_path) %>/issues/<%= view.h(p[:id]) %>/status" class="inline-flex gap-1">
|
|
126
|
+
<input type="hidden" name="csrf_token" value="<%= view.h(view.csrf_token) %>">
|
|
127
|
+
<% unless p[:status] == "resolved" -%>
|
|
128
|
+
<button type="submit" name="status" value="resolved" class="btn btn-sm btn-primary">Resolve</button>
|
|
129
|
+
<% end -%>
|
|
130
|
+
<% unless p[:status] == "ignored" -%>
|
|
131
|
+
<button type="submit" name="status" value="ignored" class="btn btn-sm btn-secondary">Ignore</button>
|
|
132
|
+
<% end -%>
|
|
133
|
+
<% unless p[:status] == "open" -%>
|
|
134
|
+
<button type="submit" name="status" value="open" class="btn btn-sm btn-secondary">Reopen</button>
|
|
135
|
+
<% end -%>
|
|
136
|
+
</form>
|
|
137
|
+
</td>
|
|
138
|
+
</tr>
|
|
139
|
+
<% end -%>
|
|
140
|
+
</tbody>
|
|
141
|
+
</table>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<%= view.render_partial("_pagination.html.erb",
|
|
145
|
+
base_href: "#{view.base_path}/issues", page: view.page, has_next: view.has_next,
|
|
146
|
+
params: {"search" => view.search, "status" => view.status, "sort_by" => view.sort_by,
|
|
147
|
+
"since" => view.since_param, "until" => view.until_param, "per_page" => view.per_page.to_s}) %>
|
|
148
|
+
<% end -%>
|
|
149
|
+
<% end -%>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<div class="card mb-3">
|
|
2
|
+
<div class="flex items-center gap-2 px-4 py-2.5">
|
|
3
|
+
<a href="<%= view.h(view.base_path) %>/custom-events" class="btn btn-sm btn-secondary shrink-0">← Back</a>
|
|
4
|
+
<div class="w-px h-4 bg-gray-200 shrink-0"></div>
|
|
5
|
+
<span class="badge badge-neutral shrink-0"><%= view.h(view.event["name"].to_s) %></span>
|
|
6
|
+
<% case view.event["level"]
|
|
7
|
+
when "error" -%>
|
|
8
|
+
<span class="badge badge-danger shrink-0"><%= view.h(view.event["level"].to_s) %></span>
|
|
9
|
+
<% when "warn" -%>
|
|
10
|
+
<span class="badge badge-warning shrink-0"><%= view.h(view.event["level"].to_s) %></span>
|
|
11
|
+
<% when "info" -%>
|
|
12
|
+
<span class="badge badge-info shrink-0"><%= view.h(view.event["level"].to_s) %></span>
|
|
13
|
+
<% else -%>
|
|
14
|
+
<span class="badge badge-neutral shrink-0"><%= view.h(view.event["level"].to_s) %></span>
|
|
15
|
+
<% end -%>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="card mb-3">
|
|
20
|
+
<div class="card-body">
|
|
21
|
+
<h5 class="text-sm font-semibold text-gray-800 mb-2"><%= view.h(view.event["name"].to_s) %></h5>
|
|
22
|
+
<div class="grid grid-cols-[100px_1fr] gap-x-4 gap-y-2 mt-3">
|
|
23
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Project</div>
|
|
24
|
+
<div class="text-xs text-gray-600"><%= view.h(view.event["project"].to_s) %></div>
|
|
25
|
+
|
|
26
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Level</div>
|
|
27
|
+
<div class="text-xs text-gray-600"><%= view.h(view.event["level"].to_s) %></div>
|
|
28
|
+
|
|
29
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Time</div>
|
|
30
|
+
<div class="text-xs text-gray-600"><%= view.event["timestamp"] ? Time.at(view.event["timestamp"].to_f).utc.strftime("%b %d, %Y %H:%M:%S UTC") : "N/A" %></div>
|
|
31
|
+
|
|
32
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Event ID</div>
|
|
33
|
+
<div class="text-xs text-gray-600 font-mono"><%= view.h(view.event["id"].to_s) %></div>
|
|
34
|
+
|
|
35
|
+
<% if view.event["session_id"] && !view.event["session_id"].to_s.empty? -%>
|
|
36
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Session</div>
|
|
37
|
+
<div class="text-xs text-gray-600">
|
|
38
|
+
<a href="<%= view.h(view.base_path) %>/sessions/<%= view.h(view.event["session_id"]) %>" class="text-blue-600 hover:text-blue-800 font-mono"><%= view.h(view.event["session_id"].to_s) %></a>
|
|
39
|
+
</div>
|
|
40
|
+
<% end -%>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<% if view.event["payload"] -%>
|
|
46
|
+
<div class="card">
|
|
47
|
+
<div class="card-body">
|
|
48
|
+
<h6 class="text-xs font-semibold text-gray-800 mb-3 uppercase tracking-wider">Payload</h6>
|
|
49
|
+
<pre class="text-[11px] text-gray-600 bg-gray-50 rounded p-3 overflow-x-auto whitespace-pre-wrap font-mono"><%= view.h(JSON.pretty_generate(view.event["payload"])) %></pre>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<% end -%>
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<h1 class="page-title mb-1">Events</h1>
|
|
2
|
+
<%= view.render_partial("_tabs.html.erb", tabs: [
|
|
3
|
+
{label: "Server events", href: "#{view.base_path}/custom-events", active: view.source != "browser"},
|
|
4
|
+
{label: "Browser events", href: "#{view.base_path}/custom-events?source=browser", active: view.source == "browser"}
|
|
5
|
+
]) %>
|
|
6
|
+
|
|
7
|
+
<% if view.source == "browser" -%>
|
|
8
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "entries") %>
|
|
9
|
+
|
|
10
|
+
<%= view.render_partial("_events_browser_filter.html.erb", search: view.search, per_page: view.per_page,
|
|
11
|
+
since_param: view.since_param, until_param: view.until_param) %>
|
|
12
|
+
|
|
13
|
+
<%# C1: same payload-metrics card as the server tab, fed by the browser
|
|
14
|
+
rows (computed over the full filtered list, not just this page). -%>
|
|
15
|
+
<%= view.render_partial("_payload_metrics.html.erb",
|
|
16
|
+
single_name: view.single_name, metric_keys: view.metric_keys, metric_key: view.metric_key,
|
|
17
|
+
metric_days: view.metric_days,
|
|
18
|
+
hidden_params: {"source" => "browser", "search" => view.search,
|
|
19
|
+
"since" => view.since_param, "until" => view.until_param,
|
|
20
|
+
"per_page" => view.per_page.to_s}) %>
|
|
21
|
+
|
|
22
|
+
<% if view.browser_rows.empty? -%>
|
|
23
|
+
<div class="card"><div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
24
|
+
<% if !view.search.empty? -%>
|
|
25
|
+
No results match your filters.
|
|
26
|
+
<a href="<%= view.h(view.base_path) %>/custom-events?source=browser" class="text-blue-600 hover:text-blue-800">Clear filters</a>
|
|
27
|
+
<% else -%>
|
|
28
|
+
No browser events captured yet.
|
|
29
|
+
<%= view.render_partial("_sibling_tab_hint.html.erb",
|
|
30
|
+
sibling: view.sibling, noun: "server event", href: "#{view.base_path}/custom-events") %>
|
|
31
|
+
<% end -%>
|
|
32
|
+
</div></div>
|
|
33
|
+
<% else -%>
|
|
34
|
+
<div class="table-wrap">
|
|
35
|
+
<table class="data-table">
|
|
36
|
+
<thead><tr><th>Name</th><th>Session</th><th>Window</th><th class="w-24">Time</th><th>Replay</th></tr></thead>
|
|
37
|
+
<tbody>
|
|
38
|
+
<% view.browser_rows.each do |r| -%>
|
|
39
|
+
<tr>
|
|
40
|
+
<td class="font-medium"><%= view.h(r[:name]) %>
|
|
41
|
+
<% if r[:payload] && !r[:payload].empty? -%>
|
|
42
|
+
<details class="inline-block align-middle ml-1"><summary class="text-[10px] text-gray-400 cursor-pointer select-none">payload</summary><div class="text-[10px] text-gray-600 bg-gray-50 rounded p-1 mt-0.5 font-mono break-all"><%= view.h(r[:payload].to_json) %></div></details>
|
|
43
|
+
<% end -%>
|
|
44
|
+
</td>
|
|
45
|
+
<td class="font-mono text-xs"><%= view.h(r[:session_id].to_s[0, 12]) %></td>
|
|
46
|
+
<td class="font-mono text-xs"><%= view.h(r[:window_id].to_s[0, 8]) %></td>
|
|
47
|
+
<td class="text-gray-400 text-[10px] tabular-nums"><%= r[:offset_ms] ? "+#{(r[:offset_ms].to_f / 1000).round}s" : "—" %></td>
|
|
48
|
+
<td><a class="text-blue-600 hover:text-blue-800" href="<%= view.h(view.base_path) %>/sessions/<%= view.h(r[:session_id]) %>/windows/<%= view.h(r[:window_id]) %>?t=<%= r[:offset_ms] %>">Open at event →</a></td>
|
|
49
|
+
</tr>
|
|
50
|
+
<% end -%>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<%= view.render_partial("_pagination.html.erb",
|
|
56
|
+
base_href: "#{view.base_path}/custom-events", page: view.page, has_next: view.has_next,
|
|
57
|
+
params: {"source" => "browser", "search" => view.search,
|
|
58
|
+
"since" => view.since_param, "until" => view.until_param, "per_page" => view.per_page.to_s}) %>
|
|
59
|
+
<% end -%>
|
|
60
|
+
<% else -%>
|
|
61
|
+
<%= view.render_partial("_events_server_filter.html.erb",
|
|
62
|
+
search: view.search, level: view.level, project: view.project, projects: view.projects, per_page: view.per_page,
|
|
63
|
+
since_param: view.since_param, until_param: view.until_param) %>
|
|
64
|
+
|
|
65
|
+
<% unless view.level_mix.empty? -%>
|
|
66
|
+
<div class="card mb-4">
|
|
67
|
+
<div class="card-body">
|
|
68
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">
|
|
69
|
+
<%= view.volume_scaled? ? "Volume per Day" : "Level Mix per Day" %>
|
|
70
|
+
<span class="font-normal normal-case tracking-normal text-gray-400">— filtered events, by UTC day<%= view.volume_scaled? ? "; bar length is the day's share of the busiest day" : "" %></span>
|
|
71
|
+
</h6>
|
|
72
|
+
<% level_colors = {"debug" => "#9ca3af", "info" => "#3b82f6", "warn" => "#f59e0b"} -%>
|
|
73
|
+
<% view.level_mix.each do |date, counts| -%>
|
|
74
|
+
<% total = counts.values.sum -%>
|
|
75
|
+
<% volume_pct = (total.to_f / view.mix_max * 100).round(1) -%>
|
|
76
|
+
<div class="flex items-center gap-2 mb-1.5" data-level-day="<%= view.h(date) %>">
|
|
77
|
+
<div class="text-[10px] text-gray-500 w-16 shrink-0 tabular-nums"><%= view.h(date) %></div>
|
|
78
|
+
<div class="flex-1">
|
|
79
|
+
<% if view.volume_scaled? -%>
|
|
80
|
+
<div class="flex h-3 rounded overflow-hidden bg-gray-100" style="width:<%= volume_pct %>%" data-volume-pct="<%= volume_pct %>">
|
|
81
|
+
<% else -%>
|
|
82
|
+
<div class="flex h-3 rounded overflow-hidden bg-gray-100">
|
|
83
|
+
<% end -%>
|
|
84
|
+
<% level_colors.each do |lvl, color| -%>
|
|
85
|
+
<% count = counts[lvl] -%>
|
|
86
|
+
<% next if count == 0 -%>
|
|
87
|
+
<div style="width:<%= (count.to_f / total * 100).round(1) %>%;background:<%= color %>" title="<%= count %> <%= lvl %> on <%= view.h(date) %>" data-level-seg="<%= lvl %>"></div>
|
|
88
|
+
<% end -%>
|
|
89
|
+
<% if counts["error"] > 0 -%>
|
|
90
|
+
<a href="<%= view.h(view.base_path) %>/custom-events?<%= view.h(view.error_query(date)) %>" style="width:<%= (counts["error"].to_f / total * 100).round(1) %>%;background:#dc2626" title="<%= counts["error"] %> error<%= counts["error"] == 1 ? "" : "s" %> on <%= view.h(date) %> (click to filter)" data-level-seg="error"></a>
|
|
91
|
+
<% end -%>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="text-[10px] text-gray-400 w-8 text-right tabular-nums"><%= total %></div>
|
|
95
|
+
</div>
|
|
96
|
+
<% end -%>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<% end -%>
|
|
100
|
+
|
|
101
|
+
<%# C5(c): numeric payload metrics, offered when the filters narrowed the
|
|
102
|
+
fetched rows to one event name; computed over those same rows. -%>
|
|
103
|
+
<%= view.render_partial("_payload_metrics.html.erb",
|
|
104
|
+
single_name: view.single_name, metric_keys: view.metric_keys, metric_key: view.metric_key,
|
|
105
|
+
metric_days: view.metric_days,
|
|
106
|
+
hidden_params: {"search" => view.search, "level" => view.level, "project" => view.project,
|
|
107
|
+
"since" => view.since_param, "until" => view.until_param,
|
|
108
|
+
"per_page" => view.per_page.to_s}) %>
|
|
109
|
+
|
|
110
|
+
<% if view.events.empty? -%>
|
|
111
|
+
<div class="card"><div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
112
|
+
<% if !view.search.empty? || !view.level.empty? || !view.project.empty? || !view.since_param.empty? || !view.until_param.empty? -%>
|
|
113
|
+
No results match your filters.
|
|
114
|
+
<a href="<%= view.h(view.base_path) %>/custom-events" class="text-blue-600 hover:text-blue-800">Clear filters</a>
|
|
115
|
+
<% else -%>
|
|
116
|
+
No events recorded yet.
|
|
117
|
+
<%= view.render_partial("_sibling_tab_hint.html.erb",
|
|
118
|
+
sibling: view.sibling, noun: "browser event", href: "#{view.base_path}/custom-events?source=browser") %>
|
|
119
|
+
<% end -%>
|
|
120
|
+
</div></div>
|
|
121
|
+
<% else -%>
|
|
122
|
+
<div class="table-wrap">
|
|
123
|
+
<table class="data-table">
|
|
124
|
+
<thead>
|
|
125
|
+
<tr>
|
|
126
|
+
<th>Name</th>
|
|
127
|
+
<th>Project</th>
|
|
128
|
+
<th>Level</th>
|
|
129
|
+
<th>Session</th>
|
|
130
|
+
<th>Time</th>
|
|
131
|
+
</tr>
|
|
132
|
+
</thead>
|
|
133
|
+
<tbody>
|
|
134
|
+
<% view.events.each do |e| -%>
|
|
135
|
+
<tr>
|
|
136
|
+
<td class="font-medium">
|
|
137
|
+
<a href="<%= view.h(view.base_path) %>/custom-events/<%= view.h(e["id"].to_s) %>" class="text-blue-600 hover:text-blue-800"><%= view.h(e["name"].to_s) %></a>
|
|
138
|
+
<% if e["payload"].is_a?(Hash) && !e["payload"].empty? -%>
|
|
139
|
+
<details class="inline-block align-middle ml-1">
|
|
140
|
+
<summary class="text-[10px] text-gray-400 cursor-pointer select-none">payload</summary>
|
|
141
|
+
<div class="text-[10px] text-gray-600 bg-gray-50 rounded p-1 mt-0.5 font-mono break-all"><%= view.h(e["payload"].to_json) %></div>
|
|
142
|
+
</details>
|
|
143
|
+
<% end -%>
|
|
144
|
+
</td>
|
|
145
|
+
<td class="text-xs text-gray-600"><%= view.h(e["project"].to_s) %></td>
|
|
146
|
+
<td>
|
|
147
|
+
<% case e["level"]
|
|
148
|
+
when "error" -%>
|
|
149
|
+
<span class="badge badge-danger"><%= view.h(e["level"].to_s) %></span>
|
|
150
|
+
<% when "warn" -%>
|
|
151
|
+
<span class="badge badge-warning"><%= view.h(e["level"].to_s) %></span>
|
|
152
|
+
<% when "info" -%>
|
|
153
|
+
<span class="badge badge-info"><%= view.h(e["level"].to_s) %></span>
|
|
154
|
+
<% else -%>
|
|
155
|
+
<span class="badge badge-neutral"><%= view.h(e["level"].to_s) %></span>
|
|
156
|
+
<% end -%>
|
|
157
|
+
</td>
|
|
158
|
+
<td>
|
|
159
|
+
<% if e["session_id"] && !e["session_id"].empty? -%>
|
|
160
|
+
<a href="<%= view.h(view.base_path) %>/sessions/<%= view.h(e["session_id"]) %>" class="text-blue-600 hover:text-blue-800 text-xs font-mono"><%= view.h(e["session_id"].to_s[0, 16]) %>…</a>
|
|
161
|
+
<% else -%>
|
|
162
|
+
<span class="text-gray-400">—</span>
|
|
163
|
+
<% end -%>
|
|
164
|
+
</td>
|
|
165
|
+
<td class="text-gray-400 text-[10px] tabular-nums"><%= e["timestamp"] ? Time.at(e["timestamp"].to_f).utc.strftime("%b %d, %H:%M:%S") : "N/A" %></td>
|
|
166
|
+
</tr>
|
|
167
|
+
<% end -%>
|
|
168
|
+
</tbody>
|
|
169
|
+
</table>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<%= view.render_partial("_pagination.html.erb",
|
|
173
|
+
base_href: "#{view.base_path}/custom-events", page: view.page, has_next: view.has_next,
|
|
174
|
+
params: {"search" => view.search, "level" => view.level, "project" => view.project,
|
|
175
|
+
"since" => view.since_param, "until" => view.until_param, "per_page" => view.per_page.to_s}) %>
|
|
176
|
+
<% end -%>
|
|
177
|
+
<% end -%>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<%= view.render_partial("_analytics_nav.html.erb", active: :export, since: view.since, until_str: view.until_str) %>
|
|
2
|
+
<h1 class="page-title">Export</h1>
|
|
3
|
+
|
|
4
|
+
<p class="text-xs text-gray-500 mb-4">
|
|
5
|
+
Download analytics datasets as CSV or JSON. Exports are bounded by the
|
|
6
|
+
<code>analytics_max_scan_sessions</code> cap, so very large stores may be
|
|
7
|
+
truncated.
|
|
8
|
+
<%# C2 (P2.2): attribution semantics, so exported per-URL numbers can't be
|
|
9
|
+
misread as something more exact than they are. -%>
|
|
10
|
+
Per-URL rows attribute each event to the page on screen when the event
|
|
11
|
+
happened (derived from the recording’s page-load markers); entry pages
|
|
12
|
+
are the first page seen in the recording.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<%# The range is set via this GET form (no-JS friendly) and carried into
|
|
16
|
+
every dataset's POST form below as hidden fields. Empty = all data. -%>
|
|
17
|
+
<form method="get" action="<%= view.h(view.base_path) %>/analytics/export">
|
|
18
|
+
<div class="card mb-4">
|
|
19
|
+
<div class="flex items-end gap-3 px-4 py-3">
|
|
20
|
+
<%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
|
|
21
|
+
<div class="shrink-0 flex items-center gap-1.5">
|
|
22
|
+
<button type="submit" class="btn btn-primary">Set range</button>
|
|
23
|
+
<a href="<%= view.h(view.base_path) %>/analytics/export" class="btn btn-secondary">Clear</a>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="shrink-0 pb-1.5 text-[10px] text-gray-400">
|
|
26
|
+
Empty = all data. The range is stamped into the download filename.
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</form>
|
|
31
|
+
|
|
32
|
+
<div class="card">
|
|
33
|
+
<div class="table-wrap">
|
|
34
|
+
<table class="data-table">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr>
|
|
37
|
+
<th>Dataset</th>
|
|
38
|
+
<th>Download</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody>
|
|
42
|
+
<% view.datasets.each do |key, label| -%>
|
|
43
|
+
<tr>
|
|
44
|
+
<td class="text-sm text-gray-900"><%= view.h(label) %></td>
|
|
45
|
+
<td>
|
|
46
|
+
<div class="flex gap-2">
|
|
47
|
+
<% %w[csv json].each do |format| -%>
|
|
48
|
+
<form method="post" action="<%= view.h(view.base_path) %>/analytics/export/<%= view.h(key) %>.<%= format %>">
|
|
49
|
+
<input type="hidden" name="csrf_token" value="<%= view.h(view.csrf_token) %>">
|
|
50
|
+
<input type="hidden" name="since" value="<%= view.h(view.since) %>">
|
|
51
|
+
<input type="hidden" name="until" value="<%= view.h(view.until_str) %>">
|
|
52
|
+
<button type="submit" class="btn btn-sm btn-secondary">Download <%= format.upcase %></button>
|
|
53
|
+
</form>
|
|
54
|
+
<% end -%>
|
|
55
|
+
</div>
|
|
56
|
+
</td>
|
|
57
|
+
</tr>
|
|
58
|
+
<% end -%>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="mt-4 flex items-center gap-2">
|
|
65
|
+
<% if view.shareable_replays -%>
|
|
66
|
+
<a href="<%= view.h(view.base_path) %>/analytics/import" class="btn btn-sm btn-secondary">Import replay</a>
|
|
67
|
+
<% end -%>
|
|
68
|
+
<a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">← Back to Analytics</a>
|
|
69
|
+
</div>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<%= view.render_partial("_analytics_nav.html.erb", active: :forms, since: view.since, until_str: view.until_str) %>
|
|
2
|
+
<div class="flex items-center justify-between mb-5">
|
|
3
|
+
<h1 class="page-title mb-0">Form Analytics</h1>
|
|
4
|
+
|
|
5
|
+
<form method="get" action="<%= view.h(view.base_path) %>/analytics/forms" class="flex items-end gap-2">
|
|
6
|
+
<%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
|
|
7
|
+
<button type="submit" class="btn btn-sm btn-secondary">Apply</button>
|
|
8
|
+
</form>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "sessions") %>
|
|
12
|
+
|
|
13
|
+
<% if view.sessions_started == 0 -%>
|
|
14
|
+
<div class="card">
|
|
15
|
+
<div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
16
|
+
No form interactions recorded yet.
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<% else -%>
|
|
20
|
+
<p class="text-xs text-gray-500 mb-3">
|
|
21
|
+
Fields are identified by interaction patterns only — no values are ever
|
|
22
|
+
read, so this works with masked inputs. Submits are <em>real</em> submit
|
|
23
|
+
events captured by the recorder (<code>track_forms</code>), attributed to the
|
|
24
|
+
page they happened on: a session counts as submitted only when every page it
|
|
25
|
+
typed on was also submitted there. <strong>Capture note:</strong> sessions
|
|
26
|
+
recorded before submit capture existed (or with <code>track_forms</code>
|
|
27
|
+
off) contain no submit events and show zero submits.
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<div class="grid grid-cols-1 sm:grid-cols-4 gap-3 mb-5">
|
|
31
|
+
<div class="card">
|
|
32
|
+
<div class="px-5 py-4">
|
|
33
|
+
<div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.sessions_started %></div>
|
|
34
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Sessions Interacting</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="card">
|
|
38
|
+
<div class="px-5 py-4">
|
|
39
|
+
<div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.sessions_completed %></div>
|
|
40
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Sessions Submitted</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="card">
|
|
44
|
+
<div class="px-5 py-4">
|
|
45
|
+
<div class="text-2xl font-bold text-gray-900 tracking-tight"><%= (view.completion_rate * 100).round %>%</div>
|
|
46
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Completion Rate</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="card">
|
|
50
|
+
<div class="px-5 py-4">
|
|
51
|
+
<div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.total_submits %></div>
|
|
52
|
+
<div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Form Submits</div>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div class="card mb-4">
|
|
58
|
+
<div class="card-body">
|
|
59
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Per-Field Metrics</h6>
|
|
60
|
+
<table class="w-full text-xs">
|
|
61
|
+
<thead>
|
|
62
|
+
<tr class="text-gray-500 border-b border-gray-200">
|
|
63
|
+
<th class="py-1 text-left font-medium">Field</th>
|
|
64
|
+
<th class="py-1 text-right font-medium">Completion</th>
|
|
65
|
+
<th class="py-1 text-right font-medium">Avg Time to Fill</th>
|
|
66
|
+
<th class="py-1 text-right font-medium">Re-fills</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
<% view.fields.each do |field| -%>
|
|
71
|
+
<tr class="border-b border-gray-100 last:border-0">
|
|
72
|
+
<td class="py-1 text-gray-700"><%= view.h(field[:label] || "Field ##{field[:field_id]}") %><% if field[:url] %> <span class="text-gray-400">on <%= view.h(field[:url]) %></span><% end %></td>
|
|
73
|
+
<td class="py-1 text-right text-gray-500 tabular-nums"><%= (field[:completion_rate] * 100).round %>%</td>
|
|
74
|
+
<td class="py-1 text-right text-gray-500 tabular-nums"><%= (field[:avg_time_to_fill_ms] / 1000.0).round(1) %>s</td>
|
|
75
|
+
<td class="py-1 text-right text-gray-500 tabular-nums"><%= field[:total_refills] %></td>
|
|
76
|
+
</tr>
|
|
77
|
+
<% end -%>
|
|
78
|
+
</tbody>
|
|
79
|
+
</table>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<div class="card mb-4">
|
|
84
|
+
<div class="card-body">
|
|
85
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Top Drop-off Fields</h6>
|
|
86
|
+
<% if view.drop_off_fields.empty? -%>
|
|
87
|
+
<p class="text-gray-400 text-xs py-4 text-center">No abandoned forms — every interacting session submitted.</p>
|
|
88
|
+
<% else -%>
|
|
89
|
+
<p class="text-[10px] text-gray-400 mb-2">The last field touched before the session left without submitting.</p>
|
|
90
|
+
<table class="w-full text-xs">
|
|
91
|
+
<% view.drop_off_fields.each do |field| -%>
|
|
92
|
+
<tr class="border-b border-gray-100 last:border-0">
|
|
93
|
+
<td class="py-1 text-gray-700"><%= view.h(field[:label] || "Field ##{field[:field_id]}") %><% if field[:url] %> <span class="text-gray-400">on <%= view.h(field[:url]) %></span><% end %></td>
|
|
94
|
+
<td class="py-1 text-right text-gray-500 tabular-nums"><%= field[:count] %> drop-off<%= field[:count] == 1 ? "" : "s" %></td>
|
|
95
|
+
</tr>
|
|
96
|
+
<% end -%>
|
|
97
|
+
</table>
|
|
98
|
+
<% end -%>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
<% end -%>
|
|
102
|
+
|
|
103
|
+
<div class="mt-4">
|
|
104
|
+
<a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">← Back to Analytics</a>
|
|
105
|
+
</div>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<%= view.render_partial("_analytics_nav.html.erb", active: :heatmap, since: view.since, until_str: view.until_str) %>
|
|
2
|
+
<div class="flex items-center justify-between mb-5">
|
|
3
|
+
<div class="flex items-center gap-3">
|
|
4
|
+
<h1 class="page-title mb-0">Click Heatmaps</h1>
|
|
5
|
+
<% if view.selected_url && !view.selected_url.to_s.empty? -%>
|
|
6
|
+
<a class="text-blue-600 hover:text-blue-800 text-xs" href="<%= view.h(view.page_report_href) %>">Page report →</a>
|
|
7
|
+
<% end -%>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<form method="get" action="<%= view.h(view.base_path) %>/analytics/heatmap" class="flex items-end gap-2">
|
|
11
|
+
<%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
|
|
12
|
+
<div class="shrink-0">
|
|
13
|
+
<label for="url" class="label">Page</label>
|
|
14
|
+
<select name="url" id="url" class="select" style="width:22rem" data-heatmap-url>
|
|
15
|
+
<% if view.urls.empty? -%>
|
|
16
|
+
<option value="">No recorded pages</option>
|
|
17
|
+
<% end -%>
|
|
18
|
+
<% view.urls.each do |u| -%>
|
|
19
|
+
<option value="<%= view.h(u) %>" <%= u == view.selected_url ? "selected" : "" %>><%= view.h(u) %></option>
|
|
20
|
+
<% end -%>
|
|
21
|
+
</select>
|
|
22
|
+
</div>
|
|
23
|
+
<button type="submit" class="btn btn-sm btn-secondary" data-heatmap-apply>Apply</button>
|
|
24
|
+
</form>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "sessions") %>
|
|
28
|
+
|
|
29
|
+
<% if view.urls.empty? -%>
|
|
30
|
+
<div class="card">
|
|
31
|
+
<div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
32
|
+
No recorded pages with clicks yet.
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<% else -%>
|
|
36
|
+
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
37
|
+
<div class="card lg:col-span-2">
|
|
38
|
+
<div class="card-body">
|
|
39
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Click Density</h6>
|
|
40
|
+
<div id="heatmap-status" class="text-xs text-gray-400 mb-2">Loading…</div>
|
|
41
|
+
<div id="heatmap-canvas-wrapper" class="inline-block max-w-full overflow-auto border border-gray-100 rounded bg-gray-50">
|
|
42
|
+
<canvas id="heatmap-canvas"></canvas>
|
|
43
|
+
</div>
|
|
44
|
+
<p class="text-[10px] text-gray-400 mt-2">
|
|
45
|
+
Clicks are placed at their position on the <em>page</em> (click + scroll offset),
|
|
46
|
+
aggregated across sessions on this page. Vertical position is normalized to an
|
|
47
|
+
estimated page height — the deepest recorded scroll plus the viewport —
|
|
48
|
+
so below-the-fold clicks land where they happened; horizontal position is
|
|
49
|
+
normalized by viewport width.
|
|
50
|
+
<%# C2 (P2.2): estimation caveat extending the A2 caption. -%>
|
|
51
|
+
The height is an estimate: pages that were never scrolled fall back to
|
|
52
|
+
viewport height, and under-scrolled pages can read taller than recorded,
|
|
53
|
+
so vertical positions are approximate.
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="card">
|
|
59
|
+
<div class="card-body">
|
|
60
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Top Clicked Elements</h6>
|
|
61
|
+
<table class="w-full text-xs">
|
|
62
|
+
<tbody id="heatmap-top-elements">
|
|
63
|
+
<tr><td class="py-2 text-gray-400 text-center">Loading…</td></tr>
|
|
64
|
+
</tbody>
|
|
65
|
+
</table>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<% end -%>
|
|
70
|
+
|
|
71
|
+
<div class="mt-4">
|
|
72
|
+
<a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">← Back to Analytics</a>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script type="application/json" id="heatmap-config"><%= view.escape_json(view.config_json) %></script>
|
|
76
|
+
<script src="<%= view.built_asset('heatmap') %>" defer></script>
|