sentiero 1.0.0.alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +7 -0
- data/README.md +679 -0
- data/lib/sentiero/analytics/analyzer.rb +91 -0
- data/lib/sentiero/analytics/bounded.rb +29 -0
- data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
- data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
- data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
- data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
- data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
- data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
- data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
- data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
- data/lib/sentiero/analytics/entry_attribution.rb +71 -0
- data/lib/sentiero/analytics/error_discovery.rb +118 -0
- data/lib/sentiero/analytics/events.rb +21 -0
- data/lib/sentiero/analytics/exporter.rb +242 -0
- data/lib/sentiero/analytics/form_analyzer.rb +153 -0
- data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
- data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
- data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
- data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
- data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
- data/lib/sentiero/analytics/problem_detail.rb +97 -0
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
- data/lib/sentiero/analytics/segmenter.rb +133 -0
- data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
- data/lib/sentiero/analytics/stats.rb +30 -0
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
- data/lib/sentiero/configuration.rb +184 -0
- data/lib/sentiero/erasure.rb +48 -0
- data/lib/sentiero/fingerprint.rb +34 -0
- data/lib/sentiero/ip_anonymizer.rb +29 -0
- data/lib/sentiero/redaction/config.rb +61 -0
- data/lib/sentiero/redaction.rb +207 -0
- data/lib/sentiero/reporter/configuration.rb +50 -0
- data/lib/sentiero/reporter/context.rb +31 -0
- data/lib/sentiero/reporter/dispatcher.rb +91 -0
- data/lib/sentiero/reporter/http_transport.rb +57 -0
- data/lib/sentiero/reporter/log_transport.rb +26 -0
- data/lib/sentiero/reporter/middleware.rb +62 -0
- data/lib/sentiero/reporter/normalizer.rb +14 -0
- data/lib/sentiero/reporter/null_transport.rb +18 -0
- data/lib/sentiero/reporter/report_context.rb +29 -0
- data/lib/sentiero/reporter/scrubber.rb +47 -0
- data/lib/sentiero/reporter/test_helper.rb +32 -0
- data/lib/sentiero/reporter/test_transport.rb +28 -0
- data/lib/sentiero/reporter.rb +214 -0
- data/lib/sentiero/roda.rb +47 -0
- data/lib/sentiero/store/error_store.rb +220 -0
- data/lib/sentiero/store/limits.rb +31 -0
- data/lib/sentiero/store/session_store.rb +118 -0
- data/lib/sentiero/store.rb +72 -0
- data/lib/sentiero/stores/file.rb +566 -0
- data/lib/sentiero/stores/memory.rb +362 -0
- data/lib/sentiero/stores/redis/keys.rb +59 -0
- data/lib/sentiero/stores/redis/lua.rb +119 -0
- data/lib/sentiero/stores/redis.rb +665 -0
- data/lib/sentiero/stores/sqlite/schema.rb +79 -0
- data/lib/sentiero/stores/sqlite.rb +626 -0
- data/lib/sentiero/user_agent.rb +32 -0
- data/lib/sentiero/version.rb +5 -0
- data/lib/sentiero/web/analytics_app.rb +538 -0
- data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
- data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
- data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
- data/lib/sentiero/web/assets/manifest.json +11 -0
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
- data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
- data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
- data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
- data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
- data/lib/sentiero/web/assets_app.rb +42 -0
- data/lib/sentiero/web/base_app.rb +319 -0
- data/lib/sentiero/web/basic_auth.rb +27 -0
- data/lib/sentiero/web/basic_auth_check.rb +41 -0
- data/lib/sentiero/web/body_reader.rb +44 -0
- data/lib/sentiero/web/csv_writer.rb +45 -0
- data/lib/sentiero/web/dashboard_app.rb +236 -0
- data/lib/sentiero/web/errors_app.rb +97 -0
- data/lib/sentiero/web/escaping.rb +37 -0
- data/lib/sentiero/web/events_app.rb +196 -0
- data/lib/sentiero/web/formatting.rb +43 -0
- data/lib/sentiero/web/ingest_app.rb +92 -0
- data/lib/sentiero/web/manifest.rb +43 -0
- data/lib/sentiero/web/monitoring_app.rb +316 -0
- data/lib/sentiero/web/script_tag.rb +57 -0
- data/lib/sentiero/web/shareable_replay.rb +88 -0
- data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
- data/lib/sentiero/web/templates/_brand.html.erb +18 -0
- data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
- data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
- data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
- data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
- data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
- data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
- data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
- data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
- data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
- data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
- data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
- data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
- data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
- data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
- data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
- data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
- data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
- data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
- data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
- data/lib/sentiero/web/templates/event_show.html.erb +52 -0
- data/lib/sentiero/web/templates/events_index.html.erb +177 -0
- data/lib/sentiero/web/templates/export_index.html.erb +69 -0
- data/lib/sentiero/web/templates/forms.html.erb +105 -0
- data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
- data/lib/sentiero/web/templates/import.html.erb +39 -0
- data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
- data/lib/sentiero/web/templates/segments.html.erb +114 -0
- data/lib/sentiero/web/templates/session_show.html.erb +195 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
- data/lib/sentiero/web/track_app.rb +57 -0
- data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
- data/lib/sentiero/web/views/analyzer_view.rb +27 -0
- data/lib/sentiero/web/views/base_view.rb +76 -0
- data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
- data/lib/sentiero/web/views/conversions_view.rb +41 -0
- data/lib/sentiero/web/views/engagement_view.rb +67 -0
- data/lib/sentiero/web/views/errors_index_view.rb +37 -0
- data/lib/sentiero/web/views/event_show_view.rb +20 -0
- data/lib/sentiero/web/views/events_index_view.rb +56 -0
- data/lib/sentiero/web/views/export_view.rb +23 -0
- data/lib/sentiero/web/views/forms_view.rb +28 -0
- data/lib/sentiero/web/views/frustration_view.rb +15 -0
- data/lib/sentiero/web/views/funnel_view.rb +36 -0
- data/lib/sentiero/web/views/heatmap_view.rb +34 -0
- data/lib/sentiero/web/views/import_view.rb +13 -0
- data/lib/sentiero/web/views/page_report_view.rb +43 -0
- data/lib/sentiero/web/views/problem_show_view.rb +46 -0
- data/lib/sentiero/web/views/scroll_view.rb +23 -0
- data/lib/sentiero/web/views/segments_view.rb +28 -0
- data/lib/sentiero/web/views/session_show_view.rb +105 -0
- data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
- data/lib/sentiero/web/views/vitals_view.rb +45 -0
- data/lib/sentiero/web/views.rb +24 -0
- data/lib/sentiero/window_ref.rb +6 -0
- data/lib/sentiero.rb +69 -0
- metadata +232 -0
|
@@ -0,0 +1,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) ? "▲" : "▼"
|
|
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×#{signals[:rage_clicks]}" if signals[:rage_clicks].to_i > 0
|
|
55
|
+
chips << "dead×#{signals[:dead_clicks]}" if signals[:dead_clicks].to_i > 0
|
|
56
|
+
chips << "churn×#{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×#{signals[:thrashing_scroll]}" if signals[:thrashing_scroll].to_i > 0
|
|
59
|
+
chips << "bounce" if signals[:quick_bounce]
|
|
60
|
+
chips << "refill×#{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
|