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.
- checksums.yaml +7 -0
- data/LICENSE.txt +7 -0
- data/README.md +679 -0
- data/lib/sentiero/analytics/analyzer.rb +91 -0
- data/lib/sentiero/analytics/bounded.rb +29 -0
- data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
- data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
- data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
- data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
- data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
- data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
- data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
- data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
- data/lib/sentiero/analytics/entry_attribution.rb +71 -0
- data/lib/sentiero/analytics/error_discovery.rb +118 -0
- data/lib/sentiero/analytics/events.rb +21 -0
- data/lib/sentiero/analytics/exporter.rb +242 -0
- data/lib/sentiero/analytics/form_analyzer.rb +153 -0
- data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
- data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
- data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
- data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
- data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
- data/lib/sentiero/analytics/problem_detail.rb +97 -0
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
- data/lib/sentiero/analytics/segmenter.rb +133 -0
- data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
- data/lib/sentiero/analytics/stats.rb +30 -0
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
- data/lib/sentiero/configuration.rb +184 -0
- data/lib/sentiero/erasure.rb +48 -0
- data/lib/sentiero/fingerprint.rb +34 -0
- data/lib/sentiero/ip_anonymizer.rb +29 -0
- data/lib/sentiero/redaction/config.rb +61 -0
- data/lib/sentiero/redaction.rb +207 -0
- data/lib/sentiero/reporter/configuration.rb +50 -0
- data/lib/sentiero/reporter/context.rb +31 -0
- data/lib/sentiero/reporter/dispatcher.rb +91 -0
- data/lib/sentiero/reporter/http_transport.rb +57 -0
- data/lib/sentiero/reporter/log_transport.rb +26 -0
- data/lib/sentiero/reporter/middleware.rb +62 -0
- data/lib/sentiero/reporter/normalizer.rb +14 -0
- data/lib/sentiero/reporter/null_transport.rb +18 -0
- data/lib/sentiero/reporter/report_context.rb +29 -0
- data/lib/sentiero/reporter/scrubber.rb +47 -0
- data/lib/sentiero/reporter/test_helper.rb +32 -0
- data/lib/sentiero/reporter/test_transport.rb +28 -0
- data/lib/sentiero/reporter.rb +214 -0
- data/lib/sentiero/roda.rb +47 -0
- data/lib/sentiero/store/error_store.rb +220 -0
- data/lib/sentiero/store/limits.rb +31 -0
- data/lib/sentiero/store/session_store.rb +118 -0
- data/lib/sentiero/store.rb +72 -0
- data/lib/sentiero/stores/file.rb +566 -0
- data/lib/sentiero/stores/memory.rb +362 -0
- data/lib/sentiero/stores/redis/keys.rb +59 -0
- data/lib/sentiero/stores/redis/lua.rb +119 -0
- data/lib/sentiero/stores/redis.rb +665 -0
- data/lib/sentiero/stores/sqlite/schema.rb +79 -0
- data/lib/sentiero/stores/sqlite.rb +626 -0
- data/lib/sentiero/user_agent.rb +32 -0
- data/lib/sentiero/version.rb +5 -0
- data/lib/sentiero/web/analytics_app.rb +538 -0
- data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
- data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
- data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
- data/lib/sentiero/web/assets/manifest.json +11 -0
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
- data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
- data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
- data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
- data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
- data/lib/sentiero/web/assets_app.rb +42 -0
- data/lib/sentiero/web/base_app.rb +319 -0
- data/lib/sentiero/web/basic_auth.rb +27 -0
- data/lib/sentiero/web/basic_auth_check.rb +41 -0
- data/lib/sentiero/web/body_reader.rb +44 -0
- data/lib/sentiero/web/csv_writer.rb +45 -0
- data/lib/sentiero/web/dashboard_app.rb +236 -0
- data/lib/sentiero/web/errors_app.rb +97 -0
- data/lib/sentiero/web/escaping.rb +37 -0
- data/lib/sentiero/web/events_app.rb +196 -0
- data/lib/sentiero/web/formatting.rb +43 -0
- data/lib/sentiero/web/ingest_app.rb +92 -0
- data/lib/sentiero/web/manifest.rb +43 -0
- data/lib/sentiero/web/monitoring_app.rb +316 -0
- data/lib/sentiero/web/script_tag.rb +57 -0
- data/lib/sentiero/web/shareable_replay.rb +88 -0
- data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
- data/lib/sentiero/web/templates/_brand.html.erb +18 -0
- data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
- data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
- data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
- data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
- data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
- data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
- data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
- data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
- data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
- data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
- data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
- data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
- data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
- data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
- data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
- data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
- data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
- data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
- data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
- data/lib/sentiero/web/templates/event_show.html.erb +52 -0
- data/lib/sentiero/web/templates/events_index.html.erb +177 -0
- data/lib/sentiero/web/templates/export_index.html.erb +69 -0
- data/lib/sentiero/web/templates/forms.html.erb +105 -0
- data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
- data/lib/sentiero/web/templates/import.html.erb +39 -0
- data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
- data/lib/sentiero/web/templates/segments.html.erb +114 -0
- data/lib/sentiero/web/templates/session_show.html.erb +195 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
- data/lib/sentiero/web/track_app.rb +57 -0
- data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
- data/lib/sentiero/web/views/analyzer_view.rb +27 -0
- data/lib/sentiero/web/views/base_view.rb +76 -0
- data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
- data/lib/sentiero/web/views/conversions_view.rb +41 -0
- data/lib/sentiero/web/views/engagement_view.rb +67 -0
- data/lib/sentiero/web/views/errors_index_view.rb +37 -0
- data/lib/sentiero/web/views/event_show_view.rb +20 -0
- data/lib/sentiero/web/views/events_index_view.rb +56 -0
- data/lib/sentiero/web/views/export_view.rb +23 -0
- data/lib/sentiero/web/views/forms_view.rb +28 -0
- data/lib/sentiero/web/views/frustration_view.rb +15 -0
- data/lib/sentiero/web/views/funnel_view.rb +36 -0
- data/lib/sentiero/web/views/heatmap_view.rb +34 -0
- data/lib/sentiero/web/views/import_view.rb +13 -0
- data/lib/sentiero/web/views/page_report_view.rb +43 -0
- data/lib/sentiero/web/views/problem_show_view.rb +46 -0
- data/lib/sentiero/web/views/scroll_view.rb +23 -0
- data/lib/sentiero/web/views/segments_view.rb +28 -0
- data/lib/sentiero/web/views/session_show_view.rb +105 -0
- data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
- data/lib/sentiero/web/views/vitals_view.rb +45 -0
- data/lib/sentiero/web/views.rb +24 -0
- data/lib/sentiero/window_ref.rb +6 -0
- data/lib/sentiero.rb +69 -0
- 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
|