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,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sentiero
4
+ module Stores
5
+ class SQLite
6
+ # Table/index definitions for the store's single SQLite3 connection.
7
+ module Schema
8
+ def self.create(db)
9
+ db.execute_batch(<<~SQL)
10
+ CREATE TABLE IF NOT EXISTS sessions (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
12
+ session_id TEXT NOT NULL UNIQUE,
13
+ created_at REAL NOT NULL,
14
+ updated_at REAL NOT NULL,
15
+ first_event_at REAL,
16
+ last_event_at REAL,
17
+ metadata TEXT
18
+ );
19
+
20
+ CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id);
21
+ CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
22
+
23
+ CREATE TABLE IF NOT EXISTS events (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ session_id TEXT NOT NULL,
26
+ window_id TEXT NOT NULL,
27
+ timestamp REAL,
28
+ data TEXT NOT NULL
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS idx_events_session_window_ts ON events(session_id, window_id, timestamp);
32
+
33
+ CREATE TABLE IF NOT EXISTS problems (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ fingerprint TEXT NOT NULL UNIQUE,
36
+ project TEXT NOT NULL,
37
+ exception_class TEXT NOT NULL,
38
+ title TEXT NOT NULL,
39
+ message TEXT,
40
+ count INTEGER NOT NULL,
41
+ status TEXT NOT NULL,
42
+ first_seen REAL NOT NULL,
43
+ last_seen REAL NOT NULL,
44
+ resolved_at REAL
45
+ );
46
+ CREATE INDEX IF NOT EXISTS idx_problems_project ON problems(project);
47
+ CREATE INDEX IF NOT EXISTS idx_problems_status ON problems(status);
48
+ CREATE INDEX IF NOT EXISTS idx_problems_last_seen ON problems(last_seen);
49
+
50
+ CREATE TABLE IF NOT EXISTS occurrences (
51
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
52
+ occurrence_id TEXT NOT NULL,
53
+ fingerprint TEXT NOT NULL,
54
+ session_id TEXT,
55
+ timestamp REAL NOT NULL,
56
+ data TEXT NOT NULL
57
+ );
58
+ CREATE INDEX IF NOT EXISTS idx_occurrences_fp_ts ON occurrences(fingerprint, timestamp);
59
+ CREATE INDEX IF NOT EXISTS idx_occurrences_session ON occurrences(session_id);
60
+
61
+ CREATE TABLE IF NOT EXISTS server_events (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ event_id TEXT NOT NULL,
64
+ project TEXT NOT NULL,
65
+ name TEXT NOT NULL,
66
+ level TEXT,
67
+ session_id TEXT,
68
+ timestamp REAL NOT NULL,
69
+ data TEXT NOT NULL
70
+ );
71
+ CREATE INDEX IF NOT EXISTS idx_server_events_event_id ON server_events(event_id);
72
+ CREATE INDEX IF NOT EXISTS idx_server_events_project ON server_events(project);
73
+ CREATE INDEX IF NOT EXISTS idx_server_events_session ON server_events(session_id);
74
+ SQL
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,626 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "monitor"
6
+
7
+ # Optional dependency: load it if present, else the initializer below raises
8
+ # a LoadError with install instructions.
9
+ begin
10
+ require "sqlite3"
11
+ rescue LoadError
12
+ end
13
+
14
+ module Sentiero
15
+ module Stores
16
+ class SQLite < Store
17
+ # Loaded after the class line above establishes SQLite < Store, so
18
+ # schema.rb's own `class SQLite` reopen doesn't hit a superclass mismatch.
19
+ require_relative "sqlite/schema"
20
+
21
+ # Single-file SQLite store for single-process production and dev.
22
+ # Requires the sqlite3 gem; `require "sentiero/stores/sqlite"` to load.
23
+
24
+ # Accumulates "column op ?" fragments and their bind params for a WHERE
25
+ # clause, so the six list/count methods below don't hand-roll the same
26
+ # conditions/params pair. #clause is "" (no WHERE) when nothing was added.
27
+ class Where
28
+ attr_reader :params
29
+
30
+ def initialize
31
+ @conditions = []
32
+ @params = []
33
+ end
34
+
35
+ def add(condition, *values)
36
+ @conditions << condition
37
+ @params.concat(values)
38
+ self
39
+ end
40
+
41
+ def clause
42
+ @conditions.empty? ? "" : "WHERE #{@conditions.join(" AND ")}"
43
+ end
44
+ end
45
+
46
+ def initialize(path: "sentiero.db", limits: nil)
47
+ unless defined?(::SQLite3)
48
+ raise LoadError, "The sqlite3 gem is required for Sentiero::Stores::SQLite. Add `gem 'sqlite3'` to your Gemfile."
49
+ end
50
+
51
+ @limits = limits
52
+ @monitor = Monitor.new
53
+ @path = path
54
+ @db = create_database
55
+ Schema.create(@db)
56
+ end
57
+
58
+ def save_events(ref, events)
59
+ return if events.nil? || events.empty?
60
+
61
+ validate_window_ref!(ref)
62
+ session_id, window_id = ref.session_id, ref.window_id
63
+
64
+ now = Time.now.to_f
65
+ event_timestamps = events.filter_map { |e| e["timestamp"]&.to_f }
66
+ batch_min = event_timestamps.min
67
+ batch_max = event_timestamps.max
68
+
69
+ @db.transaction do
70
+ existing = @db.get_first_row("SELECT id, first_event_at, last_event_at FROM sessions WHERE session_id = ?", [session_id])
71
+
72
+ if existing
73
+ new_first = batch_min ? [existing["first_event_at"], batch_min].compact.min : existing["first_event_at"]
74
+ new_last = batch_max ? [existing["last_event_at"], batch_max].compact.max : existing["last_event_at"]
75
+
76
+ @db.execute(
77
+ "UPDATE sessions SET updated_at = ?, first_event_at = ?, last_event_at = ? WHERE session_id = ?",
78
+ [now, new_first, new_last, session_id]
79
+ )
80
+ else
81
+ @db.execute(
82
+ "INSERT INTO sessions (session_id, created_at, updated_at, first_event_at, last_event_at, metadata) VALUES (?, ?, ?, ?, ?, NULL)",
83
+ [session_id, now, now, batch_min, batch_max]
84
+ )
85
+ end
86
+
87
+ stmt = @db.prepare("INSERT INTO events (session_id, window_id, timestamp, data) VALUES (?, ?, ?, ?)")
88
+ begin
89
+ events.each do |event|
90
+ stmt.execute(session_id, window_id, event["timestamp"]&.to_f, JSON.generate(event))
91
+ end
92
+ ensure
93
+ stmt.close
94
+ end
95
+
96
+ enforce_max_events(session_id)
97
+ enforce_max_sessions(session_id)
98
+ end
99
+
100
+ nil
101
+ end
102
+
103
+ # Batched scan: avoids the base's get_session + get_events-per-window N+1.
104
+ SCAN_IN_CHUNK = 500
105
+
106
+ def each_session_events(limit: nil, since: nil, until_time: nil)
107
+ return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time) unless block_given?
108
+
109
+ cap = limit || limits.analytics_max_scan_sessions
110
+ rows = scan_session_rows(cap, since, until_time)
111
+ return if rows.empty?
112
+
113
+ events = events_by_session_window(rows.map { |row| row["session_id"] })
114
+ rows.each do |row|
115
+ windows = events[row["session_id"]]
116
+ next unless windows
117
+
118
+ summary = scan_summary(row, windows)
119
+ windows.each { |window_id, window_events| yield summary, window_id, window_events }
120
+ end
121
+ end
122
+
123
+ def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
124
+ where = Where.new
125
+ where.add("s.updated_at >= ?", since.to_f) if since
126
+ where.add("s.updated_at <= ?", until_time.to_f) if until_time
127
+ if search && !search.empty?
128
+ pattern = "%#{search}%"
129
+ where.add("(s.session_id LIKE ? OR COALESCE(s.metadata, '') LIKE ?)", pattern, pattern)
130
+ end
131
+
132
+ order_clause = case sort_by
133
+ when "created_at"
134
+ "ORDER BY s.created_at DESC"
135
+ when "event_count"
136
+ "ORDER BY event_count DESC"
137
+ else
138
+ "ORDER BY s.updated_at DESC"
139
+ end
140
+
141
+ sql = <<~SQL
142
+ SELECT s.session_id, s.created_at, s.updated_at, s.first_event_at, s.last_event_at, s.metadata,
143
+ COUNT(e.id) AS event_count
144
+ FROM sessions s
145
+ LEFT JOIN events e ON e.session_id = s.session_id
146
+ #{where.clause}
147
+ GROUP BY s.id
148
+ #{order_clause}
149
+ LIMIT ? OFFSET ?
150
+ SQL
151
+
152
+ rows = @db.execute(sql, where.params + [limit, offset])
153
+
154
+ rows.map { |row|
155
+ window_ids = @db.execute(
156
+ "SELECT DISTINCT window_id FROM events WHERE session_id = ?", [row["session_id"]]
157
+ ).map { |window_row| window_row["window_id"] }
158
+
159
+ summary_hash(
160
+ session_id: row["session_id"],
161
+ window_ids: window_ids,
162
+ event_count: row["event_count"],
163
+ created_at: row["created_at"],
164
+ updated_at: row["updated_at"],
165
+ first_event_at: row["first_event_at"],
166
+ last_event_at: row["last_event_at"],
167
+ metadata: row["metadata"] && JSON.parse(row["metadata"])
168
+ )
169
+ }
170
+ end
171
+
172
+ def get_session(session_id)
173
+ validate_id!(session_id)
174
+
175
+ row = @db.get_first_row("SELECT * FROM sessions WHERE session_id = ?", [session_id])
176
+ return nil unless row
177
+
178
+ window_stats = @db.execute(
179
+ "SELECT window_id, COUNT(*) AS cnt, MIN(timestamp) AS min_ts, MAX(timestamp) AS max_ts FROM events WHERE session_id = ? GROUP BY window_id",
180
+ [session_id]
181
+ )
182
+
183
+ window_data = window_stats.map { |stats|
184
+ window = {window_id: stats["window_id"], event_count: stats["cnt"]}
185
+ window[:first_event_at] = stats["min_ts"] if stats["min_ts"]
186
+ window[:last_event_at] = stats["max_ts"] if stats["max_ts"]
187
+ window
188
+ }
189
+
190
+ result = {
191
+ session_id: session_id,
192
+ windows: window_data,
193
+ created_at: row["created_at"],
194
+ updated_at: row["updated_at"],
195
+ first_event_at: row["first_event_at"],
196
+ last_event_at: row["last_event_at"]
197
+ }
198
+
199
+ if row["metadata"]
200
+ parsed = JSON.parse(row["metadata"])
201
+ result[:metadata] = parsed unless empty_metadata?(parsed)
202
+ end
203
+
204
+ result
205
+ end
206
+
207
+ def get_events(ref, after: nil, limit: nil)
208
+ validate_window_ref!(ref)
209
+ session_id, window_id = ref.session_id, ref.window_id
210
+
211
+ conditions = ["session_id = ?", "window_id = ?"]
212
+ params = [session_id, window_id]
213
+
214
+ if after
215
+ conditions << "timestamp > ?"
216
+ params << after.to_f
217
+ end
218
+
219
+ sql = "SELECT data FROM events WHERE #{conditions.join(" AND ")} ORDER BY timestamp ASC"
220
+ if limit
221
+ sql += " LIMIT ?"
222
+ params << limit.to_i
223
+ end
224
+
225
+ @db.execute(sql, params).map { |event_row| JSON.parse(event_row["data"]) }
226
+ end
227
+
228
+ def save_metadata(session_id, metadata)
229
+ return unless metadata.is_a?(Hash) && !metadata.empty?
230
+
231
+ validate_id!(session_id)
232
+ validate_metadata!(metadata)
233
+
234
+ @db.transaction do
235
+ row = @db.get_first_row("SELECT metadata FROM sessions WHERE session_id = ?", [session_id])
236
+ return unless row
237
+
238
+ existing = row["metadata"] ? JSON.parse(row["metadata"]) : {}
239
+ merged = existing.merge(metadata.transform_keys(&:to_s))
240
+ @db.execute("UPDATE sessions SET metadata = ? WHERE session_id = ?", [JSON.generate(merged), session_id])
241
+ end
242
+ nil
243
+ end
244
+
245
+ def delete_session(session_id)
246
+ validate_id!(session_id)
247
+
248
+ @db.transaction do
249
+ @db.execute("DELETE FROM events WHERE session_id = ?", [session_id])
250
+ @db.execute("DELETE FROM sessions WHERE session_id = ?", [session_id])
251
+ @db.execute("DELETE FROM occurrences WHERE session_id = ?", [session_id])
252
+ @db.execute("DELETE FROM server_events WHERE session_id = ?", [session_id])
253
+ end
254
+ nil
255
+ end
256
+
257
+ def delete_window(ref)
258
+ validate_window_ref!(ref)
259
+ session_id, window_id = ref.session_id, ref.window_id
260
+
261
+ @db.transaction do
262
+ @db.execute("DELETE FROM events WHERE session_id = ? AND window_id = ?", [session_id, window_id])
263
+
264
+ remaining = @db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
265
+ if remaining == 0
266
+ @db.execute("DELETE FROM sessions WHERE session_id = ?", [session_id])
267
+ end
268
+ end
269
+ nil
270
+ end
271
+
272
+ def save_occurrence(occurrence)
273
+ validate_occurrence!(occurrence)
274
+ fp = occurrence["fingerprint"]
275
+ ts = occurrence["timestamp"].to_f
276
+ occ_id = SecureRandom.uuid
277
+ stored = occurrence.merge("id" => occ_id)
278
+
279
+ # Native-SQL upsert mirroring the base new_problem_attrs/touched_problem_attrs.
280
+ @db.transaction do
281
+ existing = @db.get_first_row(
282
+ "SELECT count, first_seen, last_seen, status, resolved_at FROM problems WHERE fingerprint = ?", [fp]
283
+ )
284
+ if existing
285
+ reopening = existing["status"] == "resolved"
286
+ @db.execute(
287
+ "UPDATE problems SET count = count + 1, first_seen = ?, last_seen = ?, message = ?, status = ?, resolved_at = ? WHERE fingerprint = ?",
288
+ [[existing["first_seen"], ts].min, [existing["last_seen"], ts].max, occurrence["message"],
289
+ reopening ? "open" : existing["status"], reopening ? nil : existing["resolved_at"], fp]
290
+ )
291
+ else
292
+ @db.execute(
293
+ "INSERT INTO problems (fingerprint, project, exception_class, title, message, count, status, first_seen, last_seen, resolved_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)",
294
+ [fp, occurrence["project"], occurrence["exception_class"], build_problem_title(occurrence),
295
+ occurrence["message"], 1, "open", ts, ts]
296
+ )
297
+ end
298
+
299
+ @db.execute(
300
+ "INSERT INTO occurrences (occurrence_id, fingerprint, session_id, timestamp, data) VALUES (?, ?, ?, ?, ?)",
301
+ [occ_id, fp, occurrence["session_id"], ts, JSON.generate(stored)]
302
+ )
303
+
304
+ enforce_max_problems
305
+ end
306
+ save_metadata(occurrence["session_id"], {"has_errors" => true}) if occurrence["session_id"]
307
+ fp
308
+ end
309
+
310
+ # Native-SQL mirror of the base filter_and_page_problems.
311
+ def list_problems(project:, limit:, offset: 0, status: nil, sort_by: nil, search: nil, since: nil, until_time: nil)
312
+ where = Where.new
313
+ where.add("project = ?", project) unless project.nil?
314
+ where.add("status = ?", status) if status
315
+ where.add("last_seen >= ?", since.to_f) if since
316
+ where.add("last_seen <= ?", until_time.to_f) if until_time
317
+ if search && !search.empty?
318
+ pattern = "%#{search}%"
319
+ where.add("(title LIKE ? OR exception_class LIKE ?)", pattern, pattern)
320
+ end
321
+ order = case sort_by
322
+ when "first_seen" then "ORDER BY first_seen DESC"
323
+ when "count" then "ORDER BY count DESC"
324
+ else "ORDER BY last_seen DESC"
325
+ end
326
+ @db.execute("SELECT * FROM problems #{where.clause} #{order} LIMIT ? OFFSET ?", where.params + [limit, offset])
327
+ .map { |row| problem_row_to_hash(row) }
328
+ end
329
+
330
+ def get_problem(problem_id)
331
+ validate_id!(problem_id)
332
+ row = @db.get_first_row("SELECT * FROM problems WHERE fingerprint = ?", [problem_id])
333
+ row && problem_row_to_hash(row)
334
+ end
335
+
336
+ def get_occurrences(problem_id, after: nil, limit: nil)
337
+ validate_id!(problem_id)
338
+ where = Where.new.add("fingerprint = ?", problem_id)
339
+ where.add("timestamp > ?", after.to_f) if after
340
+ sql = "SELECT data FROM occurrences #{where.clause} ORDER BY timestamp ASC"
341
+ params = where.params
342
+ if limit
343
+ sql += " LIMIT ?"
344
+ params += [limit.to_i]
345
+ end
346
+ @db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
347
+ end
348
+
349
+ # COUNT(*) on the (fingerprint, timestamp) index, no row materialization.
350
+ def count_occurrences(problem_id, after: nil)
351
+ validate_id!(problem_id)
352
+ where = Where.new.add("fingerprint = ?", problem_id)
353
+ where.add("timestamp > ?", after.to_f) if after
354
+ @db.get_first_value("SELECT COUNT(*) FROM occurrences #{where.clause}", where.params)
355
+ end
356
+
357
+ def update_problem_status(problem_id, status)
358
+ validate_id!(problem_id)
359
+ validate_status!(status)
360
+ resolved_at = (status == "resolved") ? Time.now.to_f : nil
361
+ @db.execute("UPDATE problems SET status = ?, resolved_at = ? WHERE fingerprint = ?", [status, resolved_at, problem_id])
362
+ nil
363
+ end
364
+
365
+ def save_server_event(event)
366
+ validate_server_event!(event)
367
+ ev_id = SecureRandom.uuid
368
+ stored = event.merge("id" => ev_id)
369
+ @db.transaction do
370
+ @db.execute(
371
+ "INSERT INTO server_events (event_id, project, name, level, session_id, timestamp, data) VALUES (?, ?, ?, ?, ?, ?, ?)",
372
+ [ev_id, event["project"], event["name"], event["level"], event["session_id"], event["timestamp"].to_f, JSON.generate(stored)]
373
+ )
374
+ enforce_max_server_events
375
+ end
376
+ nil
377
+ end
378
+
379
+ def get_server_event(event_id)
380
+ validate_id!(event_id)
381
+ row = @db.get_first_row("SELECT data FROM server_events WHERE event_id = ?", [event_id])
382
+ row && JSON.parse(row["data"])
383
+ end
384
+
385
+ def list_server_events(project:, limit:, name: nil, level: nil, session_id: nil, after: nil)
386
+ where = Where.new
387
+ where.add("project = ?", project) unless project.nil?
388
+ where.add("name = ?", name) if name
389
+ where.add("level = ?", level) if level
390
+ where.add("session_id = ?", session_id) if session_id
391
+ where.add("timestamp > ?", after.to_f) if after
392
+ @db.execute("SELECT data FROM server_events #{where.clause} ORDER BY timestamp ASC LIMIT ?", where.params + [limit])
393
+ .map { |row| JSON.parse(row["data"]) }
394
+ end
395
+
396
+ def occurrences_for_session(session_id, limit: nil)
397
+ validate_id!(session_id)
398
+ sql = "SELECT data FROM occurrences WHERE session_id = ? ORDER BY timestamp ASC"
399
+ params = [session_id]
400
+ if limit
401
+ sql += " LIMIT ?"
402
+ params << limit.to_i
403
+ end
404
+ @db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
405
+ end
406
+
407
+ def server_events_for_session(session_id, limit: nil)
408
+ validate_id!(session_id)
409
+ sql = "SELECT data FROM server_events WHERE session_id = ? ORDER BY timestamp ASC"
410
+ params = [session_id]
411
+ if limit
412
+ sql += " LIMIT ?"
413
+ params << limit.to_i
414
+ end
415
+ @db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
416
+ end
417
+
418
+ def session_ids_for_problem(problem_id, limit: nil)
419
+ validate_id!(problem_id)
420
+ sql = "SELECT session_id, MAX(timestamp) AS ts FROM occurrences WHERE fingerprint = ? AND session_id IS NOT NULL GROUP BY session_id ORDER BY ts DESC"
421
+ params = [problem_id]
422
+ if limit
423
+ sql += " LIMIT ?"
424
+ params << limit.to_i
425
+ end
426
+ @db.execute(sql, params).map { |row| row["session_id"] }
427
+ end
428
+
429
+ def clear!
430
+ @db.transaction do
431
+ @db.execute("DELETE FROM events")
432
+ @db.execute("DELETE FROM sessions")
433
+ @db.execute("DELETE FROM problems")
434
+ @db.execute("DELETE FROM occurrences")
435
+ @db.execute("DELETE FROM server_events")
436
+ end
437
+ nil
438
+ end
439
+
440
+ def purge_older_than(seconds)
441
+ cutoff = Time.now.to_f - seconds
442
+ session_count = nil
443
+
444
+ @db.transaction do
445
+ @db.execute(
446
+ "DELETE FROM events WHERE session_id IN (SELECT session_id FROM sessions WHERE updated_at < ?)",
447
+ [cutoff]
448
+ )
449
+ @db.execute("DELETE FROM sessions WHERE updated_at < ?", [cutoff])
450
+ session_count = @db.changes
451
+
452
+ @db.execute("DELETE FROM server_events WHERE timestamp < ?", [cutoff])
453
+ @db.execute("DELETE FROM occurrences WHERE timestamp < ?", [cutoff])
454
+ @db.execute(
455
+ "DELETE FROM occurrences WHERE fingerprint IN (SELECT fingerprint FROM problems WHERE last_seen < ?)",
456
+ [cutoff]
457
+ )
458
+ @db.execute("DELETE FROM problems WHERE last_seen < ?", [cutoff])
459
+ end
460
+
461
+ session_count
462
+ end
463
+
464
+ private
465
+
466
+ def empty_metadata?(parsed)
467
+ parsed.nil? || (parsed.is_a?(Hash) && parsed.empty?)
468
+ end
469
+
470
+ def scan_session_rows(cap, since, until_time)
471
+ where = Where.new
472
+ where.add("updated_at >= ?", since.to_f) if since
473
+ where.add("updated_at <= ?", until_time.to_f) if until_time
474
+ @db.execute(
475
+ "SELECT session_id, created_at, updated_at, first_event_at, last_event_at, metadata " \
476
+ "FROM sessions #{where.clause} ORDER BY updated_at DESC LIMIT ?", where.params + [cap]
477
+ )
478
+ end
479
+
480
+ def events_by_session_window(session_ids)
481
+ grouped = Hash.new { |h, sid| h[sid] = {} }
482
+ session_ids.each_slice(SCAN_IN_CHUNK) do |chunk|
483
+ placeholders = (["?"] * chunk.size).join(",")
484
+ @db.execute(
485
+ "SELECT session_id, window_id, data FROM events WHERE session_id IN (#{placeholders}) ORDER BY timestamp ASC",
486
+ chunk
487
+ ).each do |row|
488
+ (grouped[row["session_id"]][row["window_id"]] ||= []) << JSON.parse(row["data"])
489
+ end
490
+ end
491
+ grouped
492
+ end
493
+
494
+ def scan_summary(row, windows)
495
+ summary_hash(
496
+ session_id: row["session_id"],
497
+ window_ids: windows.keys,
498
+ event_count: windows.values.sum(&:size),
499
+ created_at: row["created_at"],
500
+ updated_at: row["updated_at"],
501
+ first_event_at: row["first_event_at"],
502
+ last_event_at: row["last_event_at"],
503
+ metadata: row["metadata"] && JSON.parse(row["metadata"])
504
+ )
505
+ end
506
+
507
+ def create_database
508
+ db = ::SQLite3::Database.new(@path)
509
+ db.results_as_hash = true
510
+ db.execute("PRAGMA journal_mode=WAL")
511
+ db.execute("PRAGMA foreign_keys=ON")
512
+ db
513
+ end
514
+
515
+ def enforce_max_events(session_id)
516
+ max_events = limits.max_events_per_session
517
+ return unless max_events
518
+
519
+ total = @db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
520
+ return unless total > max_events
521
+
522
+ excess = total - max_events
523
+ @db.execute(
524
+ "DELETE FROM events WHERE id IN (SELECT id FROM events WHERE session_id = ? ORDER BY timestamp ASC LIMIT ?)",
525
+ [session_id, excess]
526
+ )
527
+ end
528
+
529
+ def enforce_max_sessions(protected_session_id)
530
+ max_sessions = limits.max_sessions
531
+ return unless max_sessions
532
+
533
+ total = @db.get_first_value("SELECT COUNT(*) FROM sessions")
534
+ return unless total > max_sessions
535
+
536
+ to_evict = total - max_sessions
537
+ oldest = @db.execute(
538
+ "SELECT session_id FROM sessions WHERE session_id != ? ORDER BY updated_at ASC LIMIT ?",
539
+ [protected_session_id, to_evict]
540
+ ).map { |session_row| session_row["session_id"] }
541
+
542
+ oldest.each do |sid|
543
+ @db.execute("DELETE FROM events WHERE session_id = ?", [sid])
544
+ @db.execute("DELETE FROM sessions WHERE session_id = ?", [sid])
545
+ end
546
+ end
547
+
548
+ # Own row mapper (not base problem_from_strings): :id maps from the
549
+ # fingerprint column, since the table has its own AUTOINCREMENT id.
550
+ def problem_row_to_hash(row)
551
+ {
552
+ id: row["fingerprint"],
553
+ project: row["project"],
554
+ exception_class: row["exception_class"],
555
+ title: row["title"],
556
+ message: row["message"],
557
+ count: row["count"],
558
+ status: row["status"],
559
+ first_seen: row["first_seen"],
560
+ last_seen: row["last_seen"],
561
+ resolved_at: row["resolved_at"]
562
+ }
563
+ end
564
+
565
+ def enforce_max_problems
566
+ max = limits.max_problems
567
+ return unless max
568
+
569
+ total = @db.get_first_value("SELECT COUNT(*) FROM problems")
570
+ return unless total > max
571
+
572
+ excess = total - max
573
+ fps = @db.execute("SELECT fingerprint FROM problems ORDER BY last_seen ASC LIMIT ?", [excess]).map { |r| r["fingerprint"] }
574
+ fps.each do |fp|
575
+ @db.execute("DELETE FROM occurrences WHERE fingerprint = ?", [fp])
576
+ @db.execute("DELETE FROM problems WHERE fingerprint = ?", [fp])
577
+ end
578
+ end
579
+
580
+ def enforce_max_server_events
581
+ max = limits.max_server_events
582
+ return unless max
583
+
584
+ total = @db.get_first_value("SELECT COUNT(*) FROM server_events")
585
+ return unless total > max
586
+
587
+ excess = total - max
588
+ @db.execute("DELETE FROM server_events WHERE id IN (SELECT id FROM server_events ORDER BY timestamp ASC LIMIT ?)", [excess])
589
+ end
590
+
591
+ # Rack hands URL path segments over as ASCII-8BIT strings, and sqlite3
592
+ # (>= 2.x) binds ASCII-8BIT params as BLOBs — so a TEXT-column lookup
593
+ # with a Rack-supplied ID silently matches nothing. Re-tag binary string
594
+ # arguments as UTF-8 on the way in (IDs are ASCII-only by validation, so
595
+ # the bytes are unchanged). Nested payloads (events, metadata) come from
596
+ # JSON.parse and are already UTF-8.
597
+ utf8_arg = lambda do |value|
598
+ case value
599
+ when String
600
+ (value.encoding == Encoding::ASCII_8BIT) ? value.dup.force_encoding(Encoding::UTF_8) : value
601
+ when WindowRef
602
+ WindowRef.new(utf8_arg.call(value.session_id), utf8_arg.call(value.window_id))
603
+ else
604
+ value
605
+ end
606
+ end
607
+
608
+ # One shared SQLite3 connection can't be driven from multiple threads at once
609
+ # (concurrent statements race on its single transaction state), and EventsApp
610
+ # is a concurrent caller under Puma. Serialize every public op through one
611
+ # reentrant Monitor: reentrant for internal calls (save_occurrence ->
612
+ # save_metadata), wrap-all so new methods stay covered, defined last to see them.
613
+ # The same wrapper applies utf8_arg to every argument (see above).
614
+ synchronized = public_instance_methods(false)
615
+ prepend(Module.new do
616
+ synchronized.each do |method_name|
617
+ define_method(method_name) do |*args, **kwargs, &block|
618
+ args = args.map { |arg| utf8_arg.call(arg) }
619
+ kwargs = kwargs.transform_values { |value| utf8_arg.call(value) }
620
+ @monitor.synchronize { super(*args, **kwargs, &block) }
621
+ end
622
+ end
623
+ end)
624
+ end
625
+ end
626
+ end