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.
- checksums.yaml +4 -4
- data/README.md +15 -13
- data/lib/sentiero/analytics/analyzer.rb +16 -4
- data/lib/sentiero/analytics/browser_event_discovery.rb +2 -2
- data/lib/sentiero/analytics/conversion_analyzer.rb +1 -1
- data/lib/sentiero/analytics/engagement_analyzer.rb +1 -1
- data/lib/sentiero/analytics/error_discovery.rb +9 -2
- data/lib/sentiero/analytics/exporter.rb +1 -1
- data/lib/sentiero/analytics/frustration_analyzer.rb +1 -1
- data/lib/sentiero/analytics/funnel_analyzer.rb +2 -2
- data/lib/sentiero/analytics/heatmap_analyzer.rb +3 -3
- data/lib/sentiero/analytics/page_report_analyzer.rb +2 -2
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +1 -1
- data/lib/sentiero/analytics/segmenter.rb +16 -2
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +2 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +45 -6
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +2 -2
- data/lib/sentiero/configuration.rb +29 -0
- data/lib/sentiero/fingerprint/config.rb +40 -0
- data/lib/sentiero/fingerprint.rb +45 -11
- data/lib/sentiero/geo.rb +86 -0
- data/lib/sentiero/reporter.rb +2 -1
- data/lib/sentiero/store/session_store.rb +62 -3
- data/lib/sentiero/store.rb +7 -0
- data/lib/sentiero/stores/file.rb +5 -1
- data/lib/sentiero/stores/memory.rb +15 -3
- data/lib/sentiero/stores/redis.rb +17 -5
- data/lib/sentiero/stores/sqlite/payload_codec.rb +42 -0
- data/lib/sentiero/stores/sqlite/schema.rb +72 -6
- data/lib/sentiero/stores/sqlite.rb +216 -101
- data/lib/sentiero/version.rb +1 -1
- data/lib/sentiero/web/analytics_app.rb +10 -0
- data/lib/sentiero/web/assets/dashboard-YINQE5ZH.js +3 -0
- data/lib/sentiero/web/assets/manifest.json +4 -4
- data/lib/sentiero/web/assets/recorder-W2RCU5VM.js +78 -0
- data/lib/sentiero/web/assets/rrweb-player-d522d74c.js +201 -0
- data/lib/sentiero/web/assets/{style-d71e72fd.css → style-80e536e6.css} +1 -1
- data/lib/sentiero/web/body_reader.rb +21 -10
- data/lib/sentiero/web/dashboard_app.rb +77 -5
- data/lib/sentiero/web/errors_app.rb +14 -1
- data/lib/sentiero/web/events_app.rb +6 -1
- data/lib/sentiero/web/ingest_app.rb +1 -1
- data/lib/sentiero/web/templates/client_error_show.html.erb +6 -1
- data/lib/sentiero/web/templates/dashboard.html.erb +7 -0
- data/lib/sentiero/web/templates/maintenance.html.erb +50 -0
- data/lib/sentiero/web/templates/segments.html.erb +9 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +12 -4
- data/lib/sentiero/web/views/analytics_index_view.rb +7 -1
- data/lib/sentiero/web/views/client_error_show_view.rb +1 -1
- data/lib/sentiero/web/views/maintenance_view.rb +27 -0
- data/lib/sentiero/web/views/segments_view.rb +3 -2
- data/lib/sentiero/web/views/sessions_index_view.rb +6 -2
- data/lib/sentiero/web/views.rb +1 -0
- data/lib/sentiero.rb +1 -0
- metadata +10 -5
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +0 -3
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +0 -71
- 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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
70
|
-
existing =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
row =
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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 =
|
|
343
|
+
remaining = db.get_first_value("SELECT COUNT(*) FROM events WHERE session_id = ?", [session_id])
|
|
265
344
|
if remaining == 0
|
|
266
|
-
|
|
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
|
-
|
|
281
|
-
existing =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
+
db.execute(sql, params).map { |row| row["session_id"] }
|
|
427
506
|
end
|
|
428
507
|
|
|
429
508
|
def clear!
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
|
|
450
|
-
session_count =
|
|
528
|
+
db.execute("DELETE FROM sessions WHERE updated_at < ?", [cutoff])
|
|
529
|
+
session_count = db.changes
|
|
451
530
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
498
|
-
event_count:
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
544
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
576
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
609
|
-
#
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
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
|
-
@
|
|
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)
|
data/lib/sentiero/version.rb
CHANGED