sentiero 1.0.0.alpha1 → 1.0.0.beta3

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -13
  3. data/lib/sentiero/analytics/analyzer.rb +16 -4
  4. data/lib/sentiero/analytics/browser_event_discovery.rb +2 -2
  5. data/lib/sentiero/analytics/conversion_analyzer.rb +1 -1
  6. data/lib/sentiero/analytics/engagement_analyzer.rb +1 -1
  7. data/lib/sentiero/analytics/error_discovery.rb +9 -2
  8. data/lib/sentiero/analytics/exporter.rb +1 -1
  9. data/lib/sentiero/analytics/frustration_analyzer.rb +1 -1
  10. data/lib/sentiero/analytics/funnel_analyzer.rb +2 -2
  11. data/lib/sentiero/analytics/heatmap_analyzer.rb +3 -3
  12. data/lib/sentiero/analytics/page_report_analyzer.rb +2 -2
  13. data/lib/sentiero/analytics/scroll_depth_analyzer.rb +1 -1
  14. data/lib/sentiero/analytics/segmenter.rb +16 -2
  15. data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +2 -0
  16. data/lib/sentiero/analytics/stats_aggregator.rb +45 -6
  17. data/lib/sentiero/analytics/web_vitals_analyzer.rb +2 -2
  18. data/lib/sentiero/configuration.rb +29 -0
  19. data/lib/sentiero/fingerprint/config.rb +40 -0
  20. data/lib/sentiero/fingerprint.rb +45 -11
  21. data/lib/sentiero/geo.rb +86 -0
  22. data/lib/sentiero/reporter.rb +2 -1
  23. data/lib/sentiero/store/session_store.rb +62 -3
  24. data/lib/sentiero/store.rb +7 -0
  25. data/lib/sentiero/stores/file.rb +5 -1
  26. data/lib/sentiero/stores/memory.rb +15 -3
  27. data/lib/sentiero/stores/redis.rb +17 -5
  28. data/lib/sentiero/stores/sqlite/payload_codec.rb +42 -0
  29. data/lib/sentiero/stores/sqlite/schema.rb +72 -6
  30. data/lib/sentiero/stores/sqlite.rb +216 -101
  31. data/lib/sentiero/version.rb +1 -1
  32. data/lib/sentiero/web/analytics_app.rb +10 -0
  33. data/lib/sentiero/web/assets/dashboard-YINQE5ZH.js +3 -0
  34. data/lib/sentiero/web/assets/manifest.json +4 -4
  35. data/lib/sentiero/web/assets/recorder-W2RCU5VM.js +78 -0
  36. data/lib/sentiero/web/assets/rrweb-player-d522d74c.js +201 -0
  37. data/lib/sentiero/web/assets/{style-d71e72fd.css → style-80e536e6.css} +1 -1
  38. data/lib/sentiero/web/body_reader.rb +21 -10
  39. data/lib/sentiero/web/dashboard_app.rb +77 -5
  40. data/lib/sentiero/web/errors_app.rb +14 -1
  41. data/lib/sentiero/web/events_app.rb +6 -1
  42. data/lib/sentiero/web/ingest_app.rb +1 -1
  43. data/lib/sentiero/web/templates/client_error_show.html.erb +6 -1
  44. data/lib/sentiero/web/templates/dashboard.html.erb +7 -0
  45. data/lib/sentiero/web/templates/maintenance.html.erb +50 -0
  46. data/lib/sentiero/web/templates/segments.html.erb +9 -0
  47. data/lib/sentiero/web/templates/sessions_index.html.erb +12 -4
  48. data/lib/sentiero/web/views/analytics_index_view.rb +7 -1
  49. data/lib/sentiero/web/views/client_error_show_view.rb +1 -1
  50. data/lib/sentiero/web/views/maintenance_view.rb +27 -0
  51. data/lib/sentiero/web/views/segments_view.rb +3 -2
  52. data/lib/sentiero/web/views/sessions_index_view.rb +6 -2
  53. data/lib/sentiero/web/views.rb +1 -0
  54. data/lib/sentiero.rb +1 -0
  55. metadata +10 -5
  56. data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +0 -3
  57. data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +0 -71
  58. data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +0 -126
@@ -16,6 +16,7 @@ module Sentiero
16
16
  class SQLite < Store
17
17
  # Loaded after the class line above establishes SQLite < Store, so
18
18
  # schema.rb's own `class SQLite` reopen doesn't hit a superclass mismatch.
19
+ require_relative "sqlite/payload_codec"
19
20
  require_relative "sqlite/schema"
20
21
 
21
22
  # Single-file SQLite store for single-process production and dev.
@@ -50,9 +51,21 @@ module Sentiero
50
51
 
51
52
  @limits = limits
52
53
  @monitor = Monitor.new
53
- @path = path
54
- @db = create_database
55
- Schema.create(@db)
54
+ @path = path.to_s
55
+ # In-memory databases are private to one connection, so they keep the
56
+ # single shared connection serialized by @monitor. File-backed stores
57
+ # get one connection per thread: WAL readers never block the writer, so
58
+ # a slow dashboard scan can't stall recorder ingest (and vice versa).
59
+ @in_memory = @path == ":memory:" || @path.start_with?("file::memory:")
60
+ @tls_key = :"sentiero_sqlite_#{object_id}"
61
+ @shared_db = create_connection if @in_memory
62
+ Schema.create(db)
63
+ end
64
+
65
+ # event_type_counts / event_counts_by_day group on indexed columns and
66
+ # never read payloads (see Schema for the column-order guarantee).
67
+ def supports_event_aggregates?
68
+ true
56
69
  end
57
70
 
58
71
  def save_events(ref, events)
@@ -66,28 +79,30 @@ module Sentiero
66
79
  batch_min = event_timestamps.min
67
80
  batch_max = event_timestamps.max
68
81
 
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])
82
+ db.transaction(:immediate) do
83
+ existing = db.get_first_row("SELECT id, first_event_at, last_event_at FROM sessions WHERE session_id = ?", [session_id])
71
84
 
72
85
  if existing
73
86
  new_first = batch_min ? [existing["first_event_at"], batch_min].compact.min : existing["first_event_at"]
74
87
  new_last = batch_max ? [existing["last_event_at"], batch_max].compact.max : existing["last_event_at"]
75
88
 
76
- @db.execute(
89
+ db.execute(
77
90
  "UPDATE sessions SET updated_at = ?, first_event_at = ?, last_event_at = ? WHERE session_id = ?",
78
91
  [now, new_first, new_last, session_id]
79
92
  )
80
93
  else
81
- @db.execute(
94
+ db.execute(
82
95
  "INSERT INTO sessions (session_id, created_at, updated_at, first_event_at, last_event_at, metadata) VALUES (?, ?, ?, ?, ?, NULL)",
83
96
  [session_id, now, now, batch_min, batch_max]
84
97
  )
85
98
  end
86
99
 
87
- stmt = @db.prepare("INSERT INTO events (session_id, window_id, timestamp, data) VALUES (?, ?, ?, ?)")
100
+ stmt = db.prepare("INSERT INTO events (session_id, window_id, timestamp, type, data) VALUES (?, ?, ?, ?, ?)")
88
101
  begin
89
102
  events.each do |event|
90
- stmt.execute(session_id, window_id, event["timestamp"]&.to_f, JSON.generate(event))
103
+ type = event["type"]
104
+ stmt.execute(session_id, window_id, event["timestamp"]&.to_f,
105
+ type.is_a?(Integer) ? type : nil, PayloadCodec.encode(JSON.generate(event)))
91
106
  end
92
107
  ensure
93
108
  stmt.close
@@ -102,25 +117,82 @@ module Sentiero
102
117
 
103
118
  # Batched scan: avoids the base's get_session + get_events-per-window N+1.
104
119
  SCAN_IN_CHUNK = 500
120
+ # Sessions materialized at once per scan — bounds peak memory (an
121
+ # unchunked scan held every parsed payload before the first yield).
122
+ EVENT_LOAD_CHUNK = 25
105
123
 
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?
124
+ def each_session_events(limit: nil, since: nil, until_time: nil, types: nil)
125
+ unless block_given?
126
+ return enum_for(:each_session_events, limit: limit, since: since, until_time: until_time, types: types)
127
+ end
108
128
 
109
129
  cap = limit || limits.analytics_max_scan_sessions
110
130
  rows = scan_session_rows(cap, since, until_time)
111
131
  return if rows.empty?
112
132
 
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
133
+ # Summaries always describe the whole session, so a typed scan reports
134
+ # the same shape as a full one — including fully-filtered windows.
135
+ shapes = window_shapes(rows.map { |row| row["session_id"] })
117
136
 
118
- summary = scan_summary(row, windows)
119
- windows.each { |window_id, window_events| yield summary, window_id, window_events }
137
+ rows.each_slice(EVENT_LOAD_CHUNK) do |chunk|
138
+ events = events_by_session_window(chunk.map { |row| row["session_id"] }, types: types)
139
+
140
+ chunk.each do |row|
141
+ shape = shapes[row["session_id"]]
142
+ next unless shape
143
+
144
+ windows = events[row["session_id"]] || {}
145
+ starts = shape.transform_values { |stats| stats[:start] }
146
+ summary = scan_summary(row, shape.keys, shape.values.sum { |stats| stats[:count] }, starts)
147
+ shape.each_key { |window_id| yield summary, window_id, windows[window_id] || [] }
148
+ end
120
149
  end
121
150
  end
122
151
 
123
- def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil)
152
+ # Index-assisted aggregate: groups on the type column without ever
153
+ # reading a payload, unlike the base's parse-everything fallback.
154
+ def event_type_counts(limit: nil, since: nil, until_time: nil)
155
+ cap = limit || limits.analytics_max_scan_sessions
156
+ session_ids = scan_session_rows(cap, since, until_time).map { |row| row["session_id"] }
157
+
158
+ counts = {}
159
+ session_ids.each_slice(SCAN_IN_CHUNK) do |chunk|
160
+ placeholders = (["?"] * chunk.size).join(",")
161
+ db.execute(
162
+ "SELECT type, COUNT(*) AS cnt FROM events WHERE session_id IN (#{placeholders}) GROUP BY type",
163
+ chunk
164
+ ).each do |row|
165
+ key = row["type"]
166
+ counts[key] = (counts[key] || 0) + row["cnt"]
167
+ end
168
+ end
169
+ counts
170
+ end
171
+
172
+ # Payload-free daily activity series: buckets on the indexed timestamp
173
+ # column ('unixepoch' takes seconds, timestamps are ms).
174
+ def event_counts_by_day(limit: nil, since: nil, until_time: nil)
175
+ cap = limit || limits.analytics_max_scan_sessions
176
+ session_ids = scan_session_rows(cap, since, until_time).map { |row| row["session_id"] }
177
+
178
+ counts = {}
179
+ session_ids.each_slice(SCAN_IN_CHUNK) do |chunk|
180
+ placeholders = (["?"] * chunk.size).join(",")
181
+ db.execute(
182
+ "SELECT strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch') AS day, COUNT(*) AS cnt " \
183
+ "FROM events WHERE session_id IN (#{placeholders}) AND timestamp IS NOT NULL GROUP BY day",
184
+ chunk
185
+ ).each do |row|
186
+ next unless row["day"]
187
+
188
+ counts[row["day"]] = (counts[row["day"]] || 0) + row["cnt"]
189
+ end
190
+ end
191
+ counts
192
+ end
193
+
194
+ def list_sessions(limit:, offset: 0, since: nil, until_time: nil, sort_by: nil, search: nil,
195
+ min_duration_ms: nil, min_events: nil)
124
196
  where = Where.new
125
197
  where.add("s.updated_at >= ?", since.to_f) if since
126
198
  where.add("s.updated_at <= ?", until_time.to_f) if until_time
@@ -128,6 +200,9 @@ module Sentiero
128
200
  pattern = "%#{search}%"
129
201
  where.add("(s.session_id LIKE ? OR COALESCE(s.metadata, '') LIKE ?)", pattern, pattern)
130
202
  end
203
+ if min_duration_ms
204
+ where.add("s.first_event_at IS NOT NULL AND (s.last_event_at - s.first_event_at) >= ?", min_duration_ms)
205
+ end
131
206
 
132
207
  order_clause = case sort_by
133
208
  when "created_at"
@@ -138,6 +213,7 @@ module Sentiero
138
213
  "ORDER BY s.updated_at DESC"
139
214
  end
140
215
 
216
+ having = min_events ? "HAVING COUNT(e.id) >= ?" : ""
141
217
  sql = <<~SQL
142
218
  SELECT s.session_id, s.created_at, s.updated_at, s.first_event_at, s.last_event_at, s.metadata,
143
219
  COUNT(e.id) AS event_count
@@ -145,14 +221,17 @@ module Sentiero
145
221
  LEFT JOIN events e ON e.session_id = s.session_id
146
222
  #{where.clause}
147
223
  GROUP BY s.id
224
+ #{having}
148
225
  #{order_clause}
149
226
  LIMIT ? OFFSET ?
150
227
  SQL
151
228
 
152
- rows = @db.execute(sql, where.params + [limit, offset])
229
+ params = where.params
230
+ params += [min_events] if min_events
231
+ rows = db.execute(sql, params + [limit, offset])
153
232
 
154
233
  rows.map { |row|
155
- window_ids = @db.execute(
234
+ window_ids = db.execute(
156
235
  "SELECT DISTINCT window_id FROM events WHERE session_id = ?", [row["session_id"]]
157
236
  ).map { |window_row| window_row["window_id"] }
158
237
 
@@ -172,10 +251,10 @@ module Sentiero
172
251
  def get_session(session_id)
173
252
  validate_id!(session_id)
174
253
 
175
- row = @db.get_first_row("SELECT * FROM sessions WHERE session_id = ?", [session_id])
254
+ row = db.get_first_row("SELECT * FROM sessions WHERE session_id = ?", [session_id])
176
255
  return nil unless row
177
256
 
178
- window_stats = @db.execute(
257
+ window_stats = db.execute(
179
258
  "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
259
  [session_id]
181
260
  )
@@ -222,7 +301,7 @@ module Sentiero
222
301
  params << limit.to_i
223
302
  end
224
303
 
225
- @db.execute(sql, params).map { |event_row| JSON.parse(event_row["data"]) }
304
+ db.execute(sql, params).map { |event_row| JSON.parse(PayloadCodec.decode(event_row["data"])) }
226
305
  end
227
306
 
228
307
  def save_metadata(session_id, metadata)
@@ -231,13 +310,13 @@ module Sentiero
231
310
  validate_id!(session_id)
232
311
  validate_metadata!(metadata)
233
312
 
234
- @db.transaction do
235
- row = @db.get_first_row("SELECT metadata FROM sessions WHERE session_id = ?", [session_id])
313
+ db.transaction(:immediate) do
314
+ row = db.get_first_row("SELECT metadata FROM sessions WHERE session_id = ?", [session_id])
236
315
  return unless row
237
316
 
238
317
  existing = row["metadata"] ? JSON.parse(row["metadata"]) : {}
239
318
  merged = existing.merge(metadata.transform_keys(&:to_s))
240
- @db.execute("UPDATE sessions SET metadata = ? WHERE session_id = ?", [JSON.generate(merged), session_id])
319
+ db.execute("UPDATE sessions SET metadata = ? WHERE session_id = ?", [JSON.generate(merged), session_id])
241
320
  end
242
321
  nil
243
322
  end
@@ -245,11 +324,11 @@ module Sentiero
245
324
  def delete_session(session_id)
246
325
  validate_id!(session_id)
247
326
 
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])
327
+ db.transaction(:immediate) do
328
+ db.execute("DELETE FROM events WHERE session_id = ?", [session_id])
329
+ db.execute("DELETE FROM sessions WHERE session_id = ?", [session_id])
330
+ db.execute("DELETE FROM occurrences WHERE session_id = ?", [session_id])
331
+ db.execute("DELETE FROM server_events WHERE session_id = ?", [session_id])
253
332
  end
254
333
  nil
255
334
  end
@@ -258,12 +337,12 @@ module Sentiero
258
337
  validate_window_ref!(ref)
259
338
  session_id, window_id = ref.session_id, ref.window_id
260
339
 
261
- @db.transaction do
262
- @db.execute("DELETE FROM events WHERE session_id = ? AND window_id = ?", [session_id, window_id])
340
+ db.transaction(:immediate) do
341
+ db.execute("DELETE FROM events WHERE session_id = ? AND window_id = ?", [session_id, window_id])
263
342
 
264
- remaining = @db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
343
+ remaining = db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
265
344
  if remaining == 0
266
- @db.execute("DELETE FROM sessions WHERE session_id = ?", [session_id])
345
+ db.execute("DELETE FROM sessions WHERE session_id = ?", [session_id])
267
346
  end
268
347
  end
269
348
  nil
@@ -277,26 +356,26 @@ module Sentiero
277
356
  stored = occurrence.merge("id" => occ_id)
278
357
 
279
358
  # Native-SQL upsert mirroring the base new_problem_attrs/touched_problem_attrs.
280
- @db.transaction do
281
- existing = @db.get_first_row(
359
+ db.transaction(:immediate) do
360
+ existing = db.get_first_row(
282
361
  "SELECT count, first_seen, last_seen, status, resolved_at FROM problems WHERE fingerprint = ?", [fp]
283
362
  )
284
363
  if existing
285
364
  reopening = existing["status"] == "resolved"
286
- @db.execute(
365
+ db.execute(
287
366
  "UPDATE problems SET count = count + 1, first_seen = ?, last_seen = ?, message = ?, status = ?, resolved_at = ? WHERE fingerprint = ?",
288
367
  [[existing["first_seen"], ts].min, [existing["last_seen"], ts].max, occurrence["message"],
289
368
  reopening ? "open" : existing["status"], reopening ? nil : existing["resolved_at"], fp]
290
369
  )
291
370
  else
292
- @db.execute(
371
+ db.execute(
293
372
  "INSERT INTO problems (fingerprint, project, exception_class, title, message, count, status, first_seen, last_seen, resolved_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)",
294
373
  [fp, occurrence["project"], occurrence["exception_class"], build_problem_title(occurrence),
295
374
  occurrence["message"], 1, "open", ts, ts]
296
375
  )
297
376
  end
298
377
 
299
- @db.execute(
378
+ db.execute(
300
379
  "INSERT INTO occurrences (occurrence_id, fingerprint, session_id, timestamp, data) VALUES (?, ?, ?, ?, ?)",
301
380
  [occ_id, fp, occurrence["session_id"], ts, JSON.generate(stored)]
302
381
  )
@@ -323,13 +402,13 @@ module Sentiero
323
402
  when "count" then "ORDER BY count DESC"
324
403
  else "ORDER BY last_seen DESC"
325
404
  end
326
- @db.execute("SELECT * FROM problems #{where.clause} #{order} LIMIT ? OFFSET ?", where.params + [limit, offset])
405
+ db.execute("SELECT * FROM problems #{where.clause} #{order} LIMIT ? OFFSET ?", where.params + [limit, offset])
327
406
  .map { |row| problem_row_to_hash(row) }
328
407
  end
329
408
 
330
409
  def get_problem(problem_id)
331
410
  validate_id!(problem_id)
332
- row = @db.get_first_row("SELECT * FROM problems WHERE fingerprint = ?", [problem_id])
411
+ row = db.get_first_row("SELECT * FROM problems WHERE fingerprint = ?", [problem_id])
333
412
  row && problem_row_to_hash(row)
334
413
  end
335
414
 
@@ -343,7 +422,7 @@ module Sentiero
343
422
  sql += " LIMIT ?"
344
423
  params += [limit.to_i]
345
424
  end
346
- @db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
425
+ db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
347
426
  end
348
427
 
349
428
  # COUNT(*) on the (fingerprint, timestamp) index, no row materialization.
@@ -351,14 +430,14 @@ module Sentiero
351
430
  validate_id!(problem_id)
352
431
  where = Where.new.add("fingerprint = ?", problem_id)
353
432
  where.add("timestamp > ?", after.to_f) if after
354
- @db.get_first_value("SELECT COUNT(*) FROM occurrences #{where.clause}", where.params)
433
+ db.get_first_value("SELECT COUNT(*) FROM occurrences #{where.clause}", where.params)
355
434
  end
356
435
 
357
436
  def update_problem_status(problem_id, status)
358
437
  validate_id!(problem_id)
359
438
  validate_status!(status)
360
439
  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])
440
+ db.execute("UPDATE problems SET status = ?, resolved_at = ? WHERE fingerprint = ?", [status, resolved_at, problem_id])
362
441
  nil
363
442
  end
364
443
 
@@ -366,8 +445,8 @@ module Sentiero
366
445
  validate_server_event!(event)
367
446
  ev_id = SecureRandom.uuid
368
447
  stored = event.merge("id" => ev_id)
369
- @db.transaction do
370
- @db.execute(
448
+ db.transaction(:immediate) do
449
+ db.execute(
371
450
  "INSERT INTO server_events (event_id, project, name, level, session_id, timestamp, data) VALUES (?, ?, ?, ?, ?, ?, ?)",
372
451
  [ev_id, event["project"], event["name"], event["level"], event["session_id"], event["timestamp"].to_f, JSON.generate(stored)]
373
452
  )
@@ -378,7 +457,7 @@ module Sentiero
378
457
 
379
458
  def get_server_event(event_id)
380
459
  validate_id!(event_id)
381
- row = @db.get_first_row("SELECT data FROM server_events WHERE event_id = ?", [event_id])
460
+ row = db.get_first_row("SELECT data FROM server_events WHERE event_id = ?", [event_id])
382
461
  row && JSON.parse(row["data"])
383
462
  end
384
463
 
@@ -389,7 +468,7 @@ module Sentiero
389
468
  where.add("level = ?", level) if level
390
469
  where.add("session_id = ?", session_id) if session_id
391
470
  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])
471
+ db.execute("SELECT data FROM server_events #{where.clause} ORDER BY timestamp ASC LIMIT ?", where.params + [limit])
393
472
  .map { |row| JSON.parse(row["data"]) }
394
473
  end
395
474
 
@@ -401,7 +480,7 @@ module Sentiero
401
480
  sql += " LIMIT ?"
402
481
  params << limit.to_i
403
482
  end
404
- @db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
483
+ db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
405
484
  end
406
485
 
407
486
  def server_events_for_session(session_id, limit: nil)
@@ -412,7 +491,7 @@ module Sentiero
412
491
  sql += " LIMIT ?"
413
492
  params << limit.to_i
414
493
  end
415
- @db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
494
+ db.execute(sql, params).map { |row| JSON.parse(row["data"]) }
416
495
  end
417
496
 
418
497
  def session_ids_for_problem(problem_id, limit: nil)
@@ -423,16 +502,16 @@ module Sentiero
423
502
  sql += " LIMIT ?"
424
503
  params << limit.to_i
425
504
  end
426
- @db.execute(sql, params).map { |row| row["session_id"] }
505
+ db.execute(sql, params).map { |row| row["session_id"] }
427
506
  end
428
507
 
429
508
  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")
509
+ db.transaction(:immediate) do
510
+ db.execute("DELETE FROM events")
511
+ db.execute("DELETE FROM sessions")
512
+ db.execute("DELETE FROM problems")
513
+ db.execute("DELETE FROM occurrences")
514
+ db.execute("DELETE FROM server_events")
436
515
  end
437
516
  nil
438
517
  end
@@ -441,21 +520,21 @@ module Sentiero
441
520
  cutoff = Time.now.to_f - seconds
442
521
  session_count = nil
443
522
 
444
- @db.transaction do
445
- @db.execute(
523
+ db.transaction(:immediate) do
524
+ db.execute(
446
525
  "DELETE FROM events WHERE session_id IN (SELECT session_id FROM sessions WHERE updated_at < ?)",
447
526
  [cutoff]
448
527
  )
449
- @db.execute("DELETE FROM sessions WHERE updated_at < ?", [cutoff])
450
- session_count = @db.changes
528
+ db.execute("DELETE FROM sessions WHERE updated_at < ?", [cutoff])
529
+ session_count = db.changes
451
530
 
452
- @db.execute("DELETE FROM server_events WHERE timestamp < ?", [cutoff])
453
- @db.execute("DELETE FROM occurrences WHERE timestamp < ?", [cutoff])
454
- @db.execute(
531
+ db.execute("DELETE FROM server_events WHERE timestamp < ?", [cutoff])
532
+ db.execute("DELETE FROM occurrences WHERE timestamp < ?", [cutoff])
533
+ db.execute(
455
534
  "DELETE FROM occurrences WHERE fingerprint IN (SELECT fingerprint FROM problems WHERE last_seen < ?)",
456
535
  [cutoff]
457
536
  )
458
- @db.execute("DELETE FROM problems WHERE last_seen < ?", [cutoff])
537
+ db.execute("DELETE FROM problems WHERE last_seen < ?", [cutoff])
459
538
  end
460
539
 
461
540
  session_count
@@ -471,56 +550,89 @@ module Sentiero
471
550
  where = Where.new
472
551
  where.add("updated_at >= ?", since.to_f) if since
473
552
  where.add("updated_at <= ?", until_time.to_f) if until_time
474
- @db.execute(
553
+ db.execute(
475
554
  "SELECT session_id, created_at, updated_at, first_event_at, last_event_at, metadata " \
476
555
  "FROM sessions #{where.clause} ORDER BY updated_at DESC LIMIT ?", where.params + [cap]
477
556
  )
478
557
  end
479
558
 
480
- def events_by_session_window(session_ids)
559
+ def events_by_session_window(session_ids, types: nil)
560
+ type_clause = ""
561
+ type_params = []
562
+ if types
563
+ type_clause = " AND type IN (#{(["?"] * types.size).join(",")})"
564
+ type_params = types.map(&:to_i)
565
+ end
566
+
481
567
  grouped = Hash.new { |h, sid| h[sid] = {} }
482
568
  session_ids.each_slice(SCAN_IN_CHUNK) do |chunk|
483
569
  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
570
+ db.execute(
571
+ "SELECT session_id, window_id, data FROM events WHERE session_id IN (#{placeholders})#{type_clause} ORDER BY timestamp ASC",
572
+ chunk + type_params
487
573
  ).each do |row|
488
- (grouped[row["session_id"]][row["window_id"]] ||= []) << JSON.parse(row["data"])
574
+ (grouped[row["session_id"]][row["window_id"]] ||= []) << JSON.parse(PayloadCodec.decode(row["data"]))
489
575
  end
490
576
  end
491
577
  grouped
492
578
  end
493
579
 
494
- def scan_summary(row, windows)
580
+ # {session_id => {window_id => {count:, start:}}} — index-only, so a
581
+ # typed scan reports full-session summaries without reading payloads.
582
+ def window_shapes(session_ids)
583
+ shapes = Hash.new { |h, sid| h[sid] = {} }
584
+ session_ids.each_slice(SCAN_IN_CHUNK) do |chunk|
585
+ placeholders = (["?"] * chunk.size).join(",")
586
+ db.execute(
587
+ "SELECT session_id, window_id, COUNT(*) AS cnt, MIN(timestamp) AS min_ts FROM events WHERE session_id IN (#{placeholders}) GROUP BY session_id, window_id",
588
+ chunk
589
+ ).each do |row|
590
+ shapes[row["session_id"]][row["window_id"]] = {count: row["cnt"], start: row["min_ts"]}
591
+ end
592
+ end
593
+ shapes
594
+ end
595
+
596
+ def scan_summary(row, window_ids, event_count, window_starts)
495
597
  summary_hash(
496
598
  session_id: row["session_id"],
497
- window_ids: windows.keys,
498
- event_count: windows.values.sum(&:size),
599
+ window_ids: window_ids,
600
+ event_count: event_count,
499
601
  created_at: row["created_at"],
500
602
  updated_at: row["updated_at"],
501
603
  first_event_at: row["first_event_at"],
502
604
  last_event_at: row["last_event_at"],
503
605
  metadata: row["metadata"] && JSON.parse(row["metadata"])
504
- )
606
+ ).merge(window_starts: window_starts)
505
607
  end
506
608
 
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
609
+ def db
610
+ return @shared_db if @in_memory
611
+
612
+ Thread.current[@tls_key] ||= create_connection
613
+ end
614
+
615
+ def create_connection
616
+ conn = ::SQLite3::Database.new(@path)
617
+ conn.results_as_hash = true
618
+ # busy_handler_timeout (not busy_timeout): releases the GVL between
619
+ # retries, so a thread waiting on another connection's write lock can't
620
+ # starve the very thread that must finish to release it.
621
+ conn.busy_handler_timeout = 5_000
622
+ conn.execute("PRAGMA journal_mode=WAL")
623
+ conn.execute("PRAGMA foreign_keys=ON")
624
+ conn
513
625
  end
514
626
 
515
627
  def enforce_max_events(session_id)
516
628
  max_events = limits.max_events_per_session
517
629
  return unless max_events
518
630
 
519
- total = @db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
631
+ total = db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
520
632
  return unless total > max_events
521
633
 
522
634
  excess = total - max_events
523
- @db.execute(
635
+ db.execute(
524
636
  "DELETE FROM events WHERE id IN (SELECT id FROM events WHERE session_id = ? ORDER BY timestamp ASC LIMIT ?)",
525
637
  [session_id, excess]
526
638
  )
@@ -530,18 +642,18 @@ module Sentiero
530
642
  max_sessions = limits.max_sessions
531
643
  return unless max_sessions
532
644
 
533
- total = @db.get_first_value("SELECT COUNT(*) FROM sessions")
645
+ total = db.get_first_value("SELECT COUNT(*) FROM sessions")
534
646
  return unless total > max_sessions
535
647
 
536
648
  to_evict = total - max_sessions
537
- oldest = @db.execute(
649
+ oldest = db.execute(
538
650
  "SELECT session_id FROM sessions WHERE session_id != ? ORDER BY updated_at ASC LIMIT ?",
539
651
  [protected_session_id, to_evict]
540
652
  ).map { |session_row| session_row["session_id"] }
541
653
 
542
654
  oldest.each do |sid|
543
- @db.execute("DELETE FROM events WHERE session_id = ?", [sid])
544
- @db.execute("DELETE FROM sessions WHERE session_id = ?", [sid])
655
+ db.execute("DELETE FROM events WHERE session_id = ?", [sid])
656
+ db.execute("DELETE FROM sessions WHERE session_id = ?", [sid])
545
657
  end
546
658
  end
547
659
 
@@ -566,14 +678,14 @@ module Sentiero
566
678
  max = limits.max_problems
567
679
  return unless max
568
680
 
569
- total = @db.get_first_value("SELECT COUNT(*) FROM problems")
681
+ total = db.get_first_value("SELECT COUNT(*) FROM problems")
570
682
  return unless total > max
571
683
 
572
684
  excess = total - max
573
- fps = @db.execute("SELECT fingerprint FROM problems ORDER BY last_seen ASC LIMIT ?", [excess]).map { |r| r["fingerprint"] }
685
+ fps = db.execute("SELECT fingerprint FROM problems ORDER BY last_seen ASC LIMIT ?", [excess]).map { |r| r["fingerprint"] }
574
686
  fps.each do |fp|
575
- @db.execute("DELETE FROM occurrences WHERE fingerprint = ?", [fp])
576
- @db.execute("DELETE FROM problems WHERE fingerprint = ?", [fp])
687
+ db.execute("DELETE FROM occurrences WHERE fingerprint = ?", [fp])
688
+ db.execute("DELETE FROM problems WHERE fingerprint = ?", [fp])
577
689
  end
578
690
  end
579
691
 
@@ -581,11 +693,11 @@ module Sentiero
581
693
  max = limits.max_server_events
582
694
  return unless max
583
695
 
584
- total = @db.get_first_value("SELECT COUNT(*) FROM server_events")
696
+ total = db.get_first_value("SELECT COUNT(*) FROM server_events")
585
697
  return unless total > max
586
698
 
587
699
  excess = total - max
588
- @db.execute("DELETE FROM server_events WHERE id IN (SELECT id FROM server_events ORDER BY timestamp ASC LIMIT ?)", [excess])
700
+ db.execute("DELETE FROM server_events WHERE id IN (SELECT id FROM server_events ORDER BY timestamp ASC LIMIT ?)", [excess])
589
701
  end
590
702
 
591
703
  # Rack hands URL path segments over as ASCII-8BIT strings, and sqlite3
@@ -605,19 +717,22 @@ module Sentiero
605
717
  end
606
718
  end
607
719
 
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).
720
+ # File-backed stores are thread-safe via per-thread WAL connections (see
721
+ # #db); in-memory stores share one connection, so their public ops
722
+ # serialize through the reentrant @monitor (reentrant: save_occurrence
723
+ # -> save_metadata). Wrap-all, defined last, so new methods stay
724
+ # covered; the wrapper also applies utf8_arg to every argument.
614
725
  synchronized = public_instance_methods(false)
615
726
  prepend(Module.new do
616
727
  synchronized.each do |method_name|
617
728
  define_method(method_name) do |*args, **kwargs, &block|
618
729
  args = args.map { |arg| utf8_arg.call(arg) }
619
730
  kwargs = kwargs.transform_values { |value| utf8_arg.call(value) }
620
- @monitor.synchronize { super(*args, **kwargs, &block) }
731
+ if @in_memory
732
+ @monitor.synchronize { super(*args, **kwargs, &block) }
733
+ else
734
+ super(*args, **kwargs, &block)
735
+ end
621
736
  end
622
737
  end
623
738
  end)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sentiero
4
- VERSION = "1.0.0.alpha1"
4
+ VERSION = "1.0.0.beta3"
5
5
  end