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,566 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module Sentiero
8
+ module Stores
9
+ class File < Store
10
+ # File-based store, single-process dev/test only. Each session is a
11
+ # directory: meta.json (timestamps, metadata) + {window_id}.jsonl
12
+ # (one JSON event per line).
13
+
14
+ def initialize(path:, limits: nil)
15
+ @limits = limits
16
+ @root = ::File.expand_path(path)
17
+ FileUtils.mkdir_p(@root)
18
+ end
19
+
20
+ def save_events(ref, events)
21
+ return if events.nil? || events.empty?
22
+
23
+ validate_window_ref!(ref)
24
+ session_id = ref.session_id
25
+ window_id = ref.window_id
26
+
27
+ now = Time.now.to_f
28
+ session_dir = session_path(session_id)
29
+ FileUtils.mkdir_p(session_dir)
30
+
31
+ event_timestamps = events.filter_map { |event| event["timestamp"]&.to_f }
32
+ batch_min = event_timestamps.min
33
+ batch_max = event_timestamps.max
34
+
35
+ ::File.open(window_path(session_id, window_id), "a") do |file|
36
+ file.flock(::File::LOCK_EX)
37
+ file.write(serialize_events(events))
38
+ end
39
+
40
+ update_meta(session_id) do |meta|
41
+ if meta
42
+ meta["updated_at"] = now
43
+ meta["first_event_at"] = [meta["first_event_at"], batch_min].compact.min if batch_min
44
+ meta["last_event_at"] = [meta["last_event_at"], batch_max].compact.max if batch_max
45
+ else
46
+ meta = {
47
+ "created_at" => now,
48
+ "updated_at" => now,
49
+ "first_event_at" => batch_min,
50
+ "last_event_at" => batch_max,
51
+ "metadata" => nil
52
+ }
53
+ end
54
+ meta
55
+ end
56
+
57
+ enforce_max_events(session_id)
58
+ enforce_max_sessions
59
+
60
+ nil
61
+ end
62
+
63
+ def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
64
+ session_ids = list_session_ids
65
+ return [] if session_ids.empty?
66
+
67
+ summaries = session_ids.filter_map { |sid| build_summary(sid) }
68
+
69
+ if since
70
+ since_f = since.to_f
71
+ summaries = summaries.select { |summary| summary[:updated_at] >= since_f }
72
+ end
73
+ if until_time
74
+ until_f = until_time.to_f
75
+ summaries = summaries.select { |summary| summary[:updated_at] <= until_f }
76
+ end
77
+ if search && !search.empty?
78
+ summaries = summaries.select { |summary| session_matches_search?(summary, search) }
79
+ end
80
+
81
+ case sort_by
82
+ when "created_at"
83
+ summaries.sort_by! { |summary| -summary[:created_at] }
84
+ when "event_count"
85
+ summaries.sort_by! { |summary| -summary[:event_count] }
86
+ else
87
+ summaries.sort_by! { |summary| -summary[:updated_at] }
88
+ end
89
+
90
+ summaries.slice(offset, limit) || []
91
+ end
92
+
93
+ def get_session(session_id)
94
+ validate_id!(session_id)
95
+ meta = read_meta(session_id)
96
+ return nil unless meta
97
+
98
+ window_ids = list_window_ids(session_id)
99
+ return nil if window_ids.empty?
100
+
101
+ window_data = window_ids.map { |wid|
102
+ events = read_window_events(session_id, wid)
103
+ timestamps = events.filter_map { |event| event["timestamp"]&.to_f }
104
+ window = {window_id: wid, event_count: events.size}
105
+ if timestamps.any?
106
+ window[:first_event_at] = timestamps.min
107
+ window[:last_event_at] = timestamps.max
108
+ end
109
+ window
110
+ }
111
+
112
+ {session_id: session_id, windows: window_data}.merge(meta_fields(meta))
113
+ end
114
+
115
+ def get_events(ref, after: nil, limit: nil)
116
+ validate_window_ref!(ref)
117
+ session_id = ref.session_id
118
+ window_id = ref.window_id
119
+ events = read_window_events(session_id, window_id).sort_by { |event| event["timestamp"].to_f }
120
+
121
+ if after
122
+ idx = events.index { |event| event["timestamp"].to_f > after.to_f }
123
+ events = idx ? events[idx..] : []
124
+ end
125
+
126
+ limit ? events.first(limit) : events
127
+ end
128
+
129
+ def save_metadata(session_id, metadata)
130
+ return unless metadata.is_a?(Hash) && !metadata.empty?
131
+ validate_id!(session_id)
132
+ validate_metadata!(metadata)
133
+
134
+ update_meta(session_id) do |meta|
135
+ next nil unless meta
136
+
137
+ existing = meta["metadata"] || {}
138
+ meta["metadata"] = existing.merge(metadata.transform_keys(&:to_s))
139
+ meta
140
+ end
141
+ nil
142
+ end
143
+
144
+ def delete_session(session_id)
145
+ validate_id!(session_id)
146
+ dir = session_path(session_id)
147
+ FileUtils.rm_rf(dir) if ::File.directory?(dir)
148
+
149
+ update_error_data do |_problems, occurrences, server_events|
150
+ occurrences.each_value { |list| list.reject! { |occ| occ["session_id"] == session_id } }
151
+ server_events.reject! { |event| event["session_id"] == session_id }
152
+ end
153
+
154
+ nil
155
+ end
156
+
157
+ def delete_window(ref)
158
+ validate_window_ref!(ref)
159
+ session_id = ref.session_id
160
+ window_id = ref.window_id
161
+ FileUtils.rm_f(window_path(session_id, window_id))
162
+
163
+ # Only the session directory (meta.json + window files) is replay
164
+ # data; error data lives in the shared root-level JSON files, so
165
+ # this must not go through delete_session (which also erases those).
166
+ remaining = list_window_ids(session_id)
167
+ FileUtils.rm_rf(session_path(session_id)) if remaining.empty?
168
+ nil
169
+ end
170
+
171
+ def save_occurrence(occurrence)
172
+ validate_occurrence!(occurrence)
173
+ fp = occurrence["fingerprint"]
174
+ ts = occurrence["timestamp"].to_f
175
+ occ_id = SecureRandom.uuid
176
+ stored = occurrence.merge("id" => occ_id)
177
+
178
+ update_error_data do |problems, occurrences, _server_events|
179
+ existing = problems[fp]
180
+ problems[fp] = existing ? touched_problem_attrs(existing, occurrence, ts) : new_problem_attrs(occurrence, ts)
181
+ occurrences[fp] ||= []
182
+ occurrences[fp] << stored
183
+ evict_oldest_problems!(problems, occurrences, limits.max_problems)
184
+ end
185
+ save_metadata(occurrence["session_id"], {"has_errors" => true}) if occurrence["session_id"]
186
+ fp
187
+ end
188
+
189
+ def list_problems(project:, limit:, offset: 0, status: nil, sort_by: nil, search: nil, since: nil, until_time: nil)
190
+ problems, _occurrences, _server_events = read_error_data
191
+ filter_and_page_problems(
192
+ problems.values,
193
+ project: project,
194
+ status: status,
195
+ since: since,
196
+ until_time: until_time,
197
+ search: search,
198
+ sort_by: sort_by,
199
+ offset: offset,
200
+ limit: limit
201
+ )
202
+ end
203
+
204
+ def get_problem(problem_id)
205
+ validate_id!(problem_id)
206
+ problems, _occurrences, _server_events = read_error_data
207
+ problems[problem_id]&.dup
208
+ end
209
+
210
+ def get_occurrences(problem_id, after: nil, limit: nil)
211
+ validate_id!(problem_id)
212
+ _problems, occurrences, _server_events = read_error_data
213
+ list = occurrences[problem_id] || []
214
+ result = list.sort_by { |occ| occ["timestamp"].to_f }
215
+ result = result.select { |occ| occ["timestamp"].to_f > after.to_f } if after
216
+ limit ? result.first(limit) : result
217
+ end
218
+
219
+ def count_occurrences(problem_id, after: nil)
220
+ validate_id!(problem_id)
221
+ _problems, occurrences, _server_events = read_error_data
222
+ list = occurrences[problem_id] || []
223
+ return list.size unless after
224
+ after_f = after.to_f
225
+ list.count { |occ| occ["timestamp"].to_f > after_f }
226
+ end
227
+
228
+ def update_problem_status(problem_id, status)
229
+ validate_id!(problem_id)
230
+ validate_status!(status)
231
+ update_error_data do |problems, _occurrences, _server_events|
232
+ existing = problems[problem_id]
233
+ next unless existing
234
+
235
+ problems[problem_id] = existing.merge(
236
+ status: status,
237
+ resolved_at: (status == "resolved") ? Time.now.to_f : nil
238
+ )
239
+ end
240
+ nil
241
+ end
242
+
243
+ def save_server_event(event)
244
+ validate_server_event!(event)
245
+ stored = event.merge("id" => SecureRandom.uuid)
246
+ update_error_data do |_problems, _occurrences, server_events|
247
+ server_events << stored
248
+ enforce_max_server_events!(server_events)
249
+ end
250
+ nil
251
+ end
252
+
253
+ def get_server_event(event_id)
254
+ validate_id!(event_id)
255
+ _problems, _occurrences, server_events = read_error_data
256
+ server_events.find { |e| e["id"] == event_id }
257
+ end
258
+
259
+ def list_server_events(project:, limit:, name: nil, level: nil, session_id: nil, after: nil)
260
+ _problems, _occurrences, server_events = read_error_data
261
+ filter_server_events(
262
+ server_events,
263
+ project: project,
264
+ name: name,
265
+ level: level,
266
+ session_id: session_id,
267
+ after: after,
268
+ limit: limit
269
+ )
270
+ end
271
+
272
+ def occurrences_for_session(session_id, limit: nil)
273
+ validate_id!(session_id)
274
+ _problems, occurrences, _server_events = read_error_data
275
+ rows_for_session(occurrences.values.flatten, session_id, limit: limit)
276
+ end
277
+
278
+ def server_events_for_session(session_id, limit: nil)
279
+ validate_id!(session_id)
280
+ _problems, _occurrences, server_events = read_error_data
281
+ rows_for_session(server_events, session_id, limit: limit)
282
+ end
283
+
284
+ def session_ids_for_problem(problem_id, limit: nil)
285
+ validate_id!(problem_id)
286
+ _problems, occurrences, _server_events = read_error_data
287
+ latest_session_ids(occurrences[problem_id] || [], limit: limit)
288
+ end
289
+
290
+ def clear!
291
+ FileUtils.rm_rf(Dir.glob(::File.join(@root, "*")))
292
+ nil
293
+ end
294
+
295
+ # Scan meta.json directly: the base list_sessions path is capped and
296
+ # newest-first, so it would never see the oldest (stale) sessions.
297
+ def purge_older_than(seconds)
298
+ cutoff = Time.now.to_f - seconds
299
+
300
+ stale = list_session_ids.select { |sid|
301
+ updated_at = read_meta(sid)&.fetch("updated_at", nil)
302
+ updated_at && updated_at < cutoff
303
+ }
304
+
305
+ stale.each { |sid| delete_session(sid) }
306
+ deleted = stale.size
307
+
308
+ purge_error_data_older_than!(cutoff)
309
+
310
+ deleted
311
+ end
312
+
313
+ private
314
+
315
+ def session_path(session_id)
316
+ path = ::File.join(@root, session_id)
317
+ unless path.start_with?(@root + ::File::SEPARATOR)
318
+ raise ArgumentError, "path traversal detected: #{session_id.inspect}"
319
+ end
320
+ path
321
+ end
322
+
323
+ def meta_path(session_id)
324
+ ::File.join(session_path(session_id), "meta.json")
325
+ end
326
+
327
+ def window_path(session_id, window_id)
328
+ ::File.join(session_path(session_id), "#{window_id}.jsonl")
329
+ end
330
+
331
+ def serialize_events(events)
332
+ events.map { |event| JSON.generate(event) }.join("\n") + "\n"
333
+ end
334
+
335
+ def read_meta(session_id)
336
+ path = meta_path(session_id)
337
+ return nil unless ::File.exist?(path)
338
+
339
+ JSON.parse(::File.read(path))
340
+ rescue JSON::ParserError
341
+ nil
342
+ end
343
+
344
+ def meta_fields(meta)
345
+ fields = {
346
+ created_at: meta["created_at"],
347
+ updated_at: meta["updated_at"],
348
+ first_event_at: meta["first_event_at"],
349
+ last_event_at: meta["last_event_at"]
350
+ }
351
+ metadata = meta["metadata"]
352
+ fields[:metadata] = metadata if metadata
353
+ fields
354
+ end
355
+
356
+ def write_meta(session_id, meta)
357
+ path = meta_path(session_id)
358
+ tmp = "#{path}.tmp.#{Process.pid}"
359
+ ::File.write(tmp, JSON.generate(meta))
360
+ ::File.rename(tmp, path)
361
+ end
362
+
363
+ def update_meta(session_id)
364
+ dir = session_path(session_id)
365
+ FileUtils.mkdir_p(dir)
366
+ path = meta_path(session_id)
367
+ lock_path = "#{path}.lock"
368
+ ::File.open(lock_path, ::File::RDWR | ::File::CREAT, 0o600) do |lock|
369
+ lock.flock(::File::LOCK_EX)
370
+ meta = read_meta(session_id)
371
+ meta = yield meta
372
+ write_meta(session_id, meta) if meta
373
+ end
374
+ end
375
+
376
+ def list_session_ids
377
+ return [] unless ::File.directory?(@root)
378
+ Dir.children(@root).select { |name|
379
+ ::File.directory?(::File.join(@root, name))
380
+ }
381
+ end
382
+
383
+ def list_window_ids(session_id)
384
+ dir = session_path(session_id)
385
+ return [] unless ::File.directory?(dir)
386
+
387
+ Dir.children(dir)
388
+ .select { |f| f.end_with?(".jsonl") }
389
+ .map { |f| f.delete_suffix(".jsonl") }
390
+ end
391
+
392
+ def read_window_events(session_id, window_id)
393
+ path = window_path(session_id, window_id)
394
+ return [] unless ::File.exist?(path)
395
+
396
+ # Shared lock so a read can't observe a half-written line concurrent with
397
+ # a LOCK_EX append; the rescue below is a belt-and-braces fallback.
398
+ lines = ::File.open(path, "r") do |f|
399
+ f.flock(::File::LOCK_SH)
400
+ f.readlines(chomp: true)
401
+ end
402
+
403
+ lines.filter_map do |line|
404
+ next if line.empty?
405
+
406
+ JSON.parse(line)
407
+ rescue JSON::ParserError
408
+ nil
409
+ end
410
+ end
411
+
412
+ def build_summary(session_id)
413
+ meta = read_meta(session_id)
414
+ return nil unless meta
415
+
416
+ window_ids = list_window_ids(session_id)
417
+ event_count = window_ids.sum { |wid| read_window_events(session_id, wid).size }
418
+
419
+ summary_hash(
420
+ session_id: session_id,
421
+ window_ids: window_ids,
422
+ event_count: event_count,
423
+ created_at: meta["created_at"],
424
+ updated_at: meta["updated_at"],
425
+ first_event_at: meta["first_event_at"],
426
+ last_event_at: meta["last_event_at"],
427
+ metadata: meta["metadata"]
428
+ )
429
+ end
430
+
431
+ def enforce_max_events(session_id)
432
+ max_events = limits.max_events_per_session
433
+ return unless max_events
434
+
435
+ window_ids = list_window_ids(session_id)
436
+ events_by_window = window_ids.map { |wid| [wid, read_window_events(session_id, wid)] }
437
+ total = events_by_window.sum { |_, events| events.size }
438
+ return unless total > max_events
439
+
440
+ trim_windows(events_by_window, max_events).each do |wid, remaining|
441
+ path = window_path(session_id, wid)
442
+ if remaining.empty?
443
+ FileUtils.rm_f(path)
444
+ else
445
+ ::File.write(path, serialize_events(remaining))
446
+ end
447
+ end
448
+ end
449
+
450
+ def trim_windows(events_by_window, max_events)
451
+ excess = events_by_window.sum { |_, events| events.size } - max_events
452
+
453
+ sorted = events_by_window.sort_by { |_, events| events.first&.fetch("timestamp", 0) || 0 }
454
+
455
+ kept = {}
456
+ sorted.each do |wid, events|
457
+ break if excess <= 0
458
+
459
+ drop = [excess, events.size].min
460
+ excess -= drop
461
+ kept[wid] = events.drop(drop)
462
+ end
463
+ kept
464
+ end
465
+
466
+ def enforce_max_sessions
467
+ max_sessions = limits.max_sessions
468
+ return unless max_sessions
469
+
470
+ session_ids = list_session_ids
471
+ return unless session_ids.size > max_sessions
472
+
473
+ sorted = session_ids
474
+ .filter_map { |sid|
475
+ meta = read_meta(sid)
476
+ meta ? [sid, meta["updated_at"]] : nil
477
+ }
478
+ .sort_by(&:last)
479
+
480
+ to_evict = session_ids.size - max_sessions
481
+ sorted.first(to_evict).each { |sid, _| delete_session(sid) }
482
+ end
483
+
484
+ # Error data lives in three root-level JSON files, serialised by one lock:
485
+ # problems.json – fingerprint => problem hash
486
+ # occurrences.json – fingerprint => [occurrence, ...]
487
+ # server_events.json – [server event, ...]
488
+ def error_data_path(name)
489
+ ::File.join(@root, name)
490
+ end
491
+
492
+ def error_lock_path
493
+ ::File.join(@root, "error_data.lock")
494
+ end
495
+
496
+ def read_error_json(name)
497
+ path = error_data_path(name)
498
+ return nil unless ::File.exist?(path)
499
+ JSON.parse(::File.read(path))
500
+ rescue JSON::ParserError
501
+ nil
502
+ end
503
+
504
+ def read_error_data
505
+ raw_problems = read_error_json("problems.json") || {}
506
+ raw_occurrences = read_error_json("occurrences.json") || {}
507
+ raw_server_events = read_error_json("server_events.json") || []
508
+
509
+ problems = raw_problems.transform_values { |p| problem_from_strings(p) }
510
+ [problems, raw_occurrences, raw_server_events]
511
+ end
512
+
513
+ def write_error_data(problems, occurrences, server_events)
514
+ raw_problems = problems.transform_values { |p| stringify_problem(p) }
515
+ [
516
+ ["problems.json", raw_problems],
517
+ ["occurrences.json", occurrences],
518
+ ["server_events.json", server_events]
519
+ ].each do |name, data|
520
+ path = error_data_path(name)
521
+ tmp = "#{path}.tmp.#{Process.pid}"
522
+ ::File.write(tmp, JSON.generate(data))
523
+ ::File.rename(tmp, path)
524
+ end
525
+ end
526
+
527
+ def update_error_data
528
+ ::File.open(error_lock_path, ::File::RDWR | ::File::CREAT, 0o600) do |lock|
529
+ lock.flock(::File::LOCK_EX)
530
+ problems, occurrences, server_events = read_error_data
531
+ yield problems, occurrences, server_events
532
+ write_error_data(problems, occurrences, server_events)
533
+ end
534
+ end
535
+
536
+ def stringify_problem(p)
537
+ {
538
+ "id" => p[:id],
539
+ "project" => p[:project],
540
+ "exception_class" => p[:exception_class],
541
+ "title" => p[:title],
542
+ "message" => p[:message],
543
+ "count" => p[:count],
544
+ "status" => p[:status],
545
+ "first_seen" => p[:first_seen],
546
+ "last_seen" => p[:last_seen],
547
+ "resolved_at" => p[:resolved_at]
548
+ }
549
+ end
550
+
551
+ def purge_error_data_older_than!(cutoff)
552
+ update_error_data do |problems, occurrences, server_events|
553
+ purge_error_collections!(problems, occurrences, server_events, cutoff)
554
+ end
555
+ end
556
+
557
+ def enforce_max_server_events!(server_events)
558
+ max = limits.max_server_events
559
+ return unless max && server_events.size > max
560
+
561
+ excess = server_events.size - max
562
+ server_events.shift(excess)
563
+ end
564
+ end
565
+ end
566
+ end