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,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