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,97 @@
1
+ <h1 class="page-title">Sessions</h1>
2
+
3
+ <form method="get" action="<%= view.h(view.base_path) %>/">
4
+ <div class="card mb-4">
5
+ <div class="flex items-end gap-3 px-4 py-3">
6
+ <div class="shrink-0">
7
+ <label for="search" class="label">Search</label>
8
+ <input type="text" name="search" id="search" class="input" style="width:11rem" value="<%= view.h(view.search) %>" placeholder="Session ID or metadata...">
9
+ </div>
10
+ <div class="shrink-0">
11
+ <label for="since" class="label">From</label>
12
+ <input type="date" name="since" id="since" class="input" style="width:8.5rem" value="<%= view.h(view.since) %>">
13
+ </div>
14
+ <div class="shrink-0">
15
+ <label for="until" class="label">To</label>
16
+ <input type="date" name="until" id="until" class="input" style="width:8.5rem" value="<%= view.h(view.until_param) %>">
17
+ </div>
18
+ <div class="shrink-0">
19
+ <label for="sort_by" class="label">Sort by</label>
20
+ <select name="sort_by" id="sort_by" class="select" style="width:9rem">
21
+ <option value="updated_at" <%= view.sort_by == "updated_at" ? "selected" : "" %>>Last Activity</option>
22
+ <option value="created_at" <%= view.sort_by == "created_at" ? "selected" : "" %>>Created</option>
23
+ <option value="event_count" <%= view.sort_by == "event_count" ? "selected" : "" %>>Event Count</option>
24
+ </select>
25
+ </div>
26
+ <div class="shrink-0 flex items-center gap-1.5 self-end pb-1.5">
27
+ <input type="checkbox" name="has_errors" id="has_errors" value="true" class="checkbox" <%= view.has_errors ? "checked" : "" %>>
28
+ <label for="has_errors" class="label mb-0">Has errors</label>
29
+ </div>
30
+ <div class="shrink-0 flex items-center gap-1.5">
31
+ <input type="hidden" name="per_page" value="<%= view.per_page %>">
32
+ <button type="submit" class="btn btn-primary">Filter</button>
33
+ <a href="<%= view.h(view.base_path) %>/" class="btn btn-secondary">Clear</a>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </form>
38
+
39
+ <% if view.sessions.empty? -%>
40
+ <div class="card">
41
+ <div class="text-center py-10 px-6 text-gray-400 text-xs">
42
+ <% if !view.search.empty? || !view.since.empty? || !view.until_param.empty? || view.has_errors -%>
43
+ No results match your filters. <a href="<%= view.h(view.base_path) %>/" class="text-blue-500 hover:text-blue-700">Clear filters</a>
44
+ <% else -%>
45
+ No sessions recorded yet.
46
+ <% end -%>
47
+ </div>
48
+ </div>
49
+ <% else -%>
50
+ <form id="bulk-delete-form" method="post" action="<%= view.h(view.base_path) %>/sessions/bulk_delete" data-confirm="Delete selected sessions?">
51
+ <input type="hidden" name="csrf_token" value="<%= view.h(view.csrf_token) %>">
52
+
53
+ <div class="table-wrap">
54
+ <table class="data-table">
55
+ <thead>
56
+ <tr>
57
+ <th class="w-8 text-center"><input type="checkbox" id="select-all" title="Select all" class="checkbox"></th>
58
+ <th>Session ID</th>
59
+ <%# C2: metadata url is overwritten per page load, so this column
60
+ shows the session's most recent page — labeled honestly. -%>
61
+ <th>Last page</th>
62
+ <th>Device</th>
63
+ <th>Windows</th>
64
+ <th>Events</th>
65
+ <th>Duration</th>
66
+ <th>Last Activity</th>
67
+ <th></th>
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ <% view.sessions.each do |s| -%>
72
+ <%= view.render_session_row(s, true, view.csrf_token) %>
73
+ <% end -%>
74
+ </tbody>
75
+ </table>
76
+ </div>
77
+
78
+ <div class="flex justify-between items-center flex-wrap mt-3">
79
+ <nav aria-label="Session pagination">
80
+ <ul class="pagination">
81
+ <li class="<%= 'page-disabled' if view.page <= 1 %>">
82
+ <a class="page-link" href="<%= view.h(view.base_path) %>/?page=<%= view.page - 1 %>&per_page=<%= view.per_page %>&search=<%= view.h(view.search) %>&sort_by=<%= view.h(view.sort_by) %>&since=<%= view.h(view.since) %>&until=<%= view.h(view.until_param) %><%= view.has_errors ? "&has_errors=true" : "" %>">Previous</a>
83
+ </li>
84
+ <li class="<%= 'page-disabled' unless view.has_next %>">
85
+ <a class="page-link" href="<%= view.h(view.base_path) %>/?page=<%= view.page + 1 %>&per_page=<%= view.per_page %>&search=<%= view.h(view.search) %>&sort_by=<%= view.h(view.sort_by) %>&since=<%= view.h(view.since) %>&until=<%= view.h(view.until_param) %><%= view.has_errors ? "&has_errors=true" : "" %>">Next</a>
86
+ </li>
87
+ </ul>
88
+ </nav>
89
+ <div id="bulk-actions" style="display:none" class="flex items-center gap-2">
90
+ <button type="submit" class="btn btn-sm btn-danger">Delete Selected</button>
91
+ <span class="text-gray-400 text-[10px]"><span id="selected-count">0</span> selected</span>
92
+ </div>
93
+ </div>
94
+ </form>
95
+
96
+ <script src="<%= view.built_asset('sessions_index') %>"></script>
97
+ <% end -%>
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ingest_app"
4
+ require_relative "../redaction"
5
+
6
+ module Sentiero
7
+ module Web
8
+ # Server-lane ingest for custom events (Sentiero.track). Flat, un-grouped;
9
+ # persisted via Sentiero.store.save_server_event.
10
+ class TrackApp < IngestApp
11
+ VALID_LEVELS = %w[debug info warn error].freeze
12
+ MAX_NAME_LENGTH = 200
13
+ MAX_PAYLOAD_BYTES = 16_384
14
+
15
+ private
16
+
17
+ def handle(env, project, data)
18
+ name = data["name"]
19
+ unless name.is_a?(String) && !name.empty?
20
+ return json_response(400, {error: "name is required"})
21
+ end
22
+
23
+ session_id = data["session_id"]
24
+ if session_id && !valid_optional_id?(session_id)
25
+ return json_response(400, {error: "invalid session_id"})
26
+ end
27
+
28
+ level = data["level"]
29
+ level = "info" unless VALID_LEVELS.include?(level)
30
+
31
+ event = {
32
+ "project" => project,
33
+ "name" => name[0, MAX_NAME_LENGTH],
34
+ "level" => level,
35
+ "timestamp" => numeric_timestamp(data["timestamp"])
36
+ }
37
+ if data["payload"].is_a?(Hash)
38
+ redacted = Redaction.deep_redact_strings(capped_payload(data["payload"]), Sentiero.configuration.redaction)
39
+ event["payload"] = redacted
40
+ end
41
+ event["session_id"] = session_id if session_id
42
+
43
+ begin
44
+ Sentiero.store.save_server_event(event)
45
+ rescue ArgumentError => e
46
+ return json_response(400, {error: e.message})
47
+ end
48
+
49
+ json_response(200, {status: "ok"})
50
+ end
51
+
52
+ def capped_payload(payload)
53
+ (JSON.generate(payload).bytesize <= MAX_PAYLOAD_BYTES) ? payload : {"_truncated" => true}
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "rack"
5
+ require_relative "base_view"
6
+
7
+ module Sentiero
8
+ module Web
9
+ module Views
10
+ class AnalyticsIndexView < BaseView
11
+ BUCKET_COLORS = %w[#2563eb #7c3aed #db2777 #ea580c #16a34a].freeze
12
+
13
+ def initialize(range_days:, allowed_ranges:, custom_range:, since:, until_str:, deltas:, stats:)
14
+ super()
15
+ @range_days = range_days
16
+ @allowed_ranges = allowed_ranges
17
+ @custom_range = custom_range
18
+ @since = since
19
+ @until_str = until_str
20
+ @deltas = deltas
21
+ @stats = stats
22
+ end
23
+
24
+ attr_reader :range_days, :allowed_ranges, :custom_range, :since, :until_str, :deltas, :stats
25
+
26
+ def template = "analytics_index.html.erb"
27
+
28
+ # Sessions/events deltas are % change; the error-free rate is percentage points.
29
+ def render_delta(value, attr, unit)
30
+ return "" if value.nil?
31
+ arrow = (value >= 0) ? "&#9650;" : "&#9660;"
32
+ color = (value >= 0) ? "#16a34a" : "#dc2626"
33
+ %(<span class="normal-case tracking-normal tabular-nums" style="color:#{color}" data-#{attr}="#{value}" title="vs previous period">#{arrow} #{value.abs}#{unit}</span>)
34
+ end
35
+
36
+ # An active custom range is carried into the range-scoped cross-links
37
+ # (the open-problems count is all-time, so its link stays unscoped).
38
+ def range_qs
39
+ return "" unless custom_range
40
+
41
+ "&" + Rack::Utils.build_query(range_pairs)
42
+ end
43
+
44
+ def series = stats[:events_per_day_series] || []
45
+ def max_events = series.map { |d| d[:event_count] }.max || 0
46
+ def max_sessions = series.map { |d| d[:session_count] }.max || 0
47
+
48
+ def distributions
49
+ [
50
+ ["Browsers", stats[:browser_distribution], "No browser data."],
51
+ ["Devices", stats[:device_distribution], "No device data."]
52
+ ]
53
+ end
54
+
55
+ def buckets = stats[:session_duration_buckets] || {}
56
+ def bucket_total = buckets.values.sum
57
+ def bucket_colors = BUCKET_COLORS
58
+ def donut_radius = 42
59
+ def donut_circumference = 2 * Math::PI * donut_radius
60
+
61
+ def custom_tags = stats[:custom_event_tags] || {}
62
+ def browser_tags = stats[:browser_event_tags] || {}
63
+ def tag_series = stats[:custom_event_tag_series] || {}
64
+
65
+ def tag_href(tag)
66
+ q = "search=#{CGI.escape(tag)}"
67
+ q = "source=browser&#{q}" if browser_tags.key?(tag)
68
+ "#{base_path}/custom-events?#{q}"
69
+ end
70
+
71
+ def tag_day_series(tag) = tag_series[tag] || []
72
+ def tag_series_max(tag) = tag_day_series(tag).map { |day| day[:count] }.max.to_i
73
+
74
+ def seg_href(row)
75
+ "#{base_path}/analytics/segments?" + Rack::Utils.build_query({"url_pattern" => row[:url], "has_errors" => "true"})
76
+ end
77
+
78
+ def err_pct(row) = (row[:count].to_i > 0) ? (row[:error_count].to_f / row[:count] * 100).round : 0
79
+
80
+ def nav_sections(nav)
81
+ [["Internal destinations", nav[:internal] || []], ["External destinations", nav[:external] || []]]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require_relative "base_view"
5
+
6
+ module Sentiero
7
+ module Web
8
+ module Views
9
+ class AnalyzerView < BaseView
10
+ def initialize(pages:, was_truncated:, since:, until_str:)
11
+ super()
12
+ @pages = pages
13
+ @was_truncated = was_truncated
14
+ @since = since
15
+ @until_str = until_str
16
+ end
17
+
18
+ attr_reader :pages, :was_truncated, :since, :until_str
19
+
20
+ def page_report_href(url)
21
+ q = {"url" => url}.merge(range_pairs)
22
+ "#{base_path}/analytics/page?" + Rack::Utils.build_query(q)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "concurrent-ruby"
5
+ require_relative "../escaping"
6
+ require_relative "../formatting"
7
+ require_relative "../manifest"
8
+
9
+ module Sentiero
10
+ module Web
11
+ module Views
12
+ class BaseView
13
+ include Escaping
14
+ include Formatting
15
+
16
+ TEMPLATES_DIR = File.expand_path("../templates", __dir__).freeze
17
+ TEMPLATE_CACHE = Concurrent::Map.new
18
+
19
+ attr_accessor :base_path, :csrf_token
20
+
21
+ def initialize
22
+ @base_path = ""
23
+ end
24
+
25
+ def h(text) = escape_html(text)
26
+
27
+ def escape_js(text) = escape_js_string(text)
28
+
29
+ def built_asset(name) = Sentiero::Web::Manifest.asset_path(name, base_path)
30
+
31
+ # Non-empty since/until query params, for range-preserving cross-links.
32
+ # Available to any view exposing since/until_str accessors.
33
+ def range_pairs
34
+ pairs = {}
35
+ pairs["since"] = since if since && !since.to_s.empty?
36
+ pairs["until"] = until_str if until_str && !until_str.to_s.empty?
37
+ pairs
38
+ end
39
+
40
+ def template
41
+ raise NotImplementedError, "#{self.class} must define #template"
42
+ end
43
+
44
+ def render
45
+ render_with(template, view: self)
46
+ end
47
+
48
+ def render_partial(filename, **locals)
49
+ render_with(filename, view: self, **locals)
50
+ end
51
+
52
+ def render_session_row(session, selectable, csrf_token = nil)
53
+ render_partial("_session_row.html.erb", s: session, selectable: selectable, csrf_token: csrf_token)
54
+ end
55
+
56
+ def render_layout(content, request_path:)
57
+ render_with("dashboard.html.erb", view: self, content: content, request_path: request_path)
58
+ end
59
+
60
+ def self.compiled_template(filename)
61
+ TEMPLATE_CACHE.compute_if_absent(filename) do
62
+ ERB.new(File.read(File.join(TEMPLATES_DIR, filename)), trim_mode: "-")
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def render_with(filename, **locals)
69
+ b = binding
70
+ locals.each { |k, v| b.local_variable_set(k, v) }
71
+ self.class.compiled_template(filename).result(b)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ class ClientErrorShowView < BaseView
9
+ def initialize(group:, was_truncated:)
10
+ super()
11
+ @group = group
12
+ @was_truncated = was_truncated
13
+ end
14
+
15
+ attr_reader :group, :was_truncated
16
+
17
+ def template = "client_error_show.html.erb"
18
+
19
+ def facet_chips
20
+ [
21
+ ["Browsers", group[:browsers] || {}],
22
+ ["Devices", group[:devices] || {}],
23
+ ["Pages", group[:pages] || {}]
24
+ ]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ class ConversionsView < BaseView
9
+ def initialize(tags:, selected_tag:, entry_pages:, referrers:, utm:, was_truncated:, since:, until_str:)
10
+ super()
11
+ @tags = tags
12
+ @selected_tag = selected_tag
13
+ @entry_pages = entry_pages
14
+ @referrers = referrers
15
+ @utm = utm
16
+ @was_truncated = was_truncated
17
+ @since = since
18
+ @until_str = until_str
19
+ end
20
+
21
+ attr_reader :tags, :selected_tag, :entry_pages, :referrers, :utm, :was_truncated, :since, :until_str
22
+
23
+ def template = "analytics_conversions.html.erb"
24
+
25
+ def player_link(ex)
26
+ "#{h(base_path)}/sessions/#{h(ex[:session_id].to_s)}/windows/#{h(ex[:window_id].to_s)}?t=#{ex[:offset_ms].to_i}"
27
+ end
28
+
29
+ def facets
30
+ [
31
+ ["Entry page", "entry page", entry_pages, nil],
32
+ ["Referrer", "referrer", referrers, nil],
33
+ ["UTM source", "UTM source", utm[:source], :utm],
34
+ ["UTM medium", "UTM medium", utm[:medium], :utm],
35
+ ["UTM campaign", "UTM campaign", utm[:campaign], :utm]
36
+ ]
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require_relative "base_view"
5
+
6
+ module Sentiero
7
+ module Web
8
+ module Views
9
+ class EngagementView < BaseView
10
+ def initialize(sessions:, distribution:, scanned:, was_truncated:, sort:, since:, until_str:)
11
+ super()
12
+ @sessions = sessions
13
+ @distribution = distribution
14
+ @scanned = scanned
15
+ @was_truncated = was_truncated
16
+ @sort = sort
17
+ @since = since
18
+ @until_str = until_str
19
+ end
20
+
21
+ attr_reader :sessions, :distribution, :scanned, :was_truncated, :sort, :since, :until_str
22
+
23
+ def template = "analytics_engagement.html.erb"
24
+
25
+ def sorted_sessions
26
+ (sort == "duration") ? sessions.sort_by { |row| [-row[:duration_ms], row[:session_id]] } : sessions
27
+ end
28
+
29
+ def sort_link(column)
30
+ "#{base_path}/analytics/engagement?" + Rack::Utils.build_query(range_pairs.merge("sort" => column))
31
+ end
32
+
33
+ def svg_width = 360
34
+ def svg_height = 140
35
+ def bar_gap = 12
36
+ def axis_y = svg_height - 24
37
+ def chart_top = 12
38
+ def bin_count = distribution.size
39
+ def bar_width = (svg_width - bar_gap * (bin_count + 1)) / bin_count
40
+ def max_count = [distribution.values.max, 1].max
41
+ def bar_h(count) = (count.to_f / max_count * (axis_y - chart_top)).round(1)
42
+ def bar_x(i) = bar_gap + i * (bar_width + bar_gap)
43
+ def bar_y(count) = (axis_y - bar_h(count)).round(1)
44
+
45
+ def badge_class(score)
46
+ if score >= 60 then "badge badge-danger"
47
+ elsif score >= 30 then "badge badge-warning"
48
+ else "text-gray-400"
49
+ end
50
+ end
51
+
52
+ def chips(signals)
53
+ chips = []
54
+ chips << "rage&times;#{signals[:rage_clicks]}" if signals[:rage_clicks].to_i > 0
55
+ chips << "dead&times;#{signals[:dead_clicks]}" if signals[:dead_clicks].to_i > 0
56
+ chips << "churn&times;#{signals[:nav_churn]}" if signals[:nav_churn].to_i > 0
57
+ chips << "idle #{(signals[:idle_ratio].to_f * 100).round}%" if signals[:idle_ratio].to_f > 0
58
+ chips << "thrash&times;#{signals[:thrashing_scroll]}" if signals[:thrashing_scroll].to_i > 0
59
+ chips << "bounce" if signals[:quick_bounce]
60
+ chips << "refill&times;#{signals[:form_refills]}" if signals[:form_refills].to_i > 0
61
+ chips << "err-abandon" if signals[:error_abandonment]
62
+ chips
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ # Serves both /issues branches; the template switches on #source.
9
+ class ErrorsIndexView < BaseView
10
+ def initialize(source:, problems: nil, groups: nil, sibling: nil, status: nil,
11
+ search: "", sort_by: nil, since_param: nil, until_param: nil, new_since: nil,
12
+ page: nil, per_page: nil, has_next: nil, was_truncated: false)
13
+ super()
14
+ @source = source
15
+ @problems = problems
16
+ @groups = groups
17
+ @sibling = sibling
18
+ @status = status
19
+ @search = search
20
+ @sort_by = sort_by
21
+ @since_param = since_param
22
+ @until_param = until_param
23
+ @new_since = new_since
24
+ @page = page
25
+ @per_page = per_page
26
+ @has_next = has_next
27
+ @was_truncated = was_truncated
28
+ end
29
+
30
+ attr_reader :source, :problems, :groups, :sibling, :status, :search, :sort_by,
31
+ :since_param, :until_param, :new_since, :page, :per_page, :has_next, :was_truncated
32
+
33
+ def template = "errors_index.html.erb"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ class EventShowView < BaseView
9
+ def initialize(event:)
10
+ super()
11
+ @event = event
12
+ end
13
+
14
+ attr_reader :event
15
+
16
+ def template = "event_show.html.erb"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require_relative "base_view"
5
+
6
+ module Sentiero
7
+ module Web
8
+ module Views
9
+ # Serves both /custom-events branches; the template switches on #source.
10
+ class EventsIndexView < BaseView
11
+ def initialize(source: "server", events: nil, browser_rows: nil, level: nil,
12
+ search: "", project: nil, projects: nil, since_param: nil, until_param: nil,
13
+ level_mix: nil, page: nil, per_page: nil, has_next: nil, was_truncated: false,
14
+ sibling: nil, single_name: nil, metric_keys: nil, metric_key: nil, metric_days: nil)
15
+ super()
16
+ @source = source
17
+ @events = events
18
+ @browser_rows = browser_rows
19
+ @level = level
20
+ @search = search
21
+ @project = project
22
+ @projects = projects
23
+ @since_param = since_param
24
+ @until_param = until_param
25
+ @level_mix = level_mix
26
+ @page = page
27
+ @per_page = per_page
28
+ @has_next = has_next
29
+ @was_truncated = was_truncated
30
+ @sibling = sibling
31
+ @single_name = single_name
32
+ @metric_keys = metric_keys
33
+ @metric_key = metric_key
34
+ @metric_days = metric_days
35
+ end
36
+
37
+ attr_reader :source, :events, :browser_rows, :level, :search, :project, :projects,
38
+ :since_param, :until_param, :level_mix, :page, :per_page, :has_next, :was_truncated,
39
+ :sibling, :single_name, :metric_keys, :metric_key, :metric_days
40
+
41
+ def template = "events_index.html.erb"
42
+
43
+ def volume_scaled? = !search.empty?
44
+
45
+ def mix_max = level_mix.map { |_date, counts| counts.values.sum }.max
46
+
47
+ def error_query(date)
48
+ Rack::Utils.build_query({
49
+ "level" => "error", "search" => search, "project" => project,
50
+ "since" => date, "until" => date
51
+ }.reject { |_key, value| value.to_s.empty? })
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ class ExportView < BaseView
9
+ def initialize(shareable_replays:, since:, until_str:, datasets:)
10
+ super()
11
+ @shareable_replays = shareable_replays
12
+ @since = since
13
+ @until_str = until_str
14
+ @datasets = datasets
15
+ end
16
+
17
+ attr_reader :shareable_replays, :since, :until_str, :datasets
18
+
19
+ def template = "export_index.html.erb"
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ class FormsView < BaseView
9
+ def initialize(sessions_started:, sessions_completed:, completion_rate:, total_submits:, fields:, drop_off_fields:, was_truncated:, since:, until_str:)
10
+ super()
11
+ @sessions_started = sessions_started
12
+ @sessions_completed = sessions_completed
13
+ @completion_rate = completion_rate
14
+ @total_submits = total_submits
15
+ @fields = fields
16
+ @drop_off_fields = drop_off_fields
17
+ @was_truncated = was_truncated
18
+ @since = since
19
+ @until_str = until_str
20
+ end
21
+
22
+ attr_reader :sessions_started, :sessions_completed, :completion_rate, :total_submits, :fields, :drop_off_fields, :was_truncated, :since, :until_str
23
+
24
+ def template = "forms.html.erb"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "analyzer_view"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Views
8
+ class FrustrationView < AnalyzerView
9
+ def template = "analytics_frustration.html.erb"
10
+
11
+ def sorted_pages = pages.sort_by { |url, page| [-(page[:rage_count] + page[:dead_count]), url] }
12
+ end
13
+ end
14
+ end
15
+ end