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,287 @@
1
+ <%= view.render_partial("_analytics_nav.html.erb", active: :pages, 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">Page Report</h1>
4
+
5
+ <form method="get" action="<%= view.h(view.base_path) %>/analytics/page" class="flex items-end gap-2">
6
+ <%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
7
+ <div class="shrink-0">
8
+ <label for="url" class="label">Page</label>
9
+ <select name="url" id="url" class="select" style="width:22rem">
10
+ <option value="">Select a page&hellip;</option>
11
+ <% view.urls.each do |u| -%>
12
+ <option value="<%= view.h(u) %>" <%= u == view.selected_url ? "selected" : "" %>><%= view.h(u) %></option>
13
+ <% end -%>
14
+ </select>
15
+ </div>
16
+ <button type="submit" class="btn btn-sm btn-secondary">Apply</button>
17
+ </form>
18
+ </div>
19
+
20
+ <%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated) %>
21
+
22
+ <% if view.report.nil? -%>
23
+ <div class="card">
24
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
25
+ <% if view.urls.empty? -%>
26
+ No recorded pages yet.
27
+ <% else -%>
28
+ Pick a page above to see its per-URL report &mdash; time on page,
29
+ entries/exits, clicks, scroll depth, form completion, Web Vitals,
30
+ errors, frustration signals and custom events for that single URL.
31
+ <% end -%>
32
+ </div>
33
+ </div>
34
+ <% elsif view.report[:page_views].zero? -%>
35
+ <div class="card">
36
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
37
+ No data recorded for <code class="font-mono"><%= view.h(view.report[:url]) %></code> in this range.
38
+ </div>
39
+ </div>
40
+ <% else -%>
41
+ <div class="flex items-center justify-between mb-3">
42
+ <h2 class="text-sm font-medium text-gray-900 break-words"><%= view.h(view.report[:url]) %></h2>
43
+ <a class="text-blue-600 hover:text-blue-800 text-xs" href="<%= view.h(view.heatmap_href) %>">Open full heatmap &rarr;</a>
44
+ </div>
45
+
46
+ <%# ── summary cards ── -%>
47
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-5">
48
+ <div class="card"><div class="card-body">
49
+ <div class="text-[10px] uppercase tracking-wider text-gray-400">Sessions</div>
50
+ <div class="text-xl font-semibold tabular-nums"><%= view.report[:sessions] %></div>
51
+ </div></div>
52
+ <div class="card"><div class="card-body">
53
+ <div class="text-[10px] uppercase tracking-wider text-gray-400">Page views</div>
54
+ <div class="text-xl font-semibold tabular-nums"><%= view.report[:page_views] %></div>
55
+ </div></div>
56
+ <div class="card"><div class="card-body">
57
+ <div class="text-[10px] uppercase tracking-wider text-gray-400">Time on page (mean)</div>
58
+ <div class="text-xl font-semibold tabular-nums"><%= view.secs(view.time_on_page[:mean_ms]) %></div>
59
+ <div class="text-[10px] text-gray-400">median <%= view.secs(view.time_on_page[:median_ms]) %>, n=<%= view.time_on_page[:samples] %></div>
60
+ </div></div>
61
+ <div class="card"><div class="card-body">
62
+ <div class="text-[10px] uppercase tracking-wider text-gray-400">Bounce rate</div>
63
+ <div class="text-xl font-semibold tabular-nums"><%= view.pct(view.entry_exit[:bounce_rate]) %></div>
64
+ </div></div>
65
+ <div class="card"><div class="card-body">
66
+ <div class="text-[10px] uppercase tracking-wider text-gray-400">Entries</div>
67
+ <div class="text-xl font-semibold tabular-nums"><%= view.entry_exit[:entries] %></div>
68
+ </div></div>
69
+ <div class="card"><div class="card-body">
70
+ <div class="text-[10px] uppercase tracking-wider text-gray-400">Exits</div>
71
+ <div class="text-xl font-semibold tabular-nums"><%= view.entry_exit[:exits] %></div>
72
+ </div></div>
73
+ </div>
74
+
75
+ <%# ── heatmap ── -%>
76
+ <div class="card mb-3"><div class="card-body">
77
+ <div class="flex items-center justify-between mb-2">
78
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider">Heatmap</h6>
79
+ <a class="text-blue-600 hover:text-blue-800 text-xs" href="<%= view.h(view.heatmap_href) %>">Open full heatmap &rarr;</a>
80
+ </div>
81
+ <% if view.report[:heatmap][:total_clicks].zero? -%>
82
+ <p class="text-xs text-gray-400">No clicks recorded on this page.</p>
83
+ <% else -%>
84
+ <p class="text-xs text-gray-500 mb-2"><%= view.report[:heatmap][:total_clicks] %> total clicks.</p>
85
+ <table class="w-full text-xs">
86
+ <thead><tr><th class="text-left py-1 text-gray-500">Element</th><th class="text-right py-1 text-gray-500">Clicks</th></tr></thead>
87
+ <tbody>
88
+ <% view.report[:heatmap][:top_elements].each do |el| -%>
89
+ <tr>
90
+ <td class="py-1 font-mono break-all"><%= view.h(el[:selector]) %></td>
91
+ <td class="py-1 text-right tabular-nums text-gray-500"><%= el[:count] %></td>
92
+ </tr>
93
+ <% end -%>
94
+ </tbody>
95
+ </table>
96
+ <% end -%>
97
+ </div></div>
98
+
99
+ <%# ── scroll ── -%>
100
+ <div class="card mb-3"><div class="card-body">
101
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Scroll Depth</h6>
102
+ <% if view.scroll.nil? -%>
103
+ <p class="text-xs text-gray-400">No scroll recorded on this page.</p>
104
+ <% else -%>
105
+ <div class="text-xs text-gray-500 mb-3">
106
+ <%= view.scroll[:session_count] %> session<%= view.scroll[:session_count] == 1 ? "" : "s" %>,
107
+ avg depth <%= view.scroll[:avg_depth_px].round %>px<%
108
+ if view.scroll[:avg_depth_pct] %> (~<%= view.scroll[:avg_depth_pct].round %>% of the page)<% end %><%
109
+ if view.scroll[:page_height_px] %>, est. page height <%= view.scroll[:page_height_px].round %>px<% end %>.
110
+ </div>
111
+ <table class="w-full text-xs">
112
+ <tbody>
113
+ <tr><td class="py-1 text-gray-500">50th percentile</td><td class="py-1 text-right tabular-nums"><%= view.scroll[:fold_lines][:p50] ? "#{view.scroll[:fold_lines][:p50].round}% of page" : "&mdash;" %></td></tr>
114
+ <tr><td class="py-1 text-gray-500">75th percentile</td><td class="py-1 text-right tabular-nums"><%= view.scroll[:fold_lines][:p75] ? "#{view.scroll[:fold_lines][:p75].round}% of page" : "&mdash;" %></td></tr>
115
+ <tr><td class="py-1 text-gray-500">90th percentile</td><td class="py-1 text-right tabular-nums"><%= view.scroll[:fold_lines][:p90] ? "#{view.scroll[:fold_lines][:p90].round}% of page" : "&mdash;" %></td></tr>
116
+ </tbody>
117
+ </table>
118
+ <table class="w-full text-xs mt-2">
119
+ <thead><tr><th class="text-left py-1 text-gray-500">Depth bin</th><th class="text-right py-1 text-gray-500">Sessions</th></tr></thead>
120
+ <tbody>
121
+ <% view.scroll[:distribution].each do |label, count| -%>
122
+ <tr><td class="py-1"><%= view.h(label) %>%</td><td class="py-1 text-right tabular-nums text-gray-500"><%= count %></td></tr>
123
+ <% end -%>
124
+ </tbody>
125
+ </table>
126
+ <% end -%>
127
+ </div></div>
128
+
129
+ <%# ── forms ── -%>
130
+ <div class="card mb-3"><div class="card-body">
131
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Forms</h6>
132
+ <% if view.forms[:started].zero? && view.forms[:total_submits].zero? -%>
133
+ <p class="text-xs text-gray-400">No form activity recorded on this page.</p>
134
+ <% else -%>
135
+ <p class="text-xs text-gray-500 mb-2">
136
+ <%= view.forms[:started] %> session<%= view.forms[:started] == 1 ? "" : "s" %> started,
137
+ <%= view.forms[:completed] %> completed (<%= view.pct(view.forms[:completion_rate]) %>),
138
+ <%= view.forms[:total_submits] %> submit<%= view.forms[:total_submits] == 1 ? "" : "s" %> in total.
139
+ <span class="text-gray-400">Started/completed are per-session.</span>
140
+ </p>
141
+ <% unless view.forms[:fields].empty? -%>
142
+ <table class="w-full text-xs mb-2">
143
+ <thead><tr><th class="text-left py-1 text-gray-500">Field</th><th class="text-right py-1 text-gray-500">Sessions</th><th class="text-right py-1 text-gray-500">Avg fill</th><th class="text-right py-1 text-gray-500">Refills</th></tr></thead>
144
+ <tbody>
145
+ <% view.forms[:fields].each do |field| -%>
146
+ <tr>
147
+ <td class="py-1 font-mono">#<%= field[:field_id] %></td>
148
+ <td class="py-1 text-right tabular-nums text-gray-500"><%= field[:sessions] %></td>
149
+ <td class="py-1 text-right tabular-nums text-gray-500"><%= view.secs(field[:avg_time_to_fill_ms]) %></td>
150
+ <td class="py-1 text-right tabular-nums text-gray-500"><%= field[:total_refills] %></td>
151
+ </tr>
152
+ <% end -%>
153
+ </tbody>
154
+ </table>
155
+ <% end -%>
156
+ <% unless view.forms[:drop_off_fields].empty? -%>
157
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1">Drop-off fields</div>
158
+ <table class="w-full text-xs">
159
+ <tbody>
160
+ <% view.forms[:drop_off_fields].each do |field| -%>
161
+ <tr><td class="py-1 font-mono">#<%= field[:field_id] %></td><td class="py-1 text-right tabular-nums text-gray-500"><%= field[:count] %></td></tr>
162
+ <% end -%>
163
+ </tbody>
164
+ </table>
165
+ <% end -%>
166
+ <% end -%>
167
+ </div></div>
168
+
169
+ <%# ── web vitals ── -%>
170
+ <div class="card mb-3"><div class="card-body">
171
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Web Vitals</h6>
172
+ <% if view.report[:vitals][:sample_count].zero? -%>
173
+ <p class="text-xs text-gray-400">No Web Vitals recorded on this page.</p>
174
+ <% else -%>
175
+ <table class="w-full text-xs">
176
+ <thead><tr><th class="text-left py-1 text-gray-500">Metric</th><th class="text-right py-1 text-gray-500">p50</th><th class="text-right py-1 text-gray-500">p75</th><th class="text-right py-1 text-gray-500">p90</th><th class="text-right py-1 text-gray-500">n</th><th class="text-right py-1 text-gray-500">Slowest</th></tr></thead>
177
+ <tbody>
178
+ <% view.report[:vitals][:metrics].sort.each do |metric, m| -%>
179
+ <tr>
180
+ <td class="py-1 font-mono"><%= view.h(metric) %></td>
181
+ <td class="py-1 text-right tabular-nums"><%= view.h(view.format_vital(metric, m[:p50])) %></td>
182
+ <td class="py-1 text-right tabular-nums"><%= view.h(view.format_vital(metric, m[:p75])) %></td>
183
+ <td class="py-1 text-right tabular-nums"><%= view.h(view.format_vital(metric, m[:p90])) %></td>
184
+ <td class="py-1 text-right tabular-nums text-gray-400"><%= m[:samples] %></td>
185
+ <td class="py-1 text-right">
186
+ <% if m[:worst] -%>
187
+ <a class="text-blue-600 hover:text-blue-800"
188
+ href="<%= view.h(view.base_path) %>/sessions/<%= view.h(m[:worst][:session_id].to_s) %>/windows/<%= view.h(m[:worst][:window_id].to_s) %>?t=<%= m[:worst][:offset_ms].to_i %>">Open in player &rarr;</a>
189
+ <% else -%>
190
+ <span class="text-gray-400">&mdash;</span>
191
+ <% end -%>
192
+ </td>
193
+ </tr>
194
+ <% end -%>
195
+ </tbody>
196
+ </table>
197
+ <% end -%>
198
+ </div></div>
199
+
200
+ <%# ── errors ── -%>
201
+ <div class="card mb-3"><div class="card-body">
202
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Errors</h6>
203
+ <% if view.report[:errors][:total].zero? -%>
204
+ <p class="text-xs text-gray-400">No errors recorded on this page.</p>
205
+ <% else -%>
206
+ <p class="text-xs text-gray-500 mb-2"><%= view.report[:errors][:total] %> total.</p>
207
+ <% view.report[:errors][:groups].each do |group| -%>
208
+ <div class="mb-3">
209
+ <div class="flex items-center gap-2 mb-1">
210
+ <span class="badge badge-danger"><%= group[:count] %></span>
211
+ <span class="text-xs font-mono text-gray-700 break-all"><%= view.h(group[:message]) %></span>
212
+ </div>
213
+ <table class="w-full text-xs">
214
+ <tbody>
215
+ <% group[:occurrences].each do |occ| -%>
216
+ <tr>
217
+ <td class="py-1 text-gray-400 tabular-nums">+<%= (occ[:offset_ms].to_f / 1000).round(1) %>s</td>
218
+ <td class="py-1 text-right">
219
+ <a class="text-blue-600 hover:text-blue-800"
220
+ href="<%= view.h(view.base_path) %>/sessions/<%= view.h(occ[:session_id].to_s) %>/windows/<%= view.h(occ[:window_id].to_s) %>?t=<%= occ[:offset_ms].to_i %>">Open in player &rarr;</a>
221
+ </td>
222
+ </tr>
223
+ <% end -%>
224
+ </tbody>
225
+ </table>
226
+ </div>
227
+ <% end -%>
228
+ <% end -%>
229
+ </div></div>
230
+
231
+ <%# ── frustration ── -%>
232
+ <div class="card mb-3"><div class="card-body">
233
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Frustration Signals</h6>
234
+ <% if view.frustration[:rage_count].zero? && view.frustration[:dead_count].zero? -%>
235
+ <p class="text-xs text-gray-400">No frustration signals recorded on this page.</p>
236
+ <% else -%>
237
+ <p class="text-xs text-gray-500 mb-1">
238
+ <% if view.frustration[:rage_count] > 0 -%>
239
+ <span class="badge badge-danger"><%= view.frustration[:rage_count] %></span> rage clicks,
240
+ <% else -%>
241
+ 0 rage clicks,
242
+ <% end -%>
243
+ <% if view.frustration[:dead_count] > 0 -%>
244
+ <span class="badge badge-warning"><%= view.frustration[:dead_count] %></span>
245
+ <% else -%>
246
+ 0
247
+ <% end -%>
248
+ dead clicks <span class="text-gray-400">(raw, pre-filter)</span>.
249
+ </p>
250
+ <p class="text-[10px] text-gray-400 mb-2">
251
+ The dead-click count here is the raw, pre-filter total and may exceed
252
+ the Frustration page, which applies an additional de-noise pass.
253
+ </p>
254
+ <% unless view.frustration[:top_selectors].empty? -%>
255
+ <table class="w-full text-xs">
256
+ <thead><tr><th class="text-left py-1 text-gray-500">Rage-clicked element</th><th class="text-right py-1 text-gray-500">Count</th></tr></thead>
257
+ <tbody>
258
+ <% view.frustration[:top_selectors].each do |el| -%>
259
+ <tr><td class="py-1 font-mono break-all"><%= view.h(el[:selector]) %></td><td class="py-1 text-right tabular-nums text-gray-500"><%= el[:count] %></td></tr>
260
+ <% end -%>
261
+ </tbody>
262
+ </table>
263
+ <% end -%>
264
+ <% end -%>
265
+ </div></div>
266
+
267
+ <%# ── custom events ── -%>
268
+ <div class="card mb-3"><div class="card-body">
269
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Custom Events</h6>
270
+ <% if view.report[:custom_events].empty? -%>
271
+ <p class="text-xs text-gray-400">No custom events recorded on this page.</p>
272
+ <% else -%>
273
+ <table class="w-full text-xs">
274
+ <thead><tr><th class="text-left py-1 text-gray-500">Tag</th><th class="text-right py-1 text-gray-500">Count</th></tr></thead>
275
+ <tbody>
276
+ <% view.report[:custom_events].each do |ev| -%>
277
+ <tr><td class="py-1 font-mono break-all"><%= view.h(ev[:tag]) %></td><td class="py-1 text-right tabular-nums text-gray-500"><%= ev[:count] %></td></tr>
278
+ <% end -%>
279
+ </tbody>
280
+ </table>
281
+ <% end -%>
282
+ </div></div>
283
+ <% end -%>
284
+
285
+ <div class="mt-4">
286
+ <a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">&larr; Back to Analytics</a>
287
+ </div>
@@ -0,0 +1,94 @@
1
+ <%= view.render_partial("_analytics_nav.html.erb", active: :scroll, 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">Scroll Depth</h1>
4
+
5
+ <form method="get" action="<%= view.h(view.base_path) %>/analytics/scroll" 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.sorted_pages.empty? -%>
14
+ <div class="card">
15
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
16
+ No scroll data recorded yet.
17
+ </div>
18
+ </div>
19
+ <% else -%>
20
+ <p class="text-xs text-gray-500 mb-3">
21
+ Depth is the absolute percentage of the page each session reached. The page
22
+ height is <em>estimated</em> per page as the deepest recorded scroll plus the
23
+ viewport &mdash; exact whenever a session read to the end, a lower bound
24
+ otherwise. The histogram bins sessions by that page percentage.
25
+ </p>
26
+
27
+ <% view.sorted_pages.each do |url, page| -%>
28
+ <%
29
+ folds = page[:fold_lines]
30
+ dist = page[:distribution]
31
+ -%>
32
+ <div class="card mb-3">
33
+ <div class="card-body">
34
+ <div class="flex items-center justify-between mb-1">
35
+ <div class="text-sm font-medium text-gray-900 break-words"><%= view.h(url) %></div>
36
+ <a class="text-blue-600 hover:text-blue-800 text-xs shrink-0 ml-3" href="<%= view.h(view.page_report_href(url)) %>">Page report &rarr;</a>
37
+ </div>
38
+ <div class="text-xs text-gray-500 mb-3">
39
+ <%= page[:session_count] %> session<%= page[:session_count] == 1 ? "" : "s" %>,
40
+ avg depth <%= page[:avg_depth_px].round %>px<%
41
+ if page[:avg_depth_pct] %> (~<%= page[:avg_depth_pct].round %>% of the page)<% end %><%
42
+ if page[:page_height_px] %>, est. page height <%= page[:page_height_px].round %>px<% end %>.
43
+ </div>
44
+
45
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
46
+ <div>
47
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Depth Distribution</h6>
48
+ <svg viewBox="0 0 <%= view.svg_width %> <%= view.svg_height %>" width="100%" style="max-width:<%= view.svg_width %>px" role="img" aria-label="Scroll depth distribution">
49
+ <line x1="0" y1="<%= view.axis_y %>" x2="<%= view.svg_width %>" y2="<%= view.axis_y %>" stroke="#e5e7eb" stroke-width="1" />
50
+ <% dist.each_with_index do |(label, count), i| -%>
51
+ <%
52
+ bar_h = (count.to_f / view.max_count(dist) * (view.axis_y - view.chart_top)).round(1)
53
+ x = view.bar_gap + i * (view.bar_width + view.bar_gap)
54
+ y = (view.axis_y - bar_h).round(1)
55
+ -%>
56
+ <rect x="<%= x %>" y="<%= y %>" width="<%= view.bar_width %>" height="<%= bar_h %>" fill="#2563eb" rx="2" />
57
+ <text x="<%= x + view.bar_width / 2 %>" y="<%= (y - 4).round(1) %>" text-anchor="middle" font-size="10" fill="#374151"><%= count %></text>
58
+ <text x="<%= x + view.bar_width / 2 %>" y="<%= view.axis_y + 14 %>" text-anchor="middle" font-size="9" fill="#6b7280"><%= view.h(label) %>%</text>
59
+ <% end -%>
60
+ </svg>
61
+ </div>
62
+
63
+ <div>
64
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Fold Lines</h6>
65
+ <table class="w-full text-xs">
66
+ <tbody>
67
+ <tr>
68
+ <td class="py-1 text-gray-500">50th percentile</td>
69
+ <td class="py-1 text-right tabular-nums"><%= folds[:p50] ? "#{folds[:p50].round}% of page" : "&mdash;" %></td>
70
+ </tr>
71
+ <tr>
72
+ <td class="py-1 text-gray-500">75th percentile</td>
73
+ <td class="py-1 text-right tabular-nums"><%= folds[:p75] ? "#{folds[:p75].round}% of page" : "&mdash;" %></td>
74
+ </tr>
75
+ <tr>
76
+ <td class="py-1 text-gray-500">90th percentile</td>
77
+ <td class="py-1 text-right tabular-nums"><%= folds[:p90] ? "#{folds[:p90].round}% of page" : "&mdash;" %></td>
78
+ </tr>
79
+ </tbody>
80
+ </table>
81
+ <p class="text-[10px] text-gray-400 mt-2">
82
+ The page depth that this fraction of sessions reached or exceeded
83
+ (of the estimated page height).
84
+ </p>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ <% end -%>
90
+ <% end -%>
91
+
92
+ <div class="mt-4">
93
+ <a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">&larr; Back to Analytics</a>
94
+ </div>
@@ -0,0 +1,91 @@
1
+ <%= view.render_partial("_analytics_nav.html.erb", active: :vitals, 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">Web Vitals</h1>
4
+
5
+ <form method="get" action="<%= view.h(view.base_path) %>/analytics/vitals" 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: "samples") %>
12
+
13
+ <% if view.sorted_pages.empty? -%>
14
+ <div class="card">
15
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
16
+ No Web Vitals recorded yet. Enable <code class="font-mono">capture_web_vitals</code>
17
+ in the recorder config to start collecting LCP, INP and CLS.
18
+ </div>
19
+ </div>
20
+ <% else -%>
21
+ <p class="text-xs text-gray-500 mb-3">
22
+ p75 of each metric per page, from the ratings the browser reported
23
+ (good / needs-improvement / poor). One sample is one page view &mdash; the
24
+ metric&rsquo;s final report on that page &mdash; measured by the
25
+ <code class="font-mono">web-vitals</code> library running in visitors&rsquo; browsers.
26
+ </p>
27
+
28
+ <div class="table-wrap">
29
+ <table class="data-table">
30
+ <thead>
31
+ <tr>
32
+ <th>Page</th>
33
+ <th class="text-right">LCP p75</th>
34
+ <th class="text-right">INP p75</th>
35
+ <th class="text-right">CLS p75</th>
36
+ <th>Rating mix</th>
37
+ <th>Slowest session</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody>
41
+ <% view.sorted_pages.each do |url, page| -%>
42
+ <% mix = view.page_rating_mix(page); mix_total = mix.values.sum; worst = view.slowest(page) -%>
43
+ <tr>
44
+ <td class="break-words max-w-xs">
45
+ <div><%= view.h(url) %></div>
46
+ <a class="text-blue-600 hover:text-blue-800 text-[10px]" href="<%= view.h(view.page_report_href(url)) %>">Page report &rarr;</a>
47
+ </td>
48
+ <% %w[LCP INP CLS].each do |metric| -%>
49
+ <% m = page[:metrics][metric] -%>
50
+ <td class="text-right tabular-nums">
51
+ <% if m -%>
52
+ <span class="badge <%= view.rating_class(view.dominant_rating(m[:ratings])) %>">
53
+ <%= view.h(view.format_vital(metric, m[:p75])) %>
54
+ </span>
55
+ <span class="text-[10px] text-gray-400 whitespace-nowrap">n=<%= m[:samples] %></span>
56
+ <% else -%>
57
+ <span class="text-gray-400" title="The browser never reported this metric in range">&mdash; <span class="text-[10px]">(no reports)</span></span>
58
+ <% end -%>
59
+ </td>
60
+ <% end -%>
61
+ <td>
62
+ <% if mix_total > 0 -%>
63
+ <div class="flex h-2 w-24 rounded overflow-hidden" role="img"
64
+ aria-label="<%= mix.map { |rating, count| "#{count} #{rating}" }.join(", ") %>">
65
+ <% mix.each do |rating, count| -%>
66
+ <% next if count == 0 -%>
67
+ <div style="width:<%= (count.to_f / mix_total * 100).round(1) %>%;background:<%= view.rating_colors[rating] %>" title="<%= view.h(rating) %>: <%= count %>"></div>
68
+ <% end -%>
69
+ </div>
70
+ <% else -%>
71
+ <span class="text-gray-400">&mdash;</span>
72
+ <% end -%>
73
+ </td>
74
+ <td>
75
+ <% if worst -%>
76
+ <a class="text-blue-600 hover:text-blue-800"
77
+ href="<%= view.h(view.base_path) %>/sessions/<%= view.h(worst[:session_id].to_s) %>/windows/<%= view.h(worst[:window_id].to_s) %>?t=<%= worst[:offset_ms].to_i %>">Open in player &rarr;</a>
78
+ <% else -%>
79
+ <span class="text-gray-400">&mdash;</span>
80
+ <% end -%>
81
+ </td>
82
+ </tr>
83
+ <% end -%>
84
+ </tbody>
85
+ </table>
86
+ </div>
87
+ <% end -%>
88
+
89
+ <div class="mt-4">
90
+ <a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">&larr; Back to Analytics</a>
91
+ </div>
@@ -0,0 +1,73 @@
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) %>/issues?source=client" class="btn btn-sm btn-secondary shrink-0">&larr; Back</a>
4
+ <div class="w-px h-4 bg-gray-200 shrink-0"></div>
5
+ <span class="badge badge-danger shrink-0"><%= view.group[:count] %> occurrence<%= view.group[:count] == 1 ? "" : "s" %></span>
6
+ </div>
7
+ </div>
8
+
9
+ <%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "occurrences") %>
10
+
11
+ <div class="card mb-3">
12
+ <div class="card-body">
13
+ <h5 class="text-sm font-semibold text-gray-800 mb-2 break-words"><%= view.h(view.group[:message].to_s) %></h5>
14
+ <div class="grid grid-cols-[100px_1fr] gap-x-4 gap-y-2 mt-3">
15
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Source</div>
16
+ <div class="text-xs text-gray-600 font-mono">
17
+ <% if view.group[:source] -%>
18
+ <%= view.h(view.group[:source]) %><%= view.group[:line] ? ":#{view.group[:line]}" : "" %>
19
+ <% else -%>Unknown source<% end -%>
20
+ </div>
21
+
22
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider pt-0.5">Last seen</div>
23
+ <div class="text-xs text-gray-600">
24
+ <%= view.group[:last_seen_at] ? Time.at(view.group[:last_seen_at].to_f / 1000).utc.strftime("%b %d, %Y %H:%M:%S UTC") : "N/A" %>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <% if view.facet_chips.any? { |_title, counts| counts.any? } -%>
31
+ <div class="card mb-3" data-client-error-facets>
32
+ <div class="card-body">
33
+ <h6 class="text-xs font-semibold text-gray-800 mb-3 uppercase tracking-wider">Facets
34
+ <span class="font-normal normal-case tracking-normal text-gray-400">&mdash; per occurrence</span>
35
+ </h6>
36
+ <% view.facet_chips.each do |title, counts| -%>
37
+ <% next if counts.empty? -%>
38
+ <div class="flex items-center gap-2 mb-1.5 flex-wrap">
39
+ <span class="text-[10px] font-medium text-gray-400 uppercase tracking-wider w-16 shrink-0"><%= view.h(title) %></span>
40
+ <% counts.sort_by { |_value, count| -count }.first(8).each do |value, count| -%>
41
+ <span class="badge badge-neutral"><%= view.h(value) %>&nbsp;<span class="tabular-nums">&times;<%= count %></span></span>
42
+ <% end -%>
43
+ </div>
44
+ <% end -%>
45
+ </div>
46
+ </div>
47
+ <% end -%>
48
+
49
+ <div class="card">
50
+ <div class="card-body">
51
+ <h6 class="text-xs font-semibold text-gray-800 mb-3 uppercase tracking-wider">Occurrences (<%= view.group[:occurrences].size %>)</h6>
52
+ <div class="table-wrap">
53
+ <table class="data-table">
54
+ <thead><tr><th>Session</th><th>Window</th><th class="w-32">Time</th><th class="w-32">Replay</th></tr></thead>
55
+ <tbody>
56
+ <% view.group[:occurrences].each do |occ| -%>
57
+ <tr>
58
+ <td class="font-mono text-gray-500"><%= view.h(occ[:session_id].to_s) %></td>
59
+ <td class="font-mono text-gray-400"><%= view.h(occ[:window_id].to_s) %></td>
60
+ <td class="text-gray-400 text-[10px] tabular-nums">
61
+ <%= occ[:timestamp] ? Time.at(occ[:timestamp].to_f / 1000).utc.strftime("%b %d, %H:%M:%S") : "—" %>
62
+ </td>
63
+ <td>
64
+ <a class="text-blue-600 hover:text-blue-800"
65
+ href="<%= view.h(view.base_path) %>/sessions/<%= view.h(occ[:session_id].to_s) %>/windows/<%= view.h(occ[:window_id].to_s) %>?t=<%= occ[:offset_ms].to_i %>">Open in player &rarr;</a>
66
+ </td>
67
+ </tr>
68
+ <% end -%>
69
+ </tbody>
70
+ </table>
71
+ </div>
72
+ </div>
73
+ </div>
@@ -0,0 +1,56 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Sentiero Dashboard</title>
7
+ <link href="<%= view.built_asset('style') %>" rel="stylesheet">
8
+ </head>
9
+ <body>
10
+ <div class="s-layout">
11
+ <aside class="s-sidebar">
12
+ <%= view.render_partial("_brand.html.erb") %>
13
+ <ul class="s-sidebar-nav">
14
+ <li>
15
+ <a class="s-nav-item <%= request_path == '/' || request_path.start_with?('/sessions') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/">
16
+ <svg class="s-nav-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z" /></svg>
17
+ Sessions
18
+ </a>
19
+ </li>
20
+ <li>
21
+ <a class="s-nav-item <%= request_path.start_with?('/issues') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/issues">
22
+ <svg class="s-nav-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
23
+ Errors
24
+ </a>
25
+ </li>
26
+ <li>
27
+ <a class="s-nav-item <%= request_path.start_with?('/custom-events') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/custom-events">
28
+ <svg class="s-nav-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /></svg>
29
+ Events
30
+ </a>
31
+ </li>
32
+ <li>
33
+ <a class="s-nav-item <%= request_path.start_with?('/analytics') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/analytics">
34
+ <svg class="s-nav-icon" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>
35
+ Analytics
36
+ </a>
37
+ </li>
38
+ </ul>
39
+ </aside>
40
+
41
+ <header class="s-mobile-header">
42
+ <%= view.render_partial("_brand.html.erb") %>
43
+ <nav class="flex gap-1">
44
+ <a class="s-nav-item <%= request_path == '/' || request_path.start_with?('/sessions') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/">Sessions</a>
45
+ <a class="s-nav-item <%= request_path.start_with?('/issues') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/issues">Errors</a>
46
+ <a class="s-nav-item <%= request_path.start_with?('/custom-events') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/custom-events">Events</a>
47
+ <a class="s-nav-item <%= request_path.start_with?('/analytics') ? 'active' : '' %>" href="<%= view.h(view.base_path) %>/analytics">Analytics</a>
48
+ </nav>
49
+ </header>
50
+
51
+ <main class="s-main">
52
+ <%= content %>
53
+ </main>
54
+ </div>
55
+ </body>
56
+ </html>