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