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,103 @@
1
+ <%= view.render_partial("_analytics_nav.html.erb", active: :funnel, 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">Funnel</h1>
4
+
5
+ <form method="get" action="<%= view.h(view.base_path) %>/analytics/funnel" class="flex items-end gap-2">
6
+ <% 3.times do |i| -%>
7
+ <div class="shrink-0">
8
+ <label for="step<%= i + 1 %>" class="label">Step <%= i + 1 %><%= (i == 2) ? " (optional)" : "" %></label>
9
+ <select name="step<%= i + 1 %>" id="step<%= i + 1 %>" class="select" style="width:10rem">
10
+ <option value="">&mdash;</option>
11
+ <% view.tags.each do |tag| -%>
12
+ <option value="<%= view.h(tag) %>" <%= (view.selected_steps[i] == tag) ? "selected" : "" %>><%= view.h(tag) %></option>
13
+ <% end -%>
14
+ </select>
15
+ </div>
16
+ <% end -%>
17
+ <%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
18
+ <button type="submit" class="btn btn-sm btn-secondary">Apply</button>
19
+ </form>
20
+ </div>
21
+
22
+ <%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "sessions") %>
23
+
24
+ <% if view.steps.empty? -%>
25
+ <div class="card">
26
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
27
+ Pick at least two steps to build a funnel. Steps are the custom-event
28
+ tags your site records via <code class="font-mono">Sentiero.track(tag)</code>;
29
+ a session converts a step only when it fires <em>after</em> the previous one.
30
+ </div>
31
+ </div>
32
+ <% elsif view.step_one_sessions == 0 -%>
33
+ <div class="card">
34
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
35
+ No sessions performed the first step
36
+ (<code class="font-mono"><%= view.h(view.steps.first[:tag]) %></code>) in this range.
37
+ </div>
38
+ </div>
39
+ <% else -%>
40
+ <p class="text-xs text-gray-500 mb-3">
41
+ Steps must occur in order within a session (each strictly after the
42
+ previous). Conversion is relative to step 1; times are the median gap
43
+ from the previous step across converting sessions.
44
+ </p>
45
+
46
+ <% view.steps.each_with_index do |step, i| -%>
47
+ <div class="card mb-3" data-funnel-step="<%= i + 1 %>">
48
+ <div class="card-body">
49
+ <div class="flex items-center gap-3 mb-2">
50
+ <span class="badge badge-neutral shrink-0"><%= i + 1 %></span>
51
+ <span class="text-sm font-medium text-gray-900 font-mono break-all"><%= view.h(step[:tag]) %></span>
52
+ <span class="text-xs text-gray-500 tabular-nums ml-auto shrink-0">
53
+ <span data-step-sessions="<%= step[:sessions] %>"><%= step[:sessions] %> session<%= step[:sessions] == 1 ? "" : "s" %></span>
54
+ <% if step[:conversion_pct] -%>
55
+ &middot; <span data-conversion-pct="<%= step[:conversion_pct] %>"><%= step[:conversion_pct] %>%</span>
56
+ <% end -%>
57
+ <% if step[:median_ms_from_previous] -%>
58
+ &middot; median <%= view.h(view.format_gap(step[:median_ms_from_previous])) %> after step <%= i %>
59
+ <% end -%>
60
+ </span>
61
+ </div>
62
+
63
+ <div class="bg-gray-100 rounded h-4 overflow-hidden">
64
+ <div class="bg-blue-500 h-4 rounded" style="width: <%= view.step_one_sessions.zero? ? 0 : (step[:sessions].to_f / view.step_one_sessions * 100).round(1) %>%"></div>
65
+ </div>
66
+
67
+ <% if view.next_step(i) && view.dropped(i) > 0 -%>
68
+ <div class="mt-3">
69
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1.5">
70
+ <%= view.dropped(i) %> dropped off before
71
+ <code class="font-mono normal-case"><%= view.h(view.next_step(i)[:tag]) %></code>
72
+ </div>
73
+ <% unless step[:drop_off_examples].empty? -%>
74
+ <table class="w-full text-xs">
75
+ <tbody>
76
+ <% step[:drop_off_examples].each do |example| -%>
77
+ <tr>
78
+ <td class="py-1 font-mono text-gray-500"><%= view.h(example[:session_id].to_s[0, 16]) %></td>
79
+ <td class="py-1 text-gray-400 tabular-nums">+<%= (example[:offset_ms].to_f / 1000).round(1) %>s</td>
80
+ <td class="py-1 text-right">
81
+ <a class="text-blue-600 hover:text-blue-800"
82
+ href="<%= view.h(view.base_path) %>/sessions/<%= view.h(example[:session_id].to_s) %>/windows/<%= view.h(example[:window_id].to_s) %>?t=<%= example[:offset_ms].to_i %>">Open in player &rarr;</a>
83
+ </td>
84
+ </tr>
85
+ <% end -%>
86
+ </tbody>
87
+ </table>
88
+ <% if view.dropped(i) > step[:drop_off_examples].size -%>
89
+ <p class="text-[10px] text-gray-400 mt-1">
90
+ Showing the first <%= step[:drop_off_examples].size %> of <%= view.dropped(i) %> drop-offs.
91
+ </p>
92
+ <% end -%>
93
+ <% end -%>
94
+ </div>
95
+ <% end -%>
96
+ </div>
97
+ </div>
98
+ <% end -%>
99
+ <% end -%>
100
+
101
+ <div class="mt-4">
102
+ <a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">&larr; Back to Analytics</a>
103
+ </div>
@@ -0,0 +1,380 @@
1
+ <%= view.render_partial("_analytics_nav.html.erb", active: :overview, 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">Analytics</h1>
4
+
5
+ <form method="get" action="<%= view.h(view.base_path) %>/analytics" 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="range" class="label">Preset</label>
9
+ <select name="range" id="range" class="select" style="width:8rem" data-analytics-range>
10
+ <% if view.custom_range -%>
11
+ <option value="" selected>Custom</option>
12
+ <% end -%>
13
+ <% view.allowed_ranges.each do |r| -%>
14
+ <option value="<%= r %>" <%= (!view.custom_range && r == view.range_days) ? "selected" : "" %>>Last <%= r %> days</option>
15
+ <% end -%>
16
+ </select>
17
+ </div>
18
+ <button type="submit" class="btn btn-sm btn-secondary" data-analytics-apply>Apply</button>
19
+ </form>
20
+ <script src="<%= view.built_asset('analytics') %>" defer></script>
21
+ </div>
22
+
23
+ <%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.stats[:was_truncated], noun: "figures", scanned: view.stats[:sessions_scanned]) %>
24
+
25
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3">
26
+ <div class="card">
27
+ <div class="px-5 py-4">
28
+ <div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.stats[:total_sessions] %></div>
29
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Total Sessions
30
+ <%= view.render_delta(view.deltas && view.deltas[:sessions], "delta-sessions", "%") %>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ <div class="card">
35
+ <div class="px-5 py-4">
36
+ <div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.stats[:total_events] %></div>
37
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Total Events
38
+ <%= view.render_delta(view.deltas && view.deltas[:events], "delta-events", "%") %>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ <div class="card">
43
+ <div class="px-5 py-4">
44
+ <div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.stats[:avg_duration_ms] ? view.h(view.format_duration(0, view.stats[:avg_duration_ms])) : "N/A" %></div>
45
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Avg Duration</div>
46
+ </div>
47
+ </div>
48
+ </div>
49
+
50
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-5">
51
+ <a href="<%= view.h(view.base_path) %>/issues" class="card hover:bg-gray-50 transition-colors">
52
+ <div class="px-5 py-4">
53
+ <div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.stats[:open_problems] %></div>
54
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Open Problems</div>
55
+ </div>
56
+ </a>
57
+ <a href="<%= view.h(view.base_path) %>/?has_errors=true<%= view.h(view.range_qs) %>" class="card hover:bg-gray-50 transition-colors">
58
+ <div class="px-5 py-4">
59
+ <div class="text-2xl font-bold text-gray-900 tracking-tight"><%= view.stats[:sessions_with_errors] %></div>
60
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Sessions with Errors</div>
61
+ </div>
62
+ </a>
63
+ <%# B5: 1 - errored/total as the crash-free-style KPI; N/A when no sessions. -%>
64
+ <a href="<%= view.h(view.base_path) %>/?has_errors=true<%= view.h(view.range_qs) %>" class="card hover:bg-gray-50 transition-colors">
65
+ <div class="px-5 py-4">
66
+ <div class="text-2xl font-bold text-gray-900 tracking-tight">
67
+ <% total_sessions = view.stats[:total_sessions] -%>
68
+ <%= (total_sessions > 0) ? "#{((1 - view.stats[:sessions_with_errors].to_f / total_sessions) * 100).round(1)}%" : "N/A" %>
69
+ </div>
70
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mt-1">Error-Free Sessions
71
+ <%= view.render_delta(view.deltas && view.deltas[:error_free_rate], "delta-error-free", "pp") %>
72
+ </div>
73
+ </div>
74
+ </a>
75
+ </div>
76
+
77
+ <% top_problems = view.stats[:top_problems] || [] -%>
78
+ <% unless top_problems.empty? -%>
79
+ <div class="card mb-4">
80
+ <div class="card-body">
81
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Top Problems
82
+ <span class="font-normal normal-case tracking-normal text-gray-400">&mdash; open, by occurrence count</span>
83
+ </h6>
84
+ <table class="w-full text-xs">
85
+ <% top_problems.each do |p| -%>
86
+ <tr class="border-b border-gray-100 last:border-0">
87
+ <td class="py-1 text-gray-700 truncate max-w-0 w-full">
88
+ <a href="<%= view.h(view.base_path) %>/issues/<%= view.h(p[:id].to_s) %>" class="text-blue-600 hover:text-blue-800">
89
+ <span class="text-gray-500"><%= view.h(p[:exception_class].to_s) %></span>
90
+ <%= view.h(p[:message].to_s[0, 120]) %>
91
+ </a>
92
+ <% if p[:new] -%>
93
+ <span class="badge badge-info">new</span>
94
+ <% end -%>
95
+ </td>
96
+ <td class="py-1 pl-2 text-right text-gray-500 tabular-nums"><%= p[:count] %></td>
97
+ </tr>
98
+ <% end -%>
99
+ </table>
100
+ </div>
101
+ </div>
102
+ <% end -%>
103
+
104
+ <div class="card mb-4">
105
+ <div class="card-body">
106
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider">Events per <%= (view.stats[:series_bucket] == "week") ? "Week" : "Day" %>
107
+ <span class="font-normal normal-case tracking-normal text-gray-400">&mdash;
108
+ <% if view.custom_range -%>
109
+ <%= view.h(view.since.empty? ? "start" : view.since) %> &rarr; <%= view.h(view.until_str.empty? ? "today" : view.until_str) %>
110
+ <% else -%>
111
+ Last <%= view.range_days %> Days
112
+ <% end -%>
113
+ </span>
114
+ </h6>
115
+
116
+ <% if view.max_events == 0 -%>
117
+ <p class="text-gray-400 text-xs py-6 text-center">No events in this range.</p>
118
+ <% else -%>
119
+ <div class="stats-chart">
120
+ <% view.series.each do |day| -%>
121
+ <% error_count = day[:error_count].to_i -%>
122
+ <% server_count = day[:server_error_count].to_i -%>
123
+ <div class="stats-chart-col">
124
+ <div class="stats-chart-bar-wrapper">
125
+ <% height_pct = (day[:event_count].to_f / view.max_events * 100).round -%>
126
+ <div class="stats-chart-bar" style="height: <%= height_pct %>%" title="<%= view.h(day[:date]) %>: <%= day[:event_count] %> events, <%= day[:session_count] %> sessions, <%= error_count %> JS errors">
127
+ <% if day[:event_count] > 0 -%>
128
+ <span class="stats-chart-value"><%= day[:event_count] %></span>
129
+ <% end -%>
130
+ </div>
131
+ <%# C6(a): the session count as a visible thin companion bar. -%>
132
+ <% if view.max_sessions > 0 && day[:session_count] > 0 -%>
133
+ <div data-session-count="<%= day[:session_count] %>"
134
+ title="<%= view.h(day[:date]) %>: <%= day[:session_count] %> session<%= day[:session_count] == 1 ? "" : "s" %>"
135
+ style="width:14%;max-width:8px;min-height:2px;height:<%= (day[:session_count].to_f / view.max_sessions * 100).round %>%;background:#7c3aed;border-radius:2px 2px 0 0;margin-left:2px"></div>
136
+ <% end -%>
137
+ </div>
138
+ <%# C6(b): per-day error annotations — client JS errors (amber) and
139
+ server exceptions (red) side by side, visually distinct. -%>
140
+ <% if error_count > 0 || server_count > 0 -%>
141
+ <div class="text-center" style="font-size:9px;line-height:1.2">
142
+ <% if error_count > 0 -%>
143
+ <span data-error-count="<%= error_count %>" style="color:#d97706" title="<%= error_count %> JS error<%= error_count == 1 ? "" : "s" %> on <%= view.h(day[:date]) %>">&#x26a0; <%= error_count %></span>
144
+ <% end -%>
145
+ <% if server_count > 0 -%>
146
+ <span data-server-error-count="<%= server_count %>" style="color:#dc2626" title="<%= server_count %> server exception<%= server_count == 1 ? "" : "s" %> on <%= view.h(day[:date]) %>">&#x26a0; <%= server_count %></span>
147
+ <% end -%>
148
+ </div>
149
+ <% end -%>
150
+ <%# Day buckets are "YYYY-MM-DD" (shown as MM-DD); week buckets are "YYYY-Wnn" (shown as Wnn). -%>
151
+ <div class="stats-chart-label"><%= view.h(day[:date].include?("W") ? day[:date].split("-", 2).last : day[:date][-5..]) %></div>
152
+ </div>
153
+ <% end -%>
154
+ </div>
155
+ <div class="text-[10px] text-gray-400 mt-2">
156
+ <span style="color:#2563eb">&#9632;</span> events &middot;
157
+ <span style="color:#7c3aed">&#9632;</span> sessions &middot;
158
+ <span style="color:#d97706">&#x26a0;</span> JS errors &middot;
159
+ <span style="color:#dc2626">&#x26a0;</span> server exceptions
160
+ </div>
161
+ <% end -%>
162
+ <% if view.stats[:server_overlay_truncated] -%>
163
+ <div class="text-[10px] mt-1" style="color:#d97706">
164
+ Server-exception overlay truncated (problem/occurrence caps hit); daily counts are a lower bound.
165
+ </div>
166
+ <% end -%>
167
+ <div class="mt-3 text-right">
168
+ <a href="<%= view.h(view.base_path) %>/issues?source=client<%= view.h(view.range_qs) %>" class="btn btn-sm btn-secondary">View all JS errors &rarr;</a>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
174
+ <% view.distributions.each do |title, counts, empty_message| -%>
175
+ <div class="card">
176
+ <div class="card-body">
177
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3"><%= view.h(title) %></h6>
178
+ <% if counts.empty? -%>
179
+ <p class="text-gray-400 text-xs py-4 text-center"><%= view.h(empty_message) %></p>
180
+ <% else -%>
181
+ <% bar_max = counts.values.max -%>
182
+ <% counts.each do |label, count| -%>
183
+ <div class="flex items-center gap-2 mb-1.5">
184
+ <div class="text-xs text-gray-700 w-20 shrink-0 truncate"><%= view.h(label) %></div>
185
+ <div class="flex-1 bg-gray-100 rounded h-3 overflow-hidden">
186
+ <div class="bg-blue-500 h-3 rounded" style="width: <%= (count.to_f / bar_max * 100).round %>%"></div>
187
+ </div>
188
+ <div class="text-xs text-gray-500 w-8 text-right tabular-nums"><%= count %></div>
189
+ </div>
190
+ <% end -%>
191
+ <% end -%>
192
+ </div>
193
+ </div>
194
+ <% end -%>
195
+ </div>
196
+
197
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
198
+ <div class="card">
199
+ <div class="card-body">
200
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Session Duration</h6>
201
+ <% if view.bucket_total == 0 -%>
202
+ <p class="text-gray-400 text-xs py-4 text-center">No sessions with measurable duration.</p>
203
+ <% else -%>
204
+ <div class="flex items-center gap-4">
205
+ <%
206
+ # Hand-rolled donut: each slice is a stroke-dasharray arc on a circle.
207
+ offset = 0.0
208
+ -%>
209
+ <svg width="120" height="120" viewBox="0 0 120 120" role="img" aria-label="Session duration distribution">
210
+ <g transform="rotate(-90 60 60)">
211
+ <% view.buckets.each_with_index do |(label, count), i| -%>
212
+ <% next if count == 0 -%>
213
+ <% fraction = count.to_f / view.bucket_total -%>
214
+ <% dash = (fraction * view.donut_circumference).round(2) -%>
215
+ <circle cx="60" cy="60" r="<%= view.donut_radius %>" fill="none"
216
+ stroke="<%= view.bucket_colors[i % view.bucket_colors.size] %>" stroke-width="16"
217
+ stroke-dasharray="<%= dash %> <%= (view.donut_circumference - dash).round(2) %>"
218
+ stroke-dashoffset="<%= (-offset).round(2) %>"></circle>
219
+ <% offset += dash -%>
220
+ <% end -%>
221
+ </g>
222
+ </svg>
223
+ <div class="flex-1">
224
+ <% view.buckets.each_with_index do |(label, count), i| -%>
225
+ <div class="flex items-center gap-2 mb-1 text-xs">
226
+ <span class="inline-block w-3 h-3 rounded-sm shrink-0" style="background: <%= view.bucket_colors[i % view.bucket_colors.size] %>"></span>
227
+ <span class="text-gray-700 w-16"><%= view.h(label) %></span>
228
+ <span class="text-gray-500 tabular-nums"><%= count %></span>
229
+ </div>
230
+ <% end -%>
231
+ </div>
232
+ </div>
233
+ <% end -%>
234
+ </div>
235
+ </div>
236
+
237
+ <div class="card">
238
+ <div class="card-body">
239
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Custom Event Tags</h6>
240
+ <% if view.custom_tags.empty? -%>
241
+ <p class="text-gray-400 text-xs py-4 text-center">No custom events.</p>
242
+ <% else -%>
243
+ <table class="w-full text-xs">
244
+ <% view.custom_tags.each do |tag, count| -%>
245
+ <tr class="border-b border-gray-100 last:border-0">
246
+ <td class="py-1 text-gray-700">
247
+ <a href="<%= view.h(view.tag_href(tag)) %>" class="text-blue-600 hover:text-blue-800"><%= view.h(tag) %></a>
248
+ </td>
249
+ <td class="py-1 pl-2">
250
+ <%# C5: tiny per-bucket sparkline, aligned with the overview chart's buckets. %>
251
+ <% if view.tag_series_max(tag) > 0 -%>
252
+ <div class="flex items-end ml-auto" style="gap:1px;height:16px;width:6rem" data-tag-series="<%= view.h(tag) %>">
253
+ <% view.tag_day_series(tag).each do |day| -%>
254
+ <div class="flex-1" style="height:<%= [(day[:count].to_f / view.tag_series_max(tag) * 16).round, 1].max %>px;background:<%= day[:count].zero? ? "#f3f4f6" : "#93c5fd" %>" title="<%= view.h(day[:date]) %>: <%= day[:count] %>"></div>
255
+ <% end -%>
256
+ </div>
257
+ <% end -%>
258
+ </td>
259
+ <td class="py-1 pl-2 text-right text-gray-500 tabular-nums"><%= count %></td>
260
+ </tr>
261
+ <% end -%>
262
+ </table>
263
+ <% end -%>
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
269
+ <div class="card">
270
+ <div class="card-body">
271
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-1">Top Entry Pages</h6>
272
+ <%# C2 (P2.2): one-line method note so "entry" can't be misread. -%>
273
+ <div class="text-[10px] text-gray-400 mb-2">Entry = first page seen in the recording.</div>
274
+ <% pages = view.stats[:top_entry_pages] || [] -%>
275
+ <% if pages.empty? -%>
276
+ <p class="text-gray-400 text-xs py-4 text-center">No entry-page data.</p>
277
+ <% else -%>
278
+ <table class="w-full text-xs">
279
+ <tr class="border-b border-gray-100">
280
+ <th class="py-1 text-left text-[10px] font-medium text-gray-400 uppercase tracking-wider">Page</th>
281
+ <th class="py-1 pl-2 text-right text-[10px] font-medium text-gray-400 uppercase tracking-wider">Sessions</th>
282
+ <th class="py-1 pl-2 text-right text-[10px] font-medium text-gray-400 uppercase tracking-wider">Err %</th>
283
+ </tr>
284
+ <% pages.each do |row| -%>
285
+ <tr class="border-b border-gray-100 last:border-0">
286
+ <td class="py-1 text-gray-700 truncate max-w-0 w-full">
287
+ <a href="<%= view.h(view.seg_href(row)) %>" class="text-blue-600 hover:text-blue-800" title="Open errored sessions entering on this page"><%= view.h(row[:url]) %></a>
288
+ </td>
289
+ <td class="py-1 pl-2 text-right text-gray-500 tabular-nums"><%= row[:count] %></td>
290
+ <td class="py-1 pl-2 text-right tabular-nums <%= (view.err_pct(row) > 0) ? "text-red-600" : "text-gray-400" %>" data-entry-err-pct="<%= view.err_pct(row) %>"><%= view.err_pct(row) %>%</td>
291
+ </tr>
292
+ <% end -%>
293
+ </table>
294
+ <div class="text-[10px] text-gray-400 mt-1">Err % = sessions entering on this page that hit an error anywhere in the session (entry-page correlation, not the page the error fired on).</div>
295
+ <% end -%>
296
+ </div>
297
+ </div>
298
+
299
+ <div class="card">
300
+ <div class="card-body">
301
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Top Referrers</h6>
302
+ <% referrers = view.stats[:top_referrers] || [] -%>
303
+ <% if referrers.empty? -%>
304
+ <p class="text-gray-400 text-xs py-4 text-center">No referrer data.</p>
305
+ <% else -%>
306
+ <table class="w-full text-xs">
307
+ <% referrers.each do |row| -%>
308
+ <tr class="border-b border-gray-100 last:border-0">
309
+ <td class="py-1 text-gray-700 truncate max-w-0 w-full"><%= view.h(row[:referrer]) %></td>
310
+ <td class="py-1 pl-2 text-right text-gray-500 tabular-nums"><%= row[:count] %></td>
311
+ </tr>
312
+ <% end -%>
313
+ </table>
314
+ <% end -%>
315
+ </div>
316
+ </div>
317
+ </div>
318
+
319
+ <%# B8 + B9: navigation destinations and custom-metadata discovery panels. -%>
320
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
321
+ <div class="card">
322
+ <div class="card-body">
323
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Navigation Clicks</h6>
324
+ <% nav = view.stats[:navigation] || {} -%>
325
+ <% if view.nav_sections(nav).all? { |_title, rows| rows.empty? } -%>
326
+ <p class="text-gray-400 text-xs py-4 text-center">No navigation clicks recorded (enable trackNavigation).</p>
327
+ <% else -%>
328
+ <% view.nav_sections(nav).each do |title, rows| -%>
329
+ <% next if rows.empty? -%>
330
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1.5"><%= view.h(title) %></div>
331
+ <table class="w-full text-xs mb-3">
332
+ <% rows.each do |row| -%>
333
+ <tr class="border-b border-gray-100 last:border-0">
334
+ <td class="py-1 text-gray-700 truncate max-w-0 w-full"><%= view.h(row[:url]) %></td>
335
+ <td class="py-1 pl-2 text-right text-gray-500 tabular-nums"><%= row[:count] %></td>
336
+ </tr>
337
+ <% end -%>
338
+ </table>
339
+ <% end -%>
340
+ <% texts = nav[:top_texts] || [] -%>
341
+ <% unless texts.empty? -%>
342
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1.5">Top link text</div>
343
+ <div class="flex items-center gap-1 flex-wrap">
344
+ <% texts.first(8).each do |row| -%>
345
+ <span class="badge badge-neutral"><%= view.h(row[:text]) %>&nbsp;<span class="tabular-nums">&times;<%= row[:count] %></span></span>
346
+ <% end -%>
347
+ </div>
348
+ <% end -%>
349
+ <% end -%>
350
+ </div>
351
+ </div>
352
+
353
+ <div class="card">
354
+ <div class="card-body">
355
+ <h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Session Metadata</h6>
356
+ <% meta_dist = view.stats[:metadata_distributions] || [] -%>
357
+ <% if meta_dist.empty? -%>
358
+ <p class="text-gray-400 text-xs py-4 text-center">No custom metadata recorded (use Sentiero.setMetadata).</p>
359
+ <% else -%>
360
+ <% meta_dist.each do |entry| -%>
361
+ <div class="mb-3">
362
+ <div class="text-[10px] font-medium text-gray-400 uppercase tracking-wider mb-1.5">
363
+ <a href="<%= view.h("#{view.base_path}/analytics/segments?" + Rack::Utils.build_query({"metadata_key" => entry[:key]})) %>" class="text-blue-600 hover:text-blue-800"><%= view.h(entry[:key]) %></a>
364
+ <span class="normal-case tracking-normal">&middot; <%= entry[:count] %> session<%= entry[:count] == 1 ? "" : "s" %></span>
365
+ </div>
366
+ <div class="flex items-center gap-1 flex-wrap">
367
+ <% entry[:values].each do |val| -%>
368
+ <a href="<%= view.h("#{view.base_path}/analytics/segments?" + Rack::Utils.build_query({"metadata_key" => entry[:key], "metadata_value" => val[:value]})) %>" class="badge badge-neutral hover:bg-gray-50"><%= view.h(val[:value]) %>&nbsp;<span class="tabular-nums">&times;<%= val[:count] %></span></a>
369
+ <% end -%>
370
+ </div>
371
+ </div>
372
+ <% end -%>
373
+ <% end -%>
374
+ </div>
375
+ </div>
376
+ </div>
377
+
378
+ <div class="mt-4">
379
+ <a href="<%= view.h(view.base_path) %>/" class="btn btn-sm btn-secondary">&larr; Back to Sessions</a>
380
+ </div>