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,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent-ruby"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Sentiero
|
|
7
|
+
module Stores
|
|
8
|
+
class Memory < Store
|
|
9
|
+
SessionMeta = Data.define(:created_at, :updated_at, :first_event_at, :last_event_at, :session_metadata)
|
|
10
|
+
SessionEntry = Data.define(:meta, :windows)
|
|
11
|
+
|
|
12
|
+
def initialize(limits: nil)
|
|
13
|
+
@limits = limits
|
|
14
|
+
@sessions = Concurrent::Map.new
|
|
15
|
+
@problems = Concurrent::Map.new # fingerprint => problem Hash (symbol-keyed)
|
|
16
|
+
@occurrences = Concurrent::Map.new # fingerprint => Concurrent::Array of occurrence Hashes
|
|
17
|
+
@server_events = Concurrent::Array.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def save_events(ref, events)
|
|
21
|
+
validate_window_ref!(ref)
|
|
22
|
+
session_id = ref.session_id
|
|
23
|
+
window_id = ref.window_id
|
|
24
|
+
|
|
25
|
+
return if events.nil? || events.empty?
|
|
26
|
+
|
|
27
|
+
now = Time.now.to_f
|
|
28
|
+
|
|
29
|
+
event_timestamps = events.filter_map { |event| event["timestamp"]&.to_f }
|
|
30
|
+
batch_min = event_timestamps.min
|
|
31
|
+
batch_max = event_timestamps.max
|
|
32
|
+
|
|
33
|
+
@sessions.compute(session_id) do |existing|
|
|
34
|
+
entry = if existing
|
|
35
|
+
event_list = existing.windows.compute_if_absent(window_id) { Concurrent::Array.new }
|
|
36
|
+
event_list.concat(events)
|
|
37
|
+
|
|
38
|
+
new_first = batch_min ? [existing.meta.first_event_at, batch_min].compact.min : existing.meta.first_event_at
|
|
39
|
+
new_last = batch_max ? [existing.meta.last_event_at, batch_max].compact.max : existing.meta.last_event_at
|
|
40
|
+
|
|
41
|
+
SessionEntry.new(
|
|
42
|
+
meta: existing.meta.with(updated_at: now, first_event_at: new_first, last_event_at: new_last),
|
|
43
|
+
windows: existing.windows
|
|
44
|
+
)
|
|
45
|
+
else
|
|
46
|
+
windows = Concurrent::Map.new
|
|
47
|
+
event_list = Concurrent::Array.new
|
|
48
|
+
event_list.concat(events)
|
|
49
|
+
windows[window_id] = event_list
|
|
50
|
+
|
|
51
|
+
SessionEntry.new(
|
|
52
|
+
meta: SessionMeta.new(created_at: now, updated_at: now, first_event_at: batch_min, last_event_at: batch_max, session_metadata: nil),
|
|
53
|
+
windows: windows
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
trim_events!(entry)
|
|
58
|
+
|
|
59
|
+
entry
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
enforce_max_sessions
|
|
63
|
+
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
|
|
68
|
+
pairs = @sessions.each_pair.to_a
|
|
69
|
+
pairs = filter_sessions(pairs, since: since, until_time: until_time, search: search)
|
|
70
|
+
pairs = sort_sessions(pairs, sort_by)
|
|
71
|
+
page = pairs.slice(offset, limit) || []
|
|
72
|
+
page.map { |sid, entry| session_summary(sid, entry) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def get_session(session_id)
|
|
76
|
+
validate_id!(session_id)
|
|
77
|
+
entry = @sessions[session_id]
|
|
78
|
+
return nil unless entry
|
|
79
|
+
|
|
80
|
+
window_data = entry.windows.each_pair.map { |wid, events|
|
|
81
|
+
timestamps = events.filter_map { |event| event[:timestamp] || event["timestamp"] }
|
|
82
|
+
window = {window_id: wid, event_count: events.size}
|
|
83
|
+
window[:first_event_at] = timestamps.min if timestamps.any?
|
|
84
|
+
window[:last_event_at] = timestamps.max if timestamps.any?
|
|
85
|
+
window
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
result = {
|
|
89
|
+
session_id: session_id,
|
|
90
|
+
windows: window_data,
|
|
91
|
+
created_at: entry.meta.created_at,
|
|
92
|
+
updated_at: entry.meta.updated_at,
|
|
93
|
+
first_event_at: entry.meta.first_event_at,
|
|
94
|
+
last_event_at: entry.meta.last_event_at
|
|
95
|
+
}
|
|
96
|
+
result[:metadata] = entry.meta.session_metadata if entry.meta.session_metadata
|
|
97
|
+
result
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def get_events(ref, after: nil, limit: nil)
|
|
101
|
+
validate_window_ref!(ref)
|
|
102
|
+
session_id = ref.session_id
|
|
103
|
+
window_id = ref.window_id
|
|
104
|
+
entry = @sessions[session_id]
|
|
105
|
+
return [] unless entry
|
|
106
|
+
|
|
107
|
+
events = entry.windows[window_id]
|
|
108
|
+
return [] unless events
|
|
109
|
+
|
|
110
|
+
result = events.to_a.sort_by { |event| event["timestamp"].to_f }
|
|
111
|
+
|
|
112
|
+
if after
|
|
113
|
+
idx = result.index { |event| event["timestamp"].to_f > after.to_f }
|
|
114
|
+
result = idx ? result[idx..] : []
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
limit ? result.first(limit) : result
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def save_metadata(session_id, metadata)
|
|
121
|
+
validate_id!(session_id)
|
|
122
|
+
return unless metadata.is_a?(Hash) && !metadata.empty?
|
|
123
|
+
|
|
124
|
+
validate_metadata!(metadata)
|
|
125
|
+
|
|
126
|
+
@sessions.compute(session_id) do |existing|
|
|
127
|
+
next existing unless existing
|
|
128
|
+
merged = (existing.meta.session_metadata || {}).merge(metadata)
|
|
129
|
+
SessionEntry.new(meta: existing.meta.with(session_metadata: merged), windows: existing.windows)
|
|
130
|
+
end
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def delete_session(session_id)
|
|
135
|
+
validate_id!(session_id)
|
|
136
|
+
@sessions.delete(session_id)
|
|
137
|
+
|
|
138
|
+
@occurrences.each_pair do |fp, list|
|
|
139
|
+
list.reject! { |occ| occ["session_id"] == session_id }
|
|
140
|
+
end
|
|
141
|
+
@server_events.reject! { |event| event["session_id"] == session_id }
|
|
142
|
+
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def delete_window(ref)
|
|
147
|
+
validate_window_ref!(ref)
|
|
148
|
+
session_id = ref.session_id
|
|
149
|
+
window_id = ref.window_id
|
|
150
|
+
@sessions.compute(session_id) do |existing|
|
|
151
|
+
next nil unless existing
|
|
152
|
+
|
|
153
|
+
existing.windows.delete(window_id)
|
|
154
|
+
|
|
155
|
+
if existing.windows.empty?
|
|
156
|
+
nil
|
|
157
|
+
else
|
|
158
|
+
existing
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def save_occurrence(occurrence)
|
|
165
|
+
validate_occurrence!(occurrence)
|
|
166
|
+
fp = occurrence["fingerprint"]
|
|
167
|
+
ts = occurrence["timestamp"].to_f
|
|
168
|
+
|
|
169
|
+
stored = occurrence.merge("id" => SecureRandom.uuid)
|
|
170
|
+
@occurrences.compute_if_absent(fp) { Concurrent::Array.new } << stored
|
|
171
|
+
|
|
172
|
+
@problems.compute(fp) do |existing|
|
|
173
|
+
existing ? touched_problem_attrs(existing, occurrence, ts) : new_problem_attrs(occurrence, ts)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
enforce_max_problems
|
|
177
|
+
save_metadata(occurrence["session_id"], {"has_errors" => true}) if occurrence["session_id"]
|
|
178
|
+
fp
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def list_problems(project:, limit:, offset: 0, status: nil, sort_by: nil, search: nil, since: nil, until_time: nil)
|
|
182
|
+
filter_and_page_problems(@problems.values, project: project, status: status,
|
|
183
|
+
since: since, until_time: until_time, search: search,
|
|
184
|
+
sort_by: sort_by, offset: offset, limit: limit)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def get_problem(problem_id)
|
|
188
|
+
validate_id!(problem_id)
|
|
189
|
+
@problems[problem_id]&.dup
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def get_occurrences(problem_id, after: nil, limit: nil)
|
|
193
|
+
validate_id!(problem_id)
|
|
194
|
+
list = @occurrences[problem_id]
|
|
195
|
+
return [] unless list
|
|
196
|
+
|
|
197
|
+
result = list.to_a.sort_by { |occ| occ["timestamp"].to_f }
|
|
198
|
+
result = result.select { |occ| occ["timestamp"].to_f > after.to_f } if after
|
|
199
|
+
limit ? result.first(limit) : result
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Override: count without sorting or duplicating the rows.
|
|
203
|
+
def count_occurrences(problem_id, after: nil)
|
|
204
|
+
validate_id!(problem_id)
|
|
205
|
+
list = @occurrences[problem_id]
|
|
206
|
+
return 0 unless list
|
|
207
|
+
return list.size unless after
|
|
208
|
+
|
|
209
|
+
after_f = after.to_f
|
|
210
|
+
list.to_a.count { |occ| occ["timestamp"].to_f > after_f }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def update_problem_status(problem_id, status)
|
|
214
|
+
validate_id!(problem_id)
|
|
215
|
+
validate_status!(status)
|
|
216
|
+
@problems.compute(problem_id) do |existing|
|
|
217
|
+
next nil unless existing
|
|
218
|
+
|
|
219
|
+
existing.merge(
|
|
220
|
+
status: status,
|
|
221
|
+
resolved_at: (status == "resolved") ? Time.now.to_f : nil
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def save_server_event(event)
|
|
228
|
+
validate_server_event!(event)
|
|
229
|
+
@server_events << event.merge("id" => SecureRandom.uuid)
|
|
230
|
+
enforce_max_server_events
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def get_server_event(event_id)
|
|
235
|
+
validate_id!(event_id)
|
|
236
|
+
@server_events.find { |e| e["id"] == event_id }&.dup
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def list_server_events(project:, limit:, name: nil, level: nil, session_id: nil, after: nil)
|
|
240
|
+
filter_server_events(@server_events.to_a, project: project, name: name, level: level, session_id: session_id, after: after, limit: limit)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def occurrences_for_session(session_id, limit: nil)
|
|
244
|
+
validate_id!(session_id)
|
|
245
|
+
rows_for_session(@occurrences.values.flat_map(&:to_a), session_id, limit: limit)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def server_events_for_session(session_id, limit: nil)
|
|
249
|
+
validate_id!(session_id)
|
|
250
|
+
rows_for_session(@server_events.to_a, session_id, limit: limit)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def session_ids_for_problem(problem_id, limit: nil)
|
|
254
|
+
validate_id!(problem_id)
|
|
255
|
+
list = @occurrences[problem_id]
|
|
256
|
+
return [] unless list
|
|
257
|
+
|
|
258
|
+
latest_session_ids(list.to_a, limit: limit)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def purge_older_than(seconds)
|
|
262
|
+
deleted = super
|
|
263
|
+
purge_error_collections!(@problems, @occurrences, @server_events, Time.now.to_f - seconds)
|
|
264
|
+
deleted
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def clear!
|
|
268
|
+
@sessions.clear
|
|
269
|
+
@problems.clear
|
|
270
|
+
@occurrences.clear
|
|
271
|
+
@server_events.clear
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
private
|
|
276
|
+
|
|
277
|
+
def filter_sessions(pairs, since:, until_time:, search:)
|
|
278
|
+
if since
|
|
279
|
+
since_f = since.to_f
|
|
280
|
+
pairs = pairs.select { |_sid, entry| entry.meta.updated_at >= since_f }
|
|
281
|
+
end
|
|
282
|
+
if until_time
|
|
283
|
+
until_f = until_time.to_f
|
|
284
|
+
pairs = pairs.select { |_sid, entry| entry.meta.updated_at <= until_f }
|
|
285
|
+
end
|
|
286
|
+
if search && !search.empty?
|
|
287
|
+
search_down = search.downcase
|
|
288
|
+
pairs = pairs.select { |sid, entry|
|
|
289
|
+
sid.downcase.include?(search_down) ||
|
|
290
|
+
entry.meta.session_metadata&.values&.any? { |value| value.to_s.downcase.include?(search_down) }
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
pairs
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def sort_sessions(pairs, sort_by)
|
|
297
|
+
case sort_by
|
|
298
|
+
when "created_at"
|
|
299
|
+
pairs.sort_by { |_sid, entry| -entry.meta.created_at }
|
|
300
|
+
when "event_count"
|
|
301
|
+
pairs.sort_by { |_sid, entry| -entry.windows.values.sum(&:size) }
|
|
302
|
+
else
|
|
303
|
+
pairs.sort_by { |_sid, entry| -entry.meta.updated_at }
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def session_summary(sid, entry)
|
|
308
|
+
summary_hash(
|
|
309
|
+
session_id: sid,
|
|
310
|
+
window_ids: entry.windows.keys,
|
|
311
|
+
event_count: entry.windows.values.sum(&:size),
|
|
312
|
+
created_at: entry.meta.created_at,
|
|
313
|
+
updated_at: entry.meta.updated_at,
|
|
314
|
+
first_event_at: entry.meta.first_event_at,
|
|
315
|
+
last_event_at: entry.meta.last_event_at,
|
|
316
|
+
metadata: entry.meta.session_metadata
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def trim_events!(entry)
|
|
321
|
+
max_events = limits.max_events_per_session
|
|
322
|
+
return unless max_events
|
|
323
|
+
|
|
324
|
+
total = entry.windows.values.sum(&:size)
|
|
325
|
+
return unless total > max_events
|
|
326
|
+
|
|
327
|
+
excess = total - max_events
|
|
328
|
+
sorted_windows = entry.windows.each_pair.sort_by { |_wid, events|
|
|
329
|
+
events.first&.fetch("timestamp", 0) || 0
|
|
330
|
+
}
|
|
331
|
+
sorted_windows.each do |_wid, events|
|
|
332
|
+
break if excess <= 0
|
|
333
|
+
|
|
334
|
+
drop = [excess, events.size].min
|
|
335
|
+
events.shift(drop)
|
|
336
|
+
excess -= drop
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def enforce_max_sessions
|
|
341
|
+
max_sessions = limits.max_sessions
|
|
342
|
+
return unless max_sessions && @sessions.size > max_sessions
|
|
343
|
+
|
|
344
|
+
sorted = @sessions.each_pair.sort_by { |_sid, entry| entry.meta.updated_at }
|
|
345
|
+
to_evict = @sessions.size - max_sessions
|
|
346
|
+
sorted.first(to_evict).each { |sid, _entry| @sessions.delete(sid) }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def enforce_max_problems
|
|
350
|
+
evict_oldest_problems!(@problems, @occurrences, limits.max_problems)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def enforce_max_server_events
|
|
354
|
+
max = limits.max_server_events
|
|
355
|
+
return unless max && @server_events.size > max
|
|
356
|
+
|
|
357
|
+
excess = @server_events.size - max
|
|
358
|
+
@server_events.shift(excess)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
module Stores
|
|
5
|
+
class Redis
|
|
6
|
+
# Redis key layout for one Stores::Redis instance, namespaced under a
|
|
7
|
+
# single prefix. See Stores::Redis for what each key holds.
|
|
8
|
+
class Keys
|
|
9
|
+
def initialize(prefix)
|
|
10
|
+
@prefix = prefix
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def events(session_id, window_id)
|
|
14
|
+
"#{@prefix}events:#{session_id}:#{window_id}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def session(session_id)
|
|
18
|
+
"#{@prefix}session:#{session_id}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def windows(session_id)
|
|
22
|
+
"#{@prefix}windows:#{session_id}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def sessions
|
|
26
|
+
@sessions ||= "#{@prefix}sessions"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def problem(fingerprint)
|
|
30
|
+
"#{@prefix}problem:#{fingerprint}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def problems
|
|
34
|
+
@problems ||= "#{@prefix}problems"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def problems_project(project)
|
|
38
|
+
"#{@prefix}problems:project:#{project}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def occurrences(fingerprint)
|
|
42
|
+
"#{@prefix}occurrences:#{fingerprint}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def session_occurrences(session_id)
|
|
46
|
+
"#{@prefix}session_occurrences:#{session_id}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def server_events
|
|
50
|
+
@server_events ||= "#{@prefix}server_events"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def server_events_project(project)
|
|
54
|
+
"#{@prefix}server_events:project:#{project}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sentiero
|
|
4
|
+
module Stores
|
|
5
|
+
class Redis
|
|
6
|
+
# EVAL scripts for operations that need atomicity a MULTI/EXEC pipeline
|
|
7
|
+
# can't give a read-modify-write (or delete-if-empty) across keys.
|
|
8
|
+
module Lua
|
|
9
|
+
SAVE_METADATA = <<~LUA
|
|
10
|
+
local key = KEYS[1]
|
|
11
|
+
if redis.call("EXISTS", key) == 0 then
|
|
12
|
+
return 0
|
|
13
|
+
end
|
|
14
|
+
local existing_json = redis.call("HGET", key, "metadata")
|
|
15
|
+
local existing = existing_json and cjson.decode(existing_json) or {}
|
|
16
|
+
local new_data = cjson.decode(ARGV[1])
|
|
17
|
+
for k, v in pairs(new_data) do
|
|
18
|
+
existing[k] = v
|
|
19
|
+
end
|
|
20
|
+
redis.call("HSET", key, "metadata", cjson.encode(existing))
|
|
21
|
+
return 1
|
|
22
|
+
LUA
|
|
23
|
+
|
|
24
|
+
# Atomic so a concurrent save_events adding a new window mid-delete can't
|
|
25
|
+
# orphan its events key (which a read-then-pipeline sequence would miss).
|
|
26
|
+
EVICT_SESSION = <<~LUA
|
|
27
|
+
local windows_key = KEYS[1]
|
|
28
|
+
local session_key = KEYS[2]
|
|
29
|
+
local sessions_key = KEYS[3]
|
|
30
|
+
local session_id = ARGV[1]
|
|
31
|
+
local prefix = ARGV[2]
|
|
32
|
+
|
|
33
|
+
for _, window_id in ipairs(redis.call("SMEMBERS", windows_key)) do
|
|
34
|
+
redis.call("DEL", prefix .. "events:" .. session_id .. ":" .. window_id)
|
|
35
|
+
end
|
|
36
|
+
redis.call("DEL", windows_key)
|
|
37
|
+
redis.call("DEL", session_key)
|
|
38
|
+
redis.call("ZREM", sessions_key, session_id)
|
|
39
|
+
LUA
|
|
40
|
+
|
|
41
|
+
DELETE_WINDOW = <<~LUA
|
|
42
|
+
local events_key = KEYS[1]
|
|
43
|
+
local windows_key = KEYS[2]
|
|
44
|
+
local session_key = KEYS[3]
|
|
45
|
+
local sessions_key = KEYS[4]
|
|
46
|
+
local window_id = ARGV[1]
|
|
47
|
+
local session_id = ARGV[2]
|
|
48
|
+
local now = ARGV[3]
|
|
49
|
+
|
|
50
|
+
redis.call("DEL", events_key)
|
|
51
|
+
redis.call("SREM", windows_key, window_id)
|
|
52
|
+
|
|
53
|
+
local remaining = redis.call("SCARD", windows_key)
|
|
54
|
+
if remaining == 0 then
|
|
55
|
+
redis.call("DEL", session_key)
|
|
56
|
+
redis.call("DEL", windows_key)
|
|
57
|
+
redis.call("ZREM", sessions_key, session_id)
|
|
58
|
+
else
|
|
59
|
+
redis.call("HSET", session_key, "updated_at", now)
|
|
60
|
+
redis.call("ZADD", sessions_key, tonumber(now), session_id)
|
|
61
|
+
end
|
|
62
|
+
return remaining
|
|
63
|
+
LUA
|
|
64
|
+
|
|
65
|
+
# Atomic problem upsert: a read-then-write in Ruby lost concurrent
|
|
66
|
+
# count/last_seen updates for the same fingerprint. The count+1 / min
|
|
67
|
+
# first_seen / max last_seen / reopen-if-resolved logic mirrors
|
|
68
|
+
# ErrorStore#touched_problem_attrs (kept in Ruby for Memory/File); the new
|
|
69
|
+
# record is built in Ruby (new_problem_attrs) and passed in pre-serialized.
|
|
70
|
+
# Returns 1 when it created a new problem, 0 when it updated an existing one.
|
|
71
|
+
PROBLEM_UPSERT = <<~LUA
|
|
72
|
+
local prob_key, problems_key, proj_key = KEYS[1], KEYS[2], KEYS[3]
|
|
73
|
+
local fp, ts, message, new_json = ARGV[1], tonumber(ARGV[2]), ARGV[3], ARGV[4]
|
|
74
|
+
|
|
75
|
+
local existing_json = redis.call("GET", prob_key)
|
|
76
|
+
if existing_json then
|
|
77
|
+
local p = cjson.decode(existing_json)
|
|
78
|
+
p.count = (p.count or 0) + 1
|
|
79
|
+
if ts < p.first_seen then p.first_seen = ts end
|
|
80
|
+
if ts > p.last_seen then p.last_seen = ts end
|
|
81
|
+
p.message = message
|
|
82
|
+
if p.status == "resolved" then
|
|
83
|
+
p.status = "open"
|
|
84
|
+
p.resolved_at = nil
|
|
85
|
+
end
|
|
86
|
+
redis.call("SET", prob_key, cjson.encode(p))
|
|
87
|
+
redis.call("ZADD", problems_key, p.last_seen, fp)
|
|
88
|
+
return 0
|
|
89
|
+
else
|
|
90
|
+
redis.call("SET", prob_key, new_json)
|
|
91
|
+
redis.call("ZADD", problems_key, ts, fp)
|
|
92
|
+
redis.call("SADD", proj_key, fp)
|
|
93
|
+
return 1
|
|
94
|
+
end
|
|
95
|
+
LUA
|
|
96
|
+
|
|
97
|
+
UPDATE_TIMESTAMPS = <<~LUA
|
|
98
|
+
local key = KEYS[1]
|
|
99
|
+
local batch_min = ARGV[1]
|
|
100
|
+
local batch_max = ARGV[2]
|
|
101
|
+
|
|
102
|
+
if batch_min ~= "" then
|
|
103
|
+
local current = redis.call("HGET", key, "first_event_at")
|
|
104
|
+
if not current or tonumber(batch_min) < tonumber(current) then
|
|
105
|
+
redis.call("HSET", key, "first_event_at", batch_min)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if batch_max ~= "" then
|
|
110
|
+
local current = redis.call("HGET", key, "last_event_at")
|
|
111
|
+
if not current or tonumber(batch_max) > tonumber(current) then
|
|
112
|
+
redis.call("HSET", key, "last_event_at", batch_max)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
LUA
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|