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,665 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Sentiero
|
|
7
|
+
module Stores
|
|
8
|
+
class Redis < Store
|
|
9
|
+
# Loaded after the class line above establishes Redis < Store, so these
|
|
10
|
+
# files' own `class Redis` reopen doesn't hit a superclass mismatch.
|
|
11
|
+
require_relative "redis/keys"
|
|
12
|
+
require_relative "redis/lua"
|
|
13
|
+
|
|
14
|
+
# Key layout (all under @prefix): events in per-window sorted sets scored
|
|
15
|
+
# by timestamp, session metadata in hashes, window membership in sets, and
|
|
16
|
+
# a global sessions sorted set scored by updated_at. See Keys for the
|
|
17
|
+
# exact key names and Lua for the scripts run via EVAL.
|
|
18
|
+
def initialize(redis:, ttl: nil, prefix: "sentiero:", limits: nil)
|
|
19
|
+
unless defined?(::Redis)
|
|
20
|
+
raise LoadError, "The redis gem is required for Sentiero::Stores::Redis. Add `gem 'redis'` to your Gemfile."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@limits = limits
|
|
24
|
+
@redis = redis
|
|
25
|
+
@ttl = ttl
|
|
26
|
+
@prefix = prefix
|
|
27
|
+
@keys = Keys.new(prefix)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def save_events(ref, events)
|
|
31
|
+
return if events.nil? || events.empty?
|
|
32
|
+
|
|
33
|
+
validate_window_ref!(ref)
|
|
34
|
+
session_id = ref.session_id
|
|
35
|
+
window_id = ref.window_id
|
|
36
|
+
|
|
37
|
+
now = Time.now.to_f
|
|
38
|
+
events_key = @keys.events(session_id, window_id)
|
|
39
|
+
windows_key = @keys.windows(session_id)
|
|
40
|
+
session_key = @keys.session(session_id)
|
|
41
|
+
|
|
42
|
+
event_timestamps = events.filter_map { |event| event["timestamp"]&.to_f }
|
|
43
|
+
batch_min = event_timestamps.min
|
|
44
|
+
batch_max = event_timestamps.max
|
|
45
|
+
|
|
46
|
+
@redis.pipelined do |pipe|
|
|
47
|
+
events.each_with_index do |event, i|
|
|
48
|
+
score = event["timestamp"] || (now + i * 0.0001)
|
|
49
|
+
member = JSON.generate(event.merge("_seq" => "#{now}_#{i}"))
|
|
50
|
+
pipe.zadd(events_key, score, member)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
pipe.sadd(windows_key, window_id)
|
|
54
|
+
|
|
55
|
+
pipe.hsetnx(session_key, "created_at", now.to_s)
|
|
56
|
+
pipe.hset(session_key, "updated_at", now.to_s)
|
|
57
|
+
|
|
58
|
+
pipe.zadd(@keys.sessions, now, session_id)
|
|
59
|
+
|
|
60
|
+
if @ttl
|
|
61
|
+
pipe.expire(events_key, @ttl)
|
|
62
|
+
pipe.expire(windows_key, @ttl)
|
|
63
|
+
pipe.expire(session_key, @ttl)
|
|
64
|
+
pipe.expire(@keys.sessions, @ttl)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Atomic compare-and-set of first/last event timestamps, hence Lua.
|
|
69
|
+
update_event_timestamps(session_key, batch_min, batch_max)
|
|
70
|
+
|
|
71
|
+
enforce_max_events(session_id)
|
|
72
|
+
enforce_max_sessions
|
|
73
|
+
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Batched scan: three pipelined round-trips total instead of the base's
|
|
78
|
+
# get_session + get_events per window.
|
|
79
|
+
def each_session_events(limit: nil, since: nil, until_time: nil)
|
|
80
|
+
return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time) unless block_given?
|
|
81
|
+
|
|
82
|
+
cap = limit || limits.analytics_max_scan_sessions
|
|
83
|
+
min = since ? since.to_f.to_s : "-inf"
|
|
84
|
+
max = until_time ? until_time.to_f.to_s : "+inf"
|
|
85
|
+
session_ids = @redis.zrevrangebyscore(@keys.sessions, max, min, limit: [0, cap])
|
|
86
|
+
return if session_ids.empty?
|
|
87
|
+
|
|
88
|
+
meta_futures = {}
|
|
89
|
+
window_futures = {}
|
|
90
|
+
@redis.pipelined do |pipe|
|
|
91
|
+
session_ids.each do |sid|
|
|
92
|
+
meta_futures[sid] = pipe.hgetall(@keys.session(sid))
|
|
93
|
+
window_futures[sid] = pipe.smembers(@keys.windows(sid))
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
event_futures = {}
|
|
98
|
+
@redis.pipelined do |pipe|
|
|
99
|
+
session_ids.each do |sid|
|
|
100
|
+
next if meta_futures[sid].value.empty?
|
|
101
|
+
|
|
102
|
+
window_futures[sid].value.each do |wid|
|
|
103
|
+
event_futures[[sid, wid]] = pipe.zrange(@keys.events(sid, wid), 0, -1)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
session_ids.each do |sid|
|
|
109
|
+
meta = meta_futures[sid].value
|
|
110
|
+
next if meta.empty?
|
|
111
|
+
|
|
112
|
+
window_ids = window_futures[sid].value
|
|
113
|
+
next if window_ids.empty?
|
|
114
|
+
|
|
115
|
+
windows = window_ids.to_h { |wid| [wid, parse_events(event_futures[[sid, wid]].value)] }
|
|
116
|
+
summary = scan_summary(sid, meta, window_ids, windows.values.sum(&:size))
|
|
117
|
+
windows.each { |wid, events| yield summary, wid, events }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Default sort with no search needs no summaries beyond the requested page:
|
|
122
|
+
# ZREVRANGEBYSCORE with LIMIT pages the score-ordered sessions zset
|
|
123
|
+
# directly. created_at/event_count sort or search need every matching
|
|
124
|
+
# session's summary first, so those go through the general path below.
|
|
125
|
+
def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
|
|
126
|
+
min = since ? since.to_f.to_s : "-inf"
|
|
127
|
+
max = until_time ? until_time.to_f.to_s : "+inf"
|
|
128
|
+
|
|
129
|
+
if default_sort?(sort_by) && (search.nil? || search.empty?)
|
|
130
|
+
session_ids = @redis.zrevrangebyscore(@keys.sessions, max, min, limit: [offset, limit])
|
|
131
|
+
return build_session_summaries(session_ids)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
session_ids = @redis.zrevrangebyscore(@keys.sessions, max, min)
|
|
135
|
+
return [] if session_ids.empty?
|
|
136
|
+
|
|
137
|
+
summaries = build_session_summaries(session_ids)
|
|
138
|
+
|
|
139
|
+
summaries.select! { |summary| session_matches_search?(summary, search) } if search && !search.empty?
|
|
140
|
+
|
|
141
|
+
case sort_by
|
|
142
|
+
when "created_at"
|
|
143
|
+
summaries.sort_by! { |summary| -summary[:created_at] }
|
|
144
|
+
when "event_count"
|
|
145
|
+
summaries.sort_by! { |summary| -summary[:event_count] }
|
|
146
|
+
end
|
|
147
|
+
# default sort needs no work: zrevrangebyscore already returns updated_at desc
|
|
148
|
+
|
|
149
|
+
summaries.slice(offset, limit) || []
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_session(session_id)
|
|
153
|
+
validate_id!(session_id)
|
|
154
|
+
meta = @redis.hgetall(@keys.session(session_id))
|
|
155
|
+
return nil if meta.empty?
|
|
156
|
+
|
|
157
|
+
window_ids = @redis.smembers(@keys.windows(session_id))
|
|
158
|
+
return nil if window_ids.empty?
|
|
159
|
+
|
|
160
|
+
window_data = window_ids.map do |wid|
|
|
161
|
+
key = @keys.events(session_id, wid)
|
|
162
|
+
first_scores = @redis.zrangebyscore(key, "-inf", "+inf", limit: [0, 1], with_scores: true)
|
|
163
|
+
last_scores = @redis.zrevrangebyscore(key, "+inf", "-inf", limit: [0, 1], with_scores: true)
|
|
164
|
+
{
|
|
165
|
+
window_id: wid,
|
|
166
|
+
event_count: @redis.zcard(key),
|
|
167
|
+
first_event_at: first_scores.first&.last,
|
|
168
|
+
last_event_at: last_scores.first&.last
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
result = {
|
|
173
|
+
session_id: session_id,
|
|
174
|
+
windows: window_data,
|
|
175
|
+
created_at: meta["created_at"].to_f,
|
|
176
|
+
updated_at: meta["updated_at"].to_f,
|
|
177
|
+
first_event_at: meta["first_event_at"]&.to_f,
|
|
178
|
+
last_event_at: meta["last_event_at"]&.to_f
|
|
179
|
+
}
|
|
180
|
+
result[:metadata] = JSON.parse(meta["metadata"]) if meta["metadata"]
|
|
181
|
+
result
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def get_events(ref, after: nil, limit: nil)
|
|
185
|
+
validate_window_ref!(ref)
|
|
186
|
+
session_id = ref.session_id
|
|
187
|
+
window_id = ref.window_id
|
|
188
|
+
parse_events(zrange_page(@keys.events(session_id, window_id), after: after, limit: limit))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def save_metadata(session_id, metadata)
|
|
192
|
+
return unless metadata.is_a?(Hash) && !metadata.empty?
|
|
193
|
+
|
|
194
|
+
validate_id!(session_id)
|
|
195
|
+
validate_metadata!(metadata)
|
|
196
|
+
|
|
197
|
+
key = @keys.session(session_id)
|
|
198
|
+
@redis.eval(Lua::SAVE_METADATA, keys: [key], argv: [JSON.generate(metadata.transform_keys(&:to_s))])
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def delete_session(session_id)
|
|
203
|
+
validate_id!(session_id)
|
|
204
|
+
evict_session(session_id)
|
|
205
|
+
erase_session_occurrences(session_id)
|
|
206
|
+
erase_session_server_events(session_id)
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def delete_window(ref)
|
|
211
|
+
validate_window_ref!(ref)
|
|
212
|
+
session_id = ref.session_id
|
|
213
|
+
window_id = ref.window_id
|
|
214
|
+
|
|
215
|
+
now = Time.now.to_f
|
|
216
|
+
@redis.eval(
|
|
217
|
+
Lua::DELETE_WINDOW,
|
|
218
|
+
keys: [@keys.events(session_id, window_id), @keys.windows(session_id), @keys.session(session_id), @keys.sessions],
|
|
219
|
+
argv: [window_id, session_id, now.to_s]
|
|
220
|
+
)
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def save_occurrence(occurrence)
|
|
225
|
+
validate_occurrence!(occurrence)
|
|
226
|
+
fp = occurrence["fingerprint"]
|
|
227
|
+
ts = occurrence["timestamp"].to_f
|
|
228
|
+
occ_id = SecureRandom.uuid
|
|
229
|
+
stored = occurrence.merge("id" => occ_id)
|
|
230
|
+
|
|
231
|
+
created = @redis.eval(
|
|
232
|
+
Lua::PROBLEM_UPSERT,
|
|
233
|
+
keys: [@keys.problem(fp), @keys.problems, @keys.problems_project(occurrence["project"])],
|
|
234
|
+
argv: [fp, ts, occurrence["message"].to_s, JSON.generate(new_problem_attrs(occurrence, ts))]
|
|
235
|
+
)
|
|
236
|
+
enforce_max_problems if created == 1
|
|
237
|
+
|
|
238
|
+
@redis.pipelined do |pipe|
|
|
239
|
+
pipe.zadd(@keys.occurrences(fp), ts, JSON.generate(stored))
|
|
240
|
+
if occurrence["session_id"]
|
|
241
|
+
pipe.zadd(@keys.session_occurrences(occurrence["session_id"]), ts, JSON.generate(stored))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
save_metadata(occurrence["session_id"], {"has_errors" => true}) if occurrence["session_id"]
|
|
246
|
+
fp
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def list_problems(project:, limit:, offset: 0, status: nil, sort_by: nil, search: nil, since: nil, until_time: nil)
|
|
250
|
+
fps = if project.nil?
|
|
251
|
+
@redis.zrevrange(@keys.problems, 0, -1)
|
|
252
|
+
else
|
|
253
|
+
@redis.smembers(@keys.problems_project(project))
|
|
254
|
+
end
|
|
255
|
+
return [] if fps.empty?
|
|
256
|
+
|
|
257
|
+
items = fps.filter_map do |fp|
|
|
258
|
+
json = @redis.get(@keys.problem(fp))
|
|
259
|
+
json ? problem_from_strings(JSON.parse(json)) : nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
filter_and_page_problems(
|
|
263
|
+
items,
|
|
264
|
+
project: project,
|
|
265
|
+
status: status,
|
|
266
|
+
|
|
267
|
+
since: since,
|
|
268
|
+
until_time: until_time,
|
|
269
|
+
search: search,
|
|
270
|
+
|
|
271
|
+
sort_by: sort_by,
|
|
272
|
+
offset: offset,
|
|
273
|
+
limit: limit
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def get_problem(problem_id)
|
|
278
|
+
validate_id!(problem_id)
|
|
279
|
+
json = @redis.get(@keys.problem(problem_id))
|
|
280
|
+
json ? problem_from_strings(JSON.parse(json)) : nil
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def get_occurrences(problem_id, after: nil, limit: nil)
|
|
284
|
+
validate_id!(problem_id)
|
|
285
|
+
raw = zrange_page(@keys.occurrences(problem_id), after: after, limit: limit)
|
|
286
|
+
return [] if raw.nil? || raw.empty?
|
|
287
|
+
|
|
288
|
+
raw.map { |json| JSON.parse(json) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# ZCOUNT counts server-side without parsing a single member.
|
|
292
|
+
def count_occurrences(problem_id, after: nil)
|
|
293
|
+
validate_id!(problem_id)
|
|
294
|
+
min = after ? "(#{after.to_f}" : "-inf"
|
|
295
|
+
@redis.zcount(@keys.occurrences(problem_id), min, "+inf")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def update_problem_status(problem_id, status)
|
|
299
|
+
validate_id!(problem_id)
|
|
300
|
+
validate_status!(status)
|
|
301
|
+
json = @redis.get(@keys.problem(problem_id))
|
|
302
|
+
return nil unless json
|
|
303
|
+
|
|
304
|
+
problem = JSON.parse(json)
|
|
305
|
+
problem["status"] = status
|
|
306
|
+
problem["resolved_at"] = (status == "resolved") ? Time.now.to_f : nil
|
|
307
|
+
@redis.set(@keys.problem(problem_id), JSON.generate(problem))
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def save_server_event(event)
|
|
312
|
+
validate_server_event!(event)
|
|
313
|
+
ev_id = SecureRandom.uuid
|
|
314
|
+
stored = event.merge("id" => ev_id)
|
|
315
|
+
ts = event["timestamp"].to_f
|
|
316
|
+
@redis.pipelined do |pipe|
|
|
317
|
+
pipe.zadd(@keys.server_events, ts, JSON.generate(stored))
|
|
318
|
+
pipe.zadd(@keys.server_events_project(event["project"]), ts, JSON.generate(stored))
|
|
319
|
+
end
|
|
320
|
+
enforce_max_server_events
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def get_server_event(event_id)
|
|
325
|
+
validate_id!(event_id)
|
|
326
|
+
# O(n) scan of the server_events zset, bounded by max_server_events.
|
|
327
|
+
all_members = @redis.zrange(@keys.server_events, 0, -1)
|
|
328
|
+
all_members.each do |json|
|
|
329
|
+
event = JSON.parse(json)
|
|
330
|
+
return event if event["id"] == event_id
|
|
331
|
+
end
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# LIMIT on the zset alone would return fewer than `limit` rows whenever
|
|
336
|
+
# earlier events in the range don't match the filters, so page through
|
|
337
|
+
# in chunks and filter each page before counting it against the limit.
|
|
338
|
+
# Scanning is bounded by max_server_events, the same cap that already
|
|
339
|
+
# bounds how many rows can exist in this zset.
|
|
340
|
+
LIST_SERVER_EVENTS_SCAN_CHUNK = 500
|
|
341
|
+
|
|
342
|
+
def list_server_events(project:, limit:, name: nil, level: nil, session_id: nil, after: nil)
|
|
343
|
+
key = project.nil? ? @keys.server_events : @keys.server_events_project(project)
|
|
344
|
+
min = after ? "(#{after.to_f}" : "-inf"
|
|
345
|
+
max_scan = limits.max_server_events || Float::INFINITY
|
|
346
|
+
|
|
347
|
+
matches = []
|
|
348
|
+
offset = 0
|
|
349
|
+
loop do
|
|
350
|
+
batch = @redis.zrangebyscore(key, min, "+inf", limit: [offset, LIST_SERVER_EVENTS_SCAN_CHUNK])
|
|
351
|
+
break if batch.empty?
|
|
352
|
+
|
|
353
|
+
batch.each do |json|
|
|
354
|
+
event = JSON.parse(json)
|
|
355
|
+
next unless server_event_matches?(event, name: name, level: level, session_id: session_id)
|
|
356
|
+
|
|
357
|
+
matches << event
|
|
358
|
+
break if matches.size >= limit
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
offset += batch.size
|
|
362
|
+
break if matches.size >= limit
|
|
363
|
+
break if batch.size < LIST_SERVER_EVENTS_SCAN_CHUNK
|
|
364
|
+
break if offset >= max_scan
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
matches
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def occurrences_for_session(session_id, limit: nil)
|
|
371
|
+
validate_id!(session_id)
|
|
372
|
+
key = @keys.session_occurrences(session_id)
|
|
373
|
+
raw = limit ? @redis.zrange(key, 0, limit - 1) : @redis.zrange(key, 0, -1)
|
|
374
|
+
return [] if raw.nil? || raw.empty?
|
|
375
|
+
|
|
376
|
+
raw.map { |json| JSON.parse(json) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def server_events_for_session(session_id, limit: nil)
|
|
380
|
+
validate_id!(session_id)
|
|
381
|
+
raw = @redis.zrange(@keys.server_events, 0, -1)
|
|
382
|
+
return [] if raw.nil? || raw.empty?
|
|
383
|
+
|
|
384
|
+
items = raw.map { |json| JSON.parse(json) }
|
|
385
|
+
.select { |e| e["session_id"] == session_id }
|
|
386
|
+
limit ? items.first(limit) : items
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def session_ids_for_problem(problem_id, limit: nil)
|
|
390
|
+
validate_id!(problem_id)
|
|
391
|
+
raw = @redis.zrange(@keys.occurrences(problem_id), 0, -1)
|
|
392
|
+
return [] if raw.nil? || raw.empty?
|
|
393
|
+
|
|
394
|
+
latest_session_ids(raw.map { |json| JSON.parse(json) }, limit: limit)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def clear!
|
|
398
|
+
# SCAN, not KEYS: KEYS is O(N) over the whole keyspace and blocks the server.
|
|
399
|
+
@redis.scan_each(match: "#{@prefix}*") { |key| @redis.del(key) }
|
|
400
|
+
nil
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
PURGE_BATCH_SIZE = 500
|
|
404
|
+
|
|
405
|
+
# Range-query the updated_at-scored sessions zset for stale ids, paged in
|
|
406
|
+
# batches to bound memory. Orthogonal to the :ttl option: delete_session's
|
|
407
|
+
# DEL/ZREM are no-ops on already-expired keys, so they never collide.
|
|
408
|
+
def purge_older_than(seconds)
|
|
409
|
+
cutoff = Time.now.to_f - seconds
|
|
410
|
+
deleted = 0
|
|
411
|
+
|
|
412
|
+
loop do
|
|
413
|
+
batch = @redis.zrangebyscore(@keys.sessions, "-inf", "(#{cutoff}", limit: [0, PURGE_BATCH_SIZE])
|
|
414
|
+
break if batch.empty?
|
|
415
|
+
|
|
416
|
+
batch.each { |session_id| delete_session(session_id) }
|
|
417
|
+
deleted += batch.size
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
purge_error_data_older_than!(cutoff)
|
|
421
|
+
|
|
422
|
+
deleted
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
private
|
|
426
|
+
|
|
427
|
+
def default_sort?(sort_by)
|
|
428
|
+
sort_by.nil? || sort_by == "updated_at"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def server_event_matches?(event, name:, level:, session_id:)
|
|
432
|
+
(name.nil? || event["name"] == name) &&
|
|
433
|
+
(level.nil? || event["level"] == level) &&
|
|
434
|
+
(session_id.nil? || event["session_id"] == session_id)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def update_event_timestamps(session_key, batch_min, batch_max)
|
|
438
|
+
return unless batch_min || batch_max
|
|
439
|
+
|
|
440
|
+
@redis.eval(
|
|
441
|
+
Lua::UPDATE_TIMESTAMPS,
|
|
442
|
+
keys: [session_key],
|
|
443
|
+
argv: [batch_min.to_s, batch_max.to_s]
|
|
444
|
+
)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def parse_events(raw)
|
|
448
|
+
return [] if raw.nil? || raw.empty?
|
|
449
|
+
|
|
450
|
+
raw.map do |json_str|
|
|
451
|
+
event = JSON.parse(json_str)
|
|
452
|
+
event.delete("_seq")
|
|
453
|
+
event
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Cap total events for a session, draining windows oldest-first (by their
|
|
458
|
+
# earliest event score) so the newest events are kept.
|
|
459
|
+
def enforce_max_events(session_id)
|
|
460
|
+
max_events = limits.max_events_per_session
|
|
461
|
+
return unless max_events
|
|
462
|
+
|
|
463
|
+
window_ids = @redis.smembers(@keys.windows(session_id))
|
|
464
|
+
return if window_ids.empty?
|
|
465
|
+
|
|
466
|
+
cards = {}
|
|
467
|
+
firsts = {}
|
|
468
|
+
@redis.pipelined do |pipe|
|
|
469
|
+
window_ids.each do |wid|
|
|
470
|
+
key = @keys.events(session_id, wid)
|
|
471
|
+
cards[wid] = pipe.zcard(key)
|
|
472
|
+
firsts[wid] = pipe.zrange(key, 0, 0, with_scores: true)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
counts = cards.transform_values(&:value)
|
|
476
|
+
return unless counts.values.sum > max_events
|
|
477
|
+
|
|
478
|
+
excess = counts.values.sum - max_events
|
|
479
|
+
ordered = window_ids.sort_by { |wid| firsts[wid].value.first&.last || 0 }
|
|
480
|
+
emptied = []
|
|
481
|
+
@redis.pipelined do |pipe|
|
|
482
|
+
ordered.each do |wid|
|
|
483
|
+
break if excess <= 0
|
|
484
|
+
|
|
485
|
+
drop = [excess, counts[wid]].min
|
|
486
|
+
next if drop <= 0
|
|
487
|
+
|
|
488
|
+
pipe.zremrangebyrank(@keys.events(session_id, wid), 0, drop - 1)
|
|
489
|
+
emptied << wid if drop == counts[wid]
|
|
490
|
+
excess -= drop
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
@redis.srem(@keys.windows(session_id), emptied) unless emptied.empty?
|
|
494
|
+
nil
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Evict the oldest sessions (by updated_at) beyond the cap. Drops replay data
|
|
498
|
+
# only, matching the other stores; the just-saved session is newest so is
|
|
499
|
+
# never in the evicted set.
|
|
500
|
+
def enforce_max_sessions
|
|
501
|
+
max_sessions = limits.max_sessions
|
|
502
|
+
return unless max_sessions
|
|
503
|
+
|
|
504
|
+
excess = @redis.zcard(@keys.sessions) - max_sessions
|
|
505
|
+
return unless excess > 0
|
|
506
|
+
|
|
507
|
+
@redis.zrange(@keys.sessions, 0, excess - 1).each { |sid| evict_session(sid) }
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Atomic so a concurrent save_events adding a new window mid-delete can't
|
|
511
|
+
# orphan its events key (which a read-then-pipeline sequence would miss).
|
|
512
|
+
def evict_session(session_id)
|
|
513
|
+
@redis.eval(
|
|
514
|
+
Lua::EVICT_SESSION,
|
|
515
|
+
keys: [@keys.windows(session_id), @keys.session(session_id), @keys.sessions],
|
|
516
|
+
argv: [session_id, @prefix]
|
|
517
|
+
)
|
|
518
|
+
nil
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def scan_summary(session_id, meta, window_ids, event_count)
|
|
522
|
+
summary_hash(
|
|
523
|
+
session_id: session_id,
|
|
524
|
+
window_ids: window_ids,
|
|
525
|
+
event_count: event_count,
|
|
526
|
+
created_at: meta["created_at"].to_f,
|
|
527
|
+
updated_at: meta["updated_at"].to_f,
|
|
528
|
+
first_event_at: meta["first_event_at"]&.to_f,
|
|
529
|
+
last_event_at: meta["last_event_at"]&.to_f,
|
|
530
|
+
metadata: meta["metadata"] && JSON.parse(meta["metadata"])
|
|
531
|
+
)
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Pipelined batch build of session summaries for list_sessions: two
|
|
535
|
+
# round-trips total (meta+windows, then per-window zcard) instead of
|
|
536
|
+
# issuing a separate hgetall + smembers + N zcard per session in sequence.
|
|
537
|
+
# Mirrors each_session_events' own two-pipeline shape above.
|
|
538
|
+
def build_session_summaries(session_ids)
|
|
539
|
+
return [] if session_ids.empty?
|
|
540
|
+
|
|
541
|
+
meta_futures = {}
|
|
542
|
+
window_futures = {}
|
|
543
|
+
@redis.pipelined do |pipe|
|
|
544
|
+
session_ids.each do |sid|
|
|
545
|
+
meta_futures[sid] = pipe.hgetall(@keys.session(sid))
|
|
546
|
+
window_futures[sid] = pipe.smembers(@keys.windows(sid))
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
count_futures = {}
|
|
551
|
+
@redis.pipelined do |pipe|
|
|
552
|
+
session_ids.each do |sid|
|
|
553
|
+
next if meta_futures[sid].value.empty?
|
|
554
|
+
|
|
555
|
+
window_futures[sid].value.each do |wid|
|
|
556
|
+
count_futures[[sid, wid]] = pipe.zcard(@keys.events(sid, wid))
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
session_ids.filter_map do |sid|
|
|
562
|
+
meta = meta_futures[sid].value
|
|
563
|
+
next if meta.empty?
|
|
564
|
+
|
|
565
|
+
window_ids = window_futures[sid].value
|
|
566
|
+
event_count = window_ids.sum { |wid| count_futures[[sid, wid]].value }
|
|
567
|
+
scan_summary(sid, meta, window_ids, event_count)
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# One page of a timestamp-scored zset: members strictly after the `after`
|
|
572
|
+
# score (exclusive cursor) or from the start, capped at `limit`.
|
|
573
|
+
def zrange_page(key, after:, limit:)
|
|
574
|
+
if after
|
|
575
|
+
opts = limit ? {limit: [0, limit]} : {}
|
|
576
|
+
@redis.zrangebyscore(key, "(#{after.to_f}", "+inf", **opts)
|
|
577
|
+
elsif limit
|
|
578
|
+
@redis.zrange(key, 0, limit - 1)
|
|
579
|
+
else
|
|
580
|
+
@redis.zrange(key, 0, -1)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def erase_session_occurrences(session_id)
|
|
585
|
+
key = @keys.session_occurrences(session_id)
|
|
586
|
+
members = @redis.zrange(key, 0, -1)
|
|
587
|
+
|
|
588
|
+
members.each do |json|
|
|
589
|
+
occ = JSON.parse(json)
|
|
590
|
+
fp = occ["fingerprint"]
|
|
591
|
+
next unless fp
|
|
592
|
+
|
|
593
|
+
# `json` is byte-identical to the member save_occurrence stored in the
|
|
594
|
+
# per-fingerprint zset (same serialized `stored`), so ZREM matches it.
|
|
595
|
+
@redis.zrem(@keys.occurrences(fp), json)
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
@redis.del(key)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def erase_session_server_events(session_id)
|
|
602
|
+
all_members = @redis.zrange(@keys.server_events, 0, -1)
|
|
603
|
+
all_members.each do |json|
|
|
604
|
+
event = JSON.parse(json)
|
|
605
|
+
next unless event["session_id"] == session_id
|
|
606
|
+
|
|
607
|
+
@redis.zrem(@keys.server_events, json)
|
|
608
|
+
@redis.zrem(@keys.server_events_project(event["project"]), json) if event["project"]
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def purge_error_data_older_than!(cutoff)
|
|
613
|
+
@redis.zremrangebyscore(@keys.server_events, "-inf", "(#{cutoff}")
|
|
614
|
+
@redis.scan_each(match: "#{@prefix}server_events:project:*") do |key|
|
|
615
|
+
@redis.zremrangebyscore(key, "-inf", "(#{cutoff}")
|
|
616
|
+
end
|
|
617
|
+
@redis.scan_each(match: "#{@prefix}occurrences:*") do |key|
|
|
618
|
+
@redis.zremrangebyscore(key, "-inf", "(#{cutoff}")
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
stale_fps = @redis.zrangebyscore(@keys.problems, "-inf", "(#{cutoff}")
|
|
622
|
+
stale_fps.each { |fp| delete_problem_records!(fp) }
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def delete_problem_records!(fp)
|
|
626
|
+
json = @redis.get(@keys.problem(fp))
|
|
627
|
+
if json
|
|
628
|
+
project = JSON.parse(json)["project"]
|
|
629
|
+
@redis.srem(@keys.problems_project(project), fp) if project
|
|
630
|
+
end
|
|
631
|
+
@redis.del(@keys.problem(fp))
|
|
632
|
+
@redis.del(@keys.occurrences(fp))
|
|
633
|
+
@redis.zrem(@keys.problems, fp)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def enforce_max_problems
|
|
637
|
+
max = limits.max_problems
|
|
638
|
+
return unless max
|
|
639
|
+
|
|
640
|
+
total = @redis.zcard(@keys.problems)
|
|
641
|
+
return unless total > max
|
|
642
|
+
|
|
643
|
+
excess = total - max
|
|
644
|
+
fps = @redis.zrange(@keys.problems, 0, excess - 1)
|
|
645
|
+
fps.each { |fp| delete_problem_records!(fp) }
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def enforce_max_server_events
|
|
649
|
+
max = limits.max_server_events
|
|
650
|
+
return unless max
|
|
651
|
+
|
|
652
|
+
total = @redis.zcard(@keys.server_events)
|
|
653
|
+
return unless total > max
|
|
654
|
+
|
|
655
|
+
excess = total - max
|
|
656
|
+
oldest = @redis.zrange(@keys.server_events, 0, excess - 1)
|
|
657
|
+
oldest.each do |json|
|
|
658
|
+
event = JSON.parse(json)
|
|
659
|
+
@redis.zrem(@keys.server_events_project(event["project"]), json) if event["project"]
|
|
660
|
+
@redis.zrem(@keys.server_events, json)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
end
|