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,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack/utils"
5
+ require_relative "body_reader"
6
+ require_relative "../store"
7
+
8
+ module Sentiero
9
+ module Web
10
+ # Base class for the authenticated server-lane ingest apps (eg ErrorsApp, TrackApp).
11
+ # Subclasses implement #handle(env, project, data).
12
+ #
13
+ # Unlike EventsApp (the public browser lane), these require a per-project
14
+ # write-only ingest key: Authorization: Bearer <key>. Keys map to a project
15
+ # via Sentiero.configuration.ingest_keys ({ "<secret>" => "<project>" }).
16
+ class IngestApp
17
+ def call(env)
18
+ return json_response(405, {error: "method not allowed"}) unless env["REQUEST_METHOD"] == "POST"
19
+
20
+ project = authenticate(env)
21
+ return json_response(401, {error: "invalid or missing ingest key"}) unless project
22
+
23
+ body, error = read_body(env)
24
+ return error if error
25
+
26
+ begin
27
+ data = JSON.parse(body)
28
+ rescue JSON::ParserError
29
+ return json_response(400, {error: "invalid JSON body"})
30
+ end
31
+ return json_response(400, {error: "body must be a JSON object"}) unless data.is_a?(Hash)
32
+
33
+ handle(env, project, data)
34
+ end
35
+
36
+ private
37
+
38
+ # Subclass hook; returns a Rack response triple.
39
+ def handle(env, project, data)
40
+ raise NoMethodError, "#{self.class}#handle not implemented"
41
+ end
42
+
43
+ # Resolves the ingest key to a project name, or nil. Constant-time compare
44
+ # so timing can't distinguish a wrong key from a right one of equal length.
45
+ def authenticate(env)
46
+ keys = Sentiero.configuration.ingest_keys
47
+ return nil if keys.nil? || keys.empty?
48
+
49
+ presented = bearer_token(env)
50
+ return nil if presented.nil? || presented.empty?
51
+
52
+ keys.each do |key, project|
53
+ return project if Rack::Utils.secure_compare(key.to_s, presented)
54
+ end
55
+ nil
56
+ end
57
+
58
+ def bearer_token(env)
59
+ header = env["HTTP_AUTHORIZATION"]
60
+ return nil unless header
61
+
62
+ scheme, token = header.split(" ", 2)
63
+ return nil unless scheme&.downcase == "bearer"
64
+ token&.strip
65
+ end
66
+
67
+ def read_body(env)
68
+ raw, error = BodyReader.read(env)
69
+ return [raw, nil] unless error
70
+
71
+ status, message = BodyReader::ERRORS[error]
72
+ [nil, json_response(status, {error: message})]
73
+ end
74
+
75
+ def json_response(status, hash)
76
+ [status, {"content-type" => "application/json", "x-content-type-options" => "nosniff"}, [JSON.generate(hash)]]
77
+ end
78
+
79
+ def numeric_timestamp(raw)
80
+ return Time.now.to_f if raw.nil?
81
+ ts = raw.is_a?(Numeric) ? raw.to_f : Float(raw)
82
+ ts.finite? ? ts : Time.now.to_f
83
+ rescue ArgumentError, TypeError
84
+ Time.now.to_f
85
+ end
86
+
87
+ def valid_optional_id?(id)
88
+ id.is_a?(String) && id.match?(Store::VALID_ID)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sentiero
6
+ module Web
7
+ module Manifest
8
+ ASSETS_DIR = File.expand_path("assets", __dir__).freeze
9
+
10
+ def self.manifest
11
+ if @auto_reload
12
+ load_manifest
13
+ else
14
+ @manifest ||= load_manifest
15
+ end
16
+ end
17
+
18
+ # Re-read manifest from disk on every access (dev, for `npm run watch`).
19
+ def self.auto_reload!
20
+ @auto_reload = true
21
+ end
22
+
23
+ def self.asset_path(logical_name, base_path = "")
24
+ filename = manifest[logical_name]
25
+ unless filename
26
+ raise Sentiero::Error, "Unknown asset: #{logical_name}. Run 'cd frontend && npm run build' first."
27
+ end
28
+ "#{base_path}/assets/#{filename}"
29
+ end
30
+
31
+ def self.reset!
32
+ @manifest = load_manifest
33
+ @auto_reload = false
34
+ end
35
+
36
+ private_class_method def self.load_manifest
37
+ path = File.join(ASSETS_DIR, "manifest.json")
38
+ return {}.freeze unless File.exist?(path)
39
+ JSON.parse(File.read(path)).freeze
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_app"
4
+ require_relative "../analytics/browser_event_discovery"
5
+ require_relative "../analytics/error_discovery"
6
+ require_relative "../analytics/server_event_metrics"
7
+ require_relative "../analytics/problem_detail"
8
+
9
+ module Sentiero
10
+ module Web
11
+ # Rack app owning the error/issue tracking (/issues/*) and custom-event
12
+ # browsing (/custom-events/*) routes. Mounted at the same point as
13
+ # DashboardApp (which delegates those requests here), so PATH_INFO/SCRIPT_NAME
14
+ # are read from env to preserve base_path.
15
+ class MonitoringApp < BaseApp
16
+ def initialize
17
+ super
18
+ BaseApp.warn_unauthenticated_once
19
+ end
20
+
21
+ def call(env)
22
+ path = env["PATH_INFO"] || "/"
23
+ method = env["REQUEST_METHOD"]
24
+
25
+ return unauthorized_response unless authorized?(env)
26
+
27
+ case path
28
+ when "/custom-events"
29
+ handle_events_index(env)
30
+ when %r{\A/custom-events/([^/]+)\z}
31
+ event_id = $1
32
+ get_only(method) || guard(event_id) || handle_event_show(env, event_id)
33
+ when "/issues"
34
+ handle_errors_index(env)
35
+ when %r{\A/issues/client/([^/]+)\z}
36
+ # Matched BEFORE the generic /issues/:id case so "client" isn't taken as
37
+ # a server fingerprint. Client error ids are ErrorDiscovery group digests,
38
+ # not store ids, so there is no id guard here.
39
+ id = $1
40
+ get_only(method) || handle_client_error_show(env, id)
41
+ when %r{\A/issues/([^/]+)/status\z}
42
+ problem_id = $1
43
+ post_only(method) || guard(problem_id) || handle_error_status(env, problem_id)
44
+ when %r{\A/issues/([^/]+)\z}
45
+ problem_id = $1
46
+ get_only(method) || guard(problem_id) || handle_error_show(env, problem_id)
47
+ else
48
+ not_found
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def handle_event_show(env, event_id)
55
+ event = Sentiero.store.get_server_event(event_id)
56
+ return not_found if event.nil?
57
+
58
+ audit!(env, action: :view_event)
59
+ render_page(env, Views::EventShowView.new(event: event), csrf: false)
60
+ end
61
+
62
+ def handle_events_index(env)
63
+ params = query_params(env)
64
+ return handle_browser_events(env) if params["source"] == "browser"
65
+
66
+ level = %w[debug info warn error].include?(params["level"]) ? params["level"] : nil
67
+ search = params["search"]
68
+ project_param = params["project"]
69
+ project_param = nil if project_param&.empty?
70
+ since, until_time = parse_range_params(params)
71
+ since_param, until_param = echo_range_params(params, since, until_time)
72
+
73
+ page, per_page, offset = paginate(params, default: 50, max: 200)
74
+
75
+ events = filtered_server_events(level: level, project: project_param, search: search, since: since, until_time: until_time)
76
+ page_events, has_next = take_page(events.slice(offset, per_page + 1) || [], per_page)
77
+
78
+ audit!(env, action: :list_events)
79
+
80
+ projects = (Sentiero.configuration.ingest_keys || {}).values.uniq.sort
81
+
82
+ sibling = if events.empty? && level.nil? && search.to_s.empty? && project_param.nil? && since.nil? && until_time.nil?
83
+ result = Sentiero::Analytics::BrowserEventDiscovery.new(Sentiero.store).recent_events
84
+ {count: result[:rows].size, capped: result[:was_truncated]}
85
+ end
86
+
87
+ # level_mix and payload metrics compute over the full pre-pagination list,
88
+ # so the strips describe the whole filtered range, not one page.
89
+ metrics = Sentiero::Analytics::ServerEventMetrics.new(events)
90
+ render_page(env, Views::EventsIndexView.new(
91
+ events: page_events,
92
+ level: level || "",
93
+ search: search || "",
94
+ project: project_param || "",
95
+ projects: projects,
96
+ since_param: since_param,
97
+ until_param: until_param,
98
+ level_mix: metrics.level_mix_by_day,
99
+ page: page,
100
+ per_page: per_page,
101
+ has_next: has_next,
102
+ sibling: sibling,
103
+ **metrics.payload_metric_locals(params["metric_key"])
104
+ ), csrf: false)
105
+ end
106
+
107
+ # Bounded server-event fetch for the events index, request filters applied.
108
+ # `since` rides the store's after: param (strict >, equivalent for a midnight
109
+ # bound); `until` and name search filter in Ruby. Newest first for display.
110
+ def filtered_server_events(level:, project:, search:, since:, until_time:)
111
+ events = Sentiero.store.list_server_events(project: project, limit: 10_000, level: level, after: since)
112
+ events = events.select { |e| e["timestamp"].to_f <= until_time } if until_time
113
+ if search && !search.empty?
114
+ term = search.downcase
115
+ events = events.select { |e| e["name"].to_s.downcase.include?(term) }
116
+ end
117
+ events.reverse
118
+ end
119
+
120
+ # Bound on the store-list calls behind sibling-tab counts in empty states;
121
+ # a count that hits the cap renders as "500+".
122
+ SIBLING_COUNT_LIMIT = 500
123
+
124
+ def handle_browser_events(env)
125
+ params = query_params(env)
126
+ search = params["search"]
127
+ since, until_time = parse_range_params(params)
128
+ since_param, until_param = echo_range_params(params, since, until_time)
129
+
130
+ page, per_page, offset = paginate(params, default: 50, max: 200)
131
+
132
+ result = Sentiero::Analytics::BrowserEventDiscovery.new(Sentiero.store)
133
+ .recent_events(since: since, until_time: until_time)
134
+ rows = result[:rows]
135
+ if search && !search.empty?
136
+ term = search.downcase
137
+ rows = rows.select { |r| r[:name].to_s.downcase.include?(term) }
138
+ end
139
+ page_rows, has_next = take_page(rows.slice(offset, per_page + 1) || [], per_page)
140
+
141
+ # Bounded count of the sibling server-events tab for the empty-state cross-link.
142
+ sibling = if rows.empty? && search.to_s.empty? && since.nil? && until_time.nil?
143
+ server_events = Sentiero.store.list_server_events(project: nil, limit: SIBLING_COUNT_LIMIT)
144
+ {count: server_events.size, capped: server_events.size >= SIBLING_COUNT_LIMIT}
145
+ end
146
+
147
+ audit!(env, action: :list_events)
148
+ render_page(env, Views::EventsIndexView.new(
149
+ source: "browser",
150
+ browser_rows: page_rows,
151
+ search: search || "",
152
+ since_param: since_param,
153
+ until_param: until_param,
154
+ page: page,
155
+ per_page: per_page,
156
+ has_next: has_next,
157
+ was_truncated: result[:was_truncated],
158
+ sibling: sibling,
159
+ **Sentiero::Analytics::ServerEventMetrics.new(
160
+ Sentiero::Analytics::ServerEventMetrics.adapt_browser_rows(rows)
161
+ ).payload_metric_locals(params["metric_key"])
162
+ ), csrf: false)
163
+ end
164
+
165
+ # Unified errors listing. `?source=client` renders aggregated client-side JS
166
+ # errors; otherwise the server-exception ("problems") listing. The server
167
+ # branch sets the CSRF cookie for inline resolve/ignore.
168
+ def handle_errors_index(env)
169
+ params = query_params(env)
170
+ source = (params["source"] == "client") ? "client" : "server"
171
+ if source == "client"
172
+ handle_client_errors_index(env, params)
173
+ else
174
+ handle_server_errors_index(env, params)
175
+ end
176
+ end
177
+
178
+ def handle_server_errors_index(env, params)
179
+ status = %w[open resolved ignored].include?(params["status"]) ? params["status"] : nil
180
+ search = params["search"]
181
+ sort_by = %w[last_seen first_seen count].include?(params["sort_by"]) ? params["sort_by"] : "last_seen"
182
+ since, until_time = parse_range_params(params)
183
+ since_param, until_param = echo_range_params(params, since, until_time)
184
+
185
+ page, per_page, offset = paginate(params, default: 50, max: 200)
186
+ problems = Sentiero.store.list_problems(project: nil, limit: per_page + 1, offset: offset,
187
+ status: status, sort_by: sort_by, search: search, since: since, until_time: until_time)
188
+ problems, has_next = take_page(problems, per_page)
189
+
190
+ audit!(env, action: :list_problems)
191
+
192
+ sibling = if problems.empty? && page == 1 && status.nil? && search.to_s.empty? && since.nil? && until_time.nil?
193
+ result = Sentiero::Analytics::ErrorDiscovery.new(Sentiero.store).grouped_errors
194
+ {count: result[:groups].size, capped: result[:was_truncated]}
195
+ end
196
+
197
+ render_page(env, Views::ErrorsIndexView.new(
198
+ source: "server",
199
+ problems: problems,
200
+ sibling: sibling,
201
+ status: status || "",
202
+ search: search || "",
203
+ sort_by: sort_by,
204
+ since_param: since_param,
205
+ until_param: until_param,
206
+ new_since: since,
207
+ page: page,
208
+ per_page: per_page,
209
+ has_next: has_next
210
+ ))
211
+ end
212
+
213
+ def handle_client_errors_index(env, params)
214
+ sort_by = %w[count recency].include?(params["sort_by"]) ? params["sort_by"] : "count"
215
+ search = params["search"]
216
+ since, until_time = parse_range_params(params)
217
+ since_param, until_param = echo_range_params(params, since, until_time)
218
+
219
+ page, per_page, offset = paginate(params, default: 50, max: 200)
220
+
221
+ result = Sentiero::Analytics::ErrorDiscovery.new(Sentiero.store)
222
+ .grouped_errors(sort_by: sort_by, since: since, until_time: until_time)
223
+ groups = result[:groups]
224
+ if search && !search.empty?
225
+ term = search.downcase
226
+ groups = groups.select { |g| g[:message].to_s.downcase.include?(term) }
227
+ end
228
+ page_groups, has_next = take_page(groups.slice(offset, per_page + 1) || [], per_page)
229
+
230
+ audit!(env, action: :list_problems)
231
+
232
+ # Bounded count of the sibling server-exceptions tab for the empty-state cross-link.
233
+ sibling = if groups.empty? && search.to_s.empty? && since.nil? && until_time.nil?
234
+ problems = Sentiero.store.list_problems(project: nil, limit: SIBLING_COUNT_LIMIT)
235
+ {count: problems.size, capped: problems.size >= SIBLING_COUNT_LIMIT}
236
+ end
237
+
238
+ render_page(env, Views::ErrorsIndexView.new(
239
+ source: "client",
240
+ groups: page_groups,
241
+ sibling: sibling,
242
+ sort_by: sort_by,
243
+ search: search || "",
244
+ since_param: since_param,
245
+ until_param: until_param,
246
+ page: page,
247
+ per_page: per_page,
248
+ has_next: has_next,
249
+ was_truncated: result[:was_truncated]
250
+ ))
251
+ end
252
+
253
+ # Client-side JS error detail page. Re-runs ErrorDiscovery and finds the
254
+ # group whose stable :id matches.
255
+ def handle_client_error_show(env, id)
256
+ result = Sentiero::Analytics::ErrorDiscovery.new(Sentiero.store).grouped_errors
257
+ group = result[:groups].find { |g| g[:id] == id }
258
+ return not_found if group.nil?
259
+
260
+ audit!(env, action: :view_client_error)
261
+
262
+ render_page(env, Views::ClientErrorShowView.new(group: group, was_truncated: result[:was_truncated]), csrf: false)
263
+ end
264
+
265
+ def handle_error_show(env, problem_id)
266
+ problem = Sentiero.store.get_problem(problem_id)
267
+ return not_found if problem.nil?
268
+
269
+ occurrences = Sentiero.store.get_occurrences(problem_id, limit: 50).reverse # newest first
270
+ session_ids = Sentiero.store.session_ids_for_problem(problem_id, limit: 50)
271
+
272
+ session_summaries = session_ids.map do |sid|
273
+ session = Sentiero.store.get_session(sid)
274
+ if session
275
+ first_window = (session[:windows] || []).first
276
+ ua = session.dig(:metadata, "userAgent")
277
+ {
278
+ session_id: sid,
279
+ first_event_at: session[:first_event_at],
280
+ last_event_at: session[:last_event_at],
281
+ browser: ua ? parse_browser(ua) : nil,
282
+ window_id: first_window ? first_window[:window_id] : nil
283
+ }
284
+ else
285
+ {session_id: sid, first_event_at: nil, last_event_at: nil, browser: nil, window_id: nil}
286
+ end
287
+ end
288
+
289
+ audit!(env, action: :view_problem, problem_id: problem_id)
290
+
291
+ detail = Sentiero::Analytics::ProblemDetail.new(Sentiero.store)
292
+ render_page(env, Views::ProblemShowView.new(
293
+ problem: problem,
294
+ occurrences: occurrences,
295
+ session_ids: session_ids,
296
+ session_summaries: session_summaries,
297
+ facets: detail.facets(occurrences, session_summaries),
298
+ trend: detail.trend(problem_id, occurrences)
299
+ ))
300
+ end
301
+
302
+ def handle_error_status(env, problem_id)
303
+ request = Rack::Request.new(env)
304
+ return forbidden_csrf unless valid_csrf_token?(env, request.POST["csrf_token"])
305
+
306
+ status = request.POST["status"]
307
+ return [400, {"content-type" => "text/plain"}, ["bad status"]] unless %w[open resolved ignored].include?(status)
308
+
309
+ Sentiero.store.update_problem_status(problem_id, status)
310
+ audit!(env, action: :update_problem_status, problem_id: problem_id)
311
+
312
+ redirect("#{base_path(env)}/issues/#{problem_id}", status: 303)
313
+ end
314
+ end
315
+ end
316
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "escaping"
5
+
6
+ module Sentiero
7
+ module Web
8
+ module ScriptTag
9
+ extend Escaping
10
+
11
+ def self.render(events_url:, recorder_url: nil)
12
+ config = Sentiero.configuration
13
+
14
+ recorder_url ||= default_recorder_url(events_url)
15
+
16
+ json_data = {
17
+ eventsUrl: events_url,
18
+ flushIntervalMs: config.flush_interval_ms,
19
+ flushEventThreshold: config.flush_event_threshold,
20
+ recorderOptions: config.effective_recorder_options,
21
+ crossTabSessions: config.cross_tab_sessions,
22
+ redaction: config.redaction.to_client_hash,
23
+ # Seconds on the Ruby side (matches retention_period's unit); ms on
24
+ # the wire so the client can compare directly against Date.now().
25
+ sessionIdleTimeoutMs: config.session_idle_timeout * 1000,
26
+ sessionMaxAgeMs: config.session_max_age * 1000
27
+ }
28
+ json_data[:captureMetadata] = true if config.capture_metadata
29
+ json_data[:captureErrors] = true if config.capture_errors
30
+ json_data[:trackNavigation] = true if config.track_navigation
31
+ json_data[:trackCustomEvents] = true if config.track_custom_events
32
+ json_data[:captureWebVitals] = true if config.capture_web_vitals
33
+ json_data[:captureClicks] = true if config.capture_clicks
34
+ json_data[:trackForms] = true if config.track_forms
35
+ json_data[:optOutCookieName] = config.opt_out_cookie_name if config.user_opt_out
36
+ json_data[:respectGpc] = true if config.respect_gpc
37
+
38
+ config_json = JSON.generate(json_data)
39
+
40
+ safe_json = escape_json(config_json)
41
+ escaped_recorder_url = escape_html(recorder_url.to_s)
42
+
43
+ <<~HTML
44
+ <script type="application/json" id="sentiero-config">#{safe_json}</script>
45
+ <script src="#{escaped_recorder_url}"></script>
46
+ HTML
47
+ end
48
+
49
+ def self.default_recorder_url(events_url)
50
+ base = events_url.sub(%r{/events\z}, "")
51
+ Sentiero::Web::Manifest.asset_path("recorder", base)
52
+ end
53
+
54
+ private_class_method :default_recorder_url
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "base_app"
5
+ require_relative "manifest"
6
+
7
+ module Sentiero
8
+ module Web
9
+ # Builds a single self-contained HTML document for a whole session, inlining
10
+ # the rrweb-player JS/CSS and the session's events so it replays offline with
11
+ # no server. #html returns the document, or nil when there's nothing to replay.
12
+ #
13
+ # Events are inlined as a <script type="application/json"> blob escaped via
14
+ # escape_json so a </script> in the data cannot break out of the script context.
15
+ class ShareableReplay
16
+ include Escaping
17
+
18
+ def initialize(store, session_id)
19
+ @store = store
20
+ @session_id = session_id
21
+ end
22
+
23
+ def html
24
+ session = @store.get_session(@session_id)
25
+ return nil if session.nil?
26
+
27
+ windows = session[:windows] || []
28
+ return nil if windows.empty?
29
+
30
+ events = collect_events(windows)
31
+ return nil if events.empty?
32
+
33
+ build_html(events)
34
+ end
35
+
36
+ private
37
+
38
+ # rrweb replays a flat, time-ordered stream, so all windows are merged
39
+ # and sorted by timestamp into one timeline.
40
+ def collect_events(windows)
41
+ windows
42
+ .flat_map { |window| @store.get_events(Sentiero::WindowRef.new(@session_id, window[:window_id])) }
43
+ .sort_by { |event| event["timestamp"] || 0 }
44
+ end
45
+
46
+ def build_html(events)
47
+ <<~HTML
48
+ <!DOCTYPE html>
49
+ <html lang="en">
50
+ <head>
51
+ <meta charset="utf-8">
52
+ <meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>Sentiero session #{escape_html(@session_id)}</title>
54
+ <style>#{read_asset("rrweb-player-css")}</style>
55
+ <style>body{margin:0;background:#1a1a1a}#sentiero-player{display:flex;justify-content:center;padding:16px}</style>
56
+ </head>
57
+ <body>
58
+ <div id="sentiero-player"></div>
59
+ <script type="application/json" id="sentiero-events">#{escape_json(JSON.generate(events))}</script>
60
+ <script>#{read_asset("rrweb-player")}</script>
61
+ <script>#{bootloader}</script>
62
+ </body>
63
+ </html>
64
+ HTML
65
+ end
66
+
67
+ # JSON.parse safely round-trips the escape_json transform, which only
68
+ # touched <, >, & and the JS line separators (all valid in JSON strings).
69
+ def bootloader
70
+ <<~JS
71
+ (function () {
72
+ var events = JSON.parse(document.getElementById("sentiero-events").textContent);
73
+ var Player = rrwebPlayer.default || rrwebPlayer;
74
+ new Player({
75
+ target: document.getElementById("sentiero-player"),
76
+ props: { events: events, autoPlay: false }
77
+ });
78
+ })();
79
+ JS
80
+ end
81
+
82
+ def read_asset(logical_name)
83
+ filename = Manifest.manifest.fetch(logical_name)
84
+ File.read(File.join(BaseApp::ASSETS_DIR, filename))
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,22 @@
1
+ <%
2
+ range_pairs = {}
3
+ range_pairs["since"] = since if since && !since.to_s.empty?
4
+ range_pairs["until"] = until_str if until_str && !until_str.to_s.empty?
5
+ range_qs = range_pairs.empty? ? "" : "?" + Rack::Utils.build_query(range_pairs)
6
+
7
+ nav_tabs = [
8
+ {label: "Overview", href: "#{view.base_path}/analytics#{range_qs}", active: active == :overview},
9
+ {label: "Heatmap", href: "#{view.base_path}/analytics/heatmap#{range_qs}", active: active == :heatmap},
10
+ {label: "Scroll", href: "#{view.base_path}/analytics/scroll#{range_qs}", active: active == :scroll},
11
+ {label: "Forms", href: "#{view.base_path}/analytics/forms#{range_qs}", active: active == :forms},
12
+ {label: "Pages", href: "#{view.base_path}/analytics/page#{range_qs}", active: active == :pages},
13
+ {label: "Segments", href: "#{view.base_path}/analytics/segments#{range_qs}", active: active == :segments},
14
+ {label: "Web Vitals", href: "#{view.base_path}/analytics/vitals#{range_qs}", active: active == :vitals},
15
+ {label: "Frustration", href: "#{view.base_path}/analytics/frustration#{range_qs}", active: active == :frustration},
16
+ {label: "Funnel", href: "#{view.base_path}/analytics/funnel#{range_qs}", active: active == :funnel},
17
+ {label: "Engagement", href: "#{view.base_path}/analytics/engagement#{range_qs}", active: active == :engagement},
18
+ {label: "Conversions", href: "#{view.base_path}/analytics/conversions#{range_qs}", active: active == :conversions},
19
+ {label: "Export", href: "#{view.base_path}/analytics/export#{range_qs}", active: active == :export}
20
+ ]
21
+ -%>
22
+ <%= view.render_partial("_tabs.html.erb", tabs: nav_tabs) %>
@@ -0,0 +1,18 @@
1
+ <a class="s-sidebar-brand" href="<%= view.h(view.base_path) %>/">
2
+ <svg class="s-sidebar-brand-logo" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 100 100" fill="none" role="img" aria-label="Sentiero">
3
+ <rect width="100" height="100" rx="22" fill="#ff6b5a"/>
4
+ <path d="M28 76 C42 76, 68 68, 68 56 C68 44, 30 42, 30 32 C30 24, 44 18, 58 18" stroke="white" stroke-width="3.8" stroke-linecap="round" fill="none"/>
5
+ <path d="M66 60 C70 64, 74 68, 76 72" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.28" stroke-dasharray="4 3.5"/>
6
+ <circle cx="76" cy="72" r="3" fill="white" opacity="0.18"/>
7
+ <path d="M32 36 C26 32, 22 28, 20 24" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.24" stroke-dasharray="4 3.5"/>
8
+ <circle cx="20" cy="24" r="3" fill="white" opacity="0.16"/>
9
+ <path d="M48 48 C40 52, 30 56, 22 56" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.22" stroke-dasharray="4 3.5"/>
10
+ <circle cx="22" cy="56" r="2.5" fill="white" opacity="0.15"/>
11
+ <path d="M52 44 C58 42, 66 40, 74 42" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.25" stroke-dasharray="4 3.5"/>
12
+ <circle cx="74" cy="42" r="2.5" fill="white" opacity="0.16"/>
13
+ <line x1="62" y1="13" x2="70" y2="21" stroke="white" stroke-width="3" stroke-linecap="round"/>
14
+ <line x1="70" y1="13" x2="62" y2="21" stroke="white" stroke-width="3" stroke-linecap="round"/>
15
+ <circle cx="28" cy="76" r="3" fill="white" opacity="0.35"/>
16
+ </svg>
17
+ Sentiero
18
+ </a>
@@ -0,0 +1,18 @@
1
+ <%#
2
+ Shared From/To date-input pair for the standard `since`/`until` range
3
+ params (parsed UTC; the To-day is inclusive). Embed inside a GET form that
4
+ supplies the action and any other filter fields.
5
+
6
+ Locals: since (string), until_str (string); optional from_label / to_label
7
+ when the filtered column needs calling out (e.g. "Last seen from").
8
+ -%>
9
+ <% from_label = (defined?(from_label) && from_label) ? from_label : "From" -%>
10
+ <% to_label = (defined?(to_label) && to_label) ? to_label : "To" -%>
11
+ <div class="shrink-0">
12
+ <label for="since" class="label"><%= view.h(from_label) %></label>
13
+ <input type="date" name="since" id="since" class="input" style="width:8.5rem" value="<%= view.h(since) %>">
14
+ </div>
15
+ <div class="shrink-0">
16
+ <label for="until" class="label"><%= view.h(to_label) %></label>
17
+ <input type="date" name="until" id="until" class="input" style="width:8.5rem" value="<%= view.h(until_str) %>">
18
+ </div>